Files
computer-playbook/tests/unit/python/utils/install/lint/test_mermaid_cli.py
T
kevinveenbirkenbachandClaude Opus 5.5 fad0c60e52 fix(lint): provision mmdc's browser libraries off apt and fail hard
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>
2026-10-15 23:45:54 +02:00

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()