mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
On the Fedora, CentOS Stream and Manjaro workspace jobs install-lint
ran `puppeteer browsers install chrome-headless-shell --install-deps`
as root, and puppeteer rejected it with "apt-get does not seem to be
available". The installer only warned ("optional lint tool"), so
install-lint passed while mmdc had no browser.
--install-deps is now passed only where apt-get exists. Everywhere else
the libraries `ldd` reports missing are installed by soname: dnf and
yum through the RPM provide `<soname>()(64bit)`, pacman through the
first package `pacman -F usr/lib/<soname>` names (libgbm.so.1 is owned
by both mesa and mesa-amber, which conflict). browser_installed() also
requires every library to resolve, so the install-lint stamp stays
stale until the browser can actually load.
A failed npm install, a missing mmdc, a failed or timed-out browser
download and unresolved libraries now raise instead of warning.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""Unit tests for :mod:`utils.install.lint.mermaid_cli`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import subprocess
|
|
import unittest
|
|
import unittest.mock as mock
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.install.lint import mermaid_cli
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
REVISION = "155.0.8059.39"
|
|
LDD = (
|
|
"\tlinux-vdso.so.1 (0x00007f5999895000)\n"
|
|
"\tlibnss3.so => not found\n"
|
|
"\tlibglib-2.0.so.0 => /usr/lib/libglib-2.0.so.0 (0x00007f598d09d000)\n"
|
|
"\tlibgbm.so.1 => not found\n"
|
|
"\tlibnss3.so => not found\n"
|
|
)
|
|
|
|
|
|
def _done(returncode: int, stdout: str = "out\n") -> subprocess.CompletedProcess[str]:
|
|
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="err\n")
|
|
|
|
|
|
def _write_core(node_modules: Path, revision: str) -> None:
|
|
revisions = node_modules / "puppeteer-core" / "lib" / "puppeteer"
|
|
revisions.mkdir(parents=True)
|
|
(revisions / "revisions.js").write_text(
|
|
f"export const PUPPETEER_REVISIONS = Object.freeze({{\n"
|
|
f" chrome: '155.0.8059.39',\n"
|
|
f" 'chrome-headless-shell': '{revision}',\n"
|
|
f"}});\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _fake_mmdc(
|
|
root: Path, *, revision: str | None = REVISION, nested: str | None = None
|
|
) -> Path:
|
|
"""Lay out a global mermaid-cli install the way npm does.
|
|
|
|
Args:
|
|
root: npm prefix to build the install under.
|
|
revision: chrome-headless-shell revision of the puppeteer-core beside
|
|
puppeteer, or None to install mmdc without a puppeteer next to it.
|
|
nested: revision of a puppeteer-core nested under puppeteer, as npm
|
|
lays it out when mermaid-cli pins another puppeteer-core.
|
|
|
|
Returns:
|
|
The ``bin/mmdc`` symlink.
|
|
"""
|
|
package = root / "lib" / "node_modules" / "@mermaid-js" / "mermaid-cli"
|
|
(package / "src").mkdir(parents=True)
|
|
(package / "src" / "cli.js").write_text("", encoding="utf-8")
|
|
if revision is not None:
|
|
(package / "node_modules" / "puppeteer").mkdir(parents=True)
|
|
_write_core(package / "node_modules", revision)
|
|
if nested is not None:
|
|
_write_core(package / "node_modules" / "puppeteer" / "node_modules", nested)
|
|
(root / "bin").mkdir()
|
|
mmdc = root / "bin" / "mmdc"
|
|
mmdc.symlink_to(package / "src" / "cli.js")
|
|
return mmdc
|
|
|
|
|
|
def _install_browser(cache: Path, revision: str) -> Path:
|
|
build = cache / "chrome-headless-shell" / f"linux-{revision}" / "linux64"
|
|
build.mkdir(parents=True, exist_ok=True)
|
|
executable = build / "chrome-headless-shell"
|
|
executable.write_text("", encoding="utf-8")
|
|
return executable
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _environment(
|
|
*, revision: str | None = REVISION, nested: str | None = None
|
|
) -> Iterator[tuple[Path, Path]]:
|
|
"""Patch mmdc's location and the puppeteer cache onto temporary dirs.
|
|
|
|
Args:
|
|
revision: passed to :func:`_fake_mmdc`.
|
|
nested: passed to :func:`_fake_mmdc`.
|
|
|
|
Yields:
|
|
The npm prefix and the puppeteer cache directory.
|
|
"""
|
|
with TemporaryDirectory() as prefix, TemporaryDirectory() as cache:
|
|
mmdc = _fake_mmdc(Path(prefix), revision=revision, nested=nested)
|
|
with (
|
|
mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}),
|
|
mock.patch.object(mermaid_cli, "which", return_value=True),
|
|
mock.patch.object(mermaid_cli.shutil, "which", return_value=str(mmdc)),
|
|
mock.patch.object(mermaid_cli, "missing_libraries", return_value=[]),
|
|
):
|
|
yield Path(prefix), Path(cache)
|
|
|
|
|
|
class TestBrowserProvisioning(unittest.TestCase):
|
|
def provision(
|
|
self, *, euid: int, results: list[int], apt: bool = True
|
|
) -> list[mock._Call]:
|
|
"""Run ``ensure()`` with mmdc present and a stubbed puppeteer.
|
|
|
|
Args:
|
|
euid: effective uid the run pretends to have.
|
|
results: exit codes the successive puppeteer calls return; a 0
|
|
lays the pinned browser into the cache.
|
|
apt: whether apt-get is on the PATH.
|
|
|
|
Returns:
|
|
The recorded subprocess calls.
|
|
"""
|
|
calls: list[mock._Call] = []
|
|
codes = iter(results)
|
|
with _environment() as (_, cache):
|
|
|
|
def run(*args, **kwargs):
|
|
calls.append(mock.call(*args, **kwargs))
|
|
code = next(codes)
|
|
if code == 0:
|
|
_install_browser(cache, REVISION)
|
|
return _done(code)
|
|
|
|
with (
|
|
mock.patch.object(mermaid_cli.os, "geteuid", return_value=euid),
|
|
mock.patch.object(
|
|
mermaid_cli,
|
|
"which",
|
|
side_effect=lambda cmd: apt or cmd != "apt-get",
|
|
),
|
|
mock.patch.object(mermaid_cli.subprocess, "run", side_effect=run),
|
|
):
|
|
mermaid_cli.ensure()
|
|
return calls
|
|
|
|
def test_root_on_apt_installs_the_browser_with_its_libraries(self) -> None:
|
|
calls = self.provision(euid=0, results=[0])
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(
|
|
calls[0].args[0][-2:], ["chrome-headless-shell", "--install-deps"]
|
|
)
|
|
|
|
def test_root_without_apt_leaves_the_libraries_to_the_soname_install(
|
|
self,
|
|
) -> None:
|
|
calls = self.provision(euid=0, results=[0], apt=False)
|
|
self.assertNotIn("--install-deps", calls[0].args[0])
|
|
|
|
def test_the_browser_comes_from_the_puppeteer_next_to_mmdc(self) -> None:
|
|
calls = self.provision(euid=0, results=[0])
|
|
self.assertTrue(
|
|
calls[0]
|
|
.args[0][0]
|
|
.endswith("@mermaid-js/mermaid-cli/node_modules/.bin/puppeteer")
|
|
)
|
|
|
|
def test_a_user_installs_the_browser_without_libraries(self) -> None:
|
|
calls = self.provision(euid=1000, results=[0])
|
|
self.assertNotIn("--install-deps", calls[0].args[0])
|
|
|
|
def test_the_browser_install_downloads_despite_the_mmdc_skip_flag(self) -> None:
|
|
with mock.patch.dict(os.environ, {"PUPPETEER_SKIP_DOWNLOAD": "1"}):
|
|
calls = self.provision(euid=0, results=[0])
|
|
self.assertNotIn("PUPPETEER_SKIP_DOWNLOAD", calls[0].kwargs["env"])
|
|
|
|
def test_a_failed_install_clears_its_revision_and_retries_once(self) -> None:
|
|
seen: list[tuple[bool, bool]] = []
|
|
with _environment() as (_, cache):
|
|
partial = cache / "chrome-headless-shell" / f"linux-{REVISION}"
|
|
partial.mkdir(parents=True)
|
|
other = cache / "chrome-headless-shell" / "linux-155.0.8059.79"
|
|
other.mkdir(parents=True)
|
|
|
|
def run(*_args, **_kwargs):
|
|
seen.append((partial.exists(), other.exists()))
|
|
if len(seen) == 1:
|
|
return _done(1)
|
|
_install_browser(cache, REVISION)
|
|
return _done(0)
|
|
|
|
with mock.patch.object(mermaid_cli.subprocess, "run", side_effect=run):
|
|
mermaid_cli.ensure()
|
|
self.assertEqual(seen, [(True, True), (False, True)])
|
|
|
|
def test_a_second_failure_raises_with_puppeteers_output(self) -> None:
|
|
with self.assertRaisesRegex(RuntimeError, "out\nerr\n"):
|
|
self.provision(euid=0, results=[1, 1])
|
|
|
|
def test_the_pinned_browser_is_not_provisioned_again(self) -> None:
|
|
with _environment() as (_, cache):
|
|
_install_browser(cache, REVISION)
|
|
with mock.patch.object(mermaid_cli.subprocess, "run") as run:
|
|
mermaid_cli.ensure()
|
|
run.assert_not_called()
|
|
|
|
def test_a_browser_of_another_revision_is_replaced(self) -> None:
|
|
with _environment() as (_, cache):
|
|
_install_browser(cache, "155.0.8059.79")
|
|
|
|
def run(*_args, **_kwargs):
|
|
_install_browser(cache, REVISION)
|
|
return _done(0)
|
|
|
|
with mock.patch.object(
|
|
mermaid_cli.subprocess, "run", side_effect=run
|
|
) as stub:
|
|
mermaid_cli.ensure()
|
|
stub.assert_called_once()
|
|
|
|
def test_a_reported_success_without_the_browser_raises(self) -> None:
|
|
with (
|
|
_environment(),
|
|
mock.patch.object(mermaid_cli.subprocess, "run", return_value=_done(0)),
|
|
self.assertRaisesRegex(RuntimeError, "still missing"),
|
|
):
|
|
mermaid_cli.ensure()
|
|
|
|
def test_mmdc_without_its_own_puppeteer_raises(self) -> None:
|
|
with (
|
|
_environment(revision=None),
|
|
mock.patch.object(mermaid_cli.subprocess, "run") as run,
|
|
self.assertRaisesRegex(RuntimeError, "no puppeteer next to mmdc"),
|
|
):
|
|
mermaid_cli.ensure()
|
|
run.assert_not_called()
|
|
|
|
def test_a_timeout_propagates(self) -> None:
|
|
with (
|
|
_environment(),
|
|
mock.patch.object(
|
|
mermaid_cli.subprocess,
|
|
"run",
|
|
side_effect=subprocess.TimeoutExpired("puppeteer", 900),
|
|
),
|
|
self.assertRaises(subprocess.TimeoutExpired),
|
|
):
|
|
mermaid_cli.ensure()
|
|
|
|
|
|
class TestMmdcInstall(unittest.TestCase):
|
|
def test_a_failed_npm_install_propagates(self) -> None:
|
|
with (
|
|
mock.patch.object(mermaid_cli, "which", return_value=False),
|
|
mock.patch.object(mermaid_cli, "install_command_via_pkg"),
|
|
mock.patch.object(
|
|
mermaid_cli, "npm_install_global", side_effect=RuntimeError("npm")
|
|
),
|
|
mock.patch.object(mermaid_cli, "_ensure_browser") as browser,
|
|
self.assertRaisesRegex(RuntimeError, "npm"),
|
|
):
|
|
mermaid_cli.ensure()
|
|
browser.assert_not_called()
|
|
|
|
def test_mmdc_still_missing_after_npm_raises(self) -> None:
|
|
with (
|
|
mock.patch.object(mermaid_cli, "which", return_value=False),
|
|
mock.patch.object(mermaid_cli, "install_command_via_pkg"),
|
|
mock.patch.object(mermaid_cli, "npm_install_global"),
|
|
mock.patch.object(mermaid_cli, "_ensure_browser") as browser,
|
|
self.assertRaisesRegex(RuntimeError, "mmdc"),
|
|
):
|
|
mermaid_cli.ensure()
|
|
browser.assert_not_called()
|
|
|
|
|
|
class TestBrowserLibraries(unittest.TestCase):
|
|
def test_ldd_not_found_lines_become_sorted_sonames(self) -> None:
|
|
with mock.patch.object(
|
|
mermaid_cli.subprocess, "run", return_value=_done(0, LDD)
|
|
) as run:
|
|
missing = mermaid_cli.missing_libraries(Path("/opt/chs"))
|
|
self.assertEqual(missing, ["libgbm.so.1", "libnss3.so"])
|
|
self.assertEqual(run.call_args.args[0], ["ldd", "/opt/chs"])
|
|
|
|
def ensure_libraries(
|
|
self, *, manager: str, missing: list[list[str]], files: dict[str, str]
|
|
) -> mock.Mock:
|
|
"""Run the library step with stubbed ldd, package manager and file DB.
|
|
|
|
Args:
|
|
manager: the package manager the host reports.
|
|
missing: successive ``missing_libraries`` results.
|
|
files: ``pacman -Fq`` stdout per queried path.
|
|
|
|
Returns:
|
|
The stubbed ``run_privileged``.
|
|
"""
|
|
|
|
def query(argv, **_kwargs):
|
|
return _done(0, files.get(argv[-1], ""))
|
|
|
|
with (
|
|
mock.patch.object(mermaid_cli, "missing_libraries", side_effect=missing),
|
|
mock.patch.object(
|
|
mermaid_cli, "detect_package_manager", return_value=manager
|
|
),
|
|
mock.patch.object(mermaid_cli.subprocess, "run", side_effect=query),
|
|
mock.patch.object(mermaid_cli, "run_privileged") as privileged,
|
|
):
|
|
mermaid_cli._ensure_browser_libraries(Path("/opt/chs"))
|
|
return privileged
|
|
|
|
def test_resolved_libraries_install_nothing(self) -> None:
|
|
privileged = self.ensure_libraries(manager="dnf", missing=[[]], files={})
|
|
privileged.assert_not_called()
|
|
|
|
def test_the_redhat_family_installs_by_soname_provide(self) -> None:
|
|
privileged = self.ensure_libraries(
|
|
manager="dnf", missing=[["libgbm.so.1", "libnss3.so"], []], files={}
|
|
)
|
|
privileged.assert_called_once_with(
|
|
["dnf", "-y", "install", "libgbm.so.1()(64bit)", "libnss3.so()(64bit)"]
|
|
)
|
|
|
|
def test_arch_takes_the_first_package_owning_each_soname(self) -> None:
|
|
privileged = self.ensure_libraries(
|
|
manager="pacman",
|
|
missing=[["libgbm.so.1", "libnss3.so"], []],
|
|
files={
|
|
"usr/lib/libgbm.so.1": "extra/mesa\nextra/mesa-amber\n",
|
|
"usr/lib/libnss3.so": "core/nss\n",
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
privileged.call_args_list,
|
|
[
|
|
mock.call(["pacman", "-Fy"]),
|
|
mock.call(["pacman", "-Syu", "--noconfirm", "--needed", "mesa", "nss"]),
|
|
],
|
|
)
|
|
|
|
def test_apt_without_libraries_fails_without_installing(self) -> None:
|
|
with self.assertRaisesRegex(RuntimeError, "--install-deps"):
|
|
self.ensure_libraries(manager="apt-get", missing=[["libnss3.so"]], files={})
|
|
|
|
def test_libraries_still_missing_after_the_install_raise(self) -> None:
|
|
with self.assertRaisesRegex(RuntimeError, "still lacks libnss3.so"):
|
|
self.ensure_libraries(
|
|
manager="dnf", missing=[["libnss3.so"], ["libnss3.so"]], files={}
|
|
)
|
|
|
|
|
|
class TestBrowserInstalled(unittest.TestCase):
|
|
def test_only_the_pinned_revision_counts(self) -> None:
|
|
with _environment() as (_, cache):
|
|
_install_browser(cache, "155.0.8059.79")
|
|
self.assertFalse(mermaid_cli.browser_installed())
|
|
_install_browser(cache, REVISION)
|
|
self.assertTrue(mermaid_cli.browser_installed())
|
|
|
|
def test_a_browser_with_missing_libraries_is_not_installed(self) -> None:
|
|
with _environment() as (_, cache):
|
|
_install_browser(cache, REVISION)
|
|
with mock.patch.object(
|
|
mermaid_cli, "missing_libraries", return_value=["libnss3.so"]
|
|
):
|
|
self.assertFalse(mermaid_cli.browser_installed())
|
|
|
|
def test_the_revision_is_read_from_mmdcs_puppeteer_core(self) -> None:
|
|
with _environment():
|
|
self.assertEqual(mermaid_cli.browser_revision(), REVISION)
|
|
|
|
def test_a_puppeteer_core_nested_under_puppeteer_wins(self) -> None:
|
|
with _environment(revision="155.0.8059.79", nested=REVISION) as (_, cache):
|
|
self.assertEqual(mermaid_cli.browser_revision(), REVISION)
|
|
_install_browser(cache, REVISION)
|
|
self.assertTrue(mermaid_cli.browser_installed())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|