Files
computer-playbook/tests/unit/python/utils/install/lint/test_main.py
T
kevinveenbirkenbachandClaude Opus 5.5 c8cb7fda8c fix(lint): provision the mermaid browser in install-lint, not in the parallel lint run
make lint runs install-lint once and then dispatches its fifteen targets
in parallel. scripts/lint/mermaid.sh provisioned chrome-headless-shell
inside that parallel phase with `npx puppeteer browsers install
--install-deps`, whose `apt-get satisfy` carries no lock timeout. In a
fresh stack container another target held the apt lock, the shared
libraries stayed missing, the script only warned, and all 289 Markdown
files then failed on libnspr4.so (#795).

utils/install/lint/mermaid_cli.py now provisions the browser right after
mmdc, inside the serial install-lint step, as root with --install-deps
and as a user without it, because puppeteer refuses to install libraries
without root. A failed install deletes the chrome-headless-shell cache
folder and retries once under a 900 second timeout per attempt; a cut-off
download otherwise leaves a folder without its executable, and puppeteer
refuses every later install of that build. The install-lint stamp counts
as fresh only while the browser executable exists, so a recreated
container provisions it again.

mermaid.sh no longer calls apt-get or npx. It renders one minimal diagram
first and, when that fails, exits with mmdc's output instead of reporting
every Markdown file.

Verified: tests/unit/python/scripts/lint/test_mermaid.py stubs mmdc,
npx and apt-get. With a browser that cannot load libnspr4.so the script
exits 1 with that line, before any Markdown file renders, and mmdc is the
only tool it calls. Against main's mermaid.sh the same test fails: it
calls apt-get and npx and reports the files one by one. The installer and
stamp cases pass, shellcheck is clean.

Not verified: a full make lint in a recreated stack container. The race
reproduced in one of two runs in the issue.

feature/store-i18n changed the provisioning in mermaid.sh with 2f5f52bd1a
and dc67bce5f0 (timeout, deleting a cut-off browser). Both behaviours
moved into mermaid_cli.py here, so its merge takes this version of
mermaid.sh.

Closes #795

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:31:36 +02:00

174 lines
6.6 KiB
Python

"""Unit tests for :mod:`utils.install.lint.__main__`."""
from __future__ import annotations
import os
import time
import unittest
import unittest.mock as mock
from pathlib import Path
from tempfile import TemporaryDirectory
from utils.install.lint import __main__ as cli
def _make_repo_root(tmp: str, *, stamp_age_offset: float | None = None) -> Path:
root = Path(tmp)
(root / "scripts" / "install").mkdir(parents=True)
(root / "scripts" / "install" / "lint.sh").write_text("#\n", encoding="utf-8")
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
if stamp_age_offset is not None:
stamp = root / cli._STAMP
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.write_text("", encoding="utf-8")
if stamp_age_offset:
new_time = time.time() + stamp_age_offset
os.utime(stamp, (new_time, new_time))
return root
class TestStampLogic(unittest.TestCase):
def test_fresh_stamp_short_circuits(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=60.0)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli.shutil, "which", return_value="/usr/bin/x"),
mock.patch.object(
cli.mermaid_cli, "browser_installed", return_value=True
),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main([])
self.assertEqual(rc, 0)
inst.assert_not_called()
def test_missing_mermaid_browser_invalidates_stamp(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=60.0)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli.shutil, "which", return_value="/usr/bin/x"),
mock.patch.object(
cli.mermaid_cli, "browser_installed", return_value=False
),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main([])
self.assertEqual(rc, 0)
inst.assert_called_once()
def test_missing_tool_invalidates_stamp(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=60.0)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli.shutil, "which", return_value=None),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main([])
self.assertEqual(rc, 0)
inst.assert_called_once()
def test_stale_stamp_triggers_install(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=-60.0)
time.sleep(0.01)
(root / "scripts" / "install" / "lint.sh").write_text(
"#updated\n", encoding="utf-8"
)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main([])
self.assertEqual(rc, 0)
inst.assert_called_once()
self.assertTrue((root / cli._STAMP).exists())
def test_no_stamp_triggers_install_and_touches(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main([])
self.assertEqual(rc, 0)
inst.assert_called_once()
self.assertTrue((root / cli._STAMP).exists())
def test_force_drops_stamp(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=60.0)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli, "_install_all") as inst,
):
rc = cli.main(["--force"])
self.assertEqual(rc, 0)
inst.assert_called_once()
class TestGroupDispatch(unittest.TestCase):
def test_group_only_bypasses_stamp(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp, stamp_age_offset=60.0)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli, "_install_javascript_tools") as js,
mock.patch.object(cli, "_install_all") as inst_all,
):
rc = cli.main(["javascript"])
self.assertEqual(rc, 0)
js.assert_called_once()
inst_all.assert_not_called()
def test_group_does_not_touch_stamp(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp)
stamp = root / cli._STAMP
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli, "_install_javascript_tools"),
):
cli.main(["javascript"])
self.assertFalse(stamp.exists())
def test_ansible_group_calls_all_modules(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(cli.ansible_commands, "ensure") as a,
mock.patch.object(cli.ansible_collections, "ensure") as b,
mock.patch.object(cli.ansible_lint, "ensure") as c,
):
cli.main(["ansible"])
a.assert_called_once()
b.assert_called_once()
c.assert_called_once()
def test_unknown_group_returns_1(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp)
with mock.patch.object(cli, "PROJECT_ROOT", root):
rc = cli.main(["bogus"])
self.assertEqual(rc, 1)
def test_runtime_error_in_ensure_returns_1(self) -> None:
with TemporaryDirectory() as tmp:
root = _make_repo_root(tmp)
with (
mock.patch.object(cli, "PROJECT_ROOT", root),
mock.patch.object(
cli.eslint, "ensure", side_effect=RuntimeError("boom")
),
):
rc = cli.main(["javascript"])
self.assertEqual(rc, 1)
if __name__ == "__main__":
unittest.main()