From 4e5c01676c9e493412cc0ae6ea6e6bdfde0bf86b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 14 Oct 2026 13:35:15 +0200 Subject: [PATCH 1/7] fix(web-app-bridgy-fed): build on python 3.14 until cbor2 ships cp315 The updater moved the build image from python 3.14-slim to 3.15-slim (39d57d953b). Upstream's requirements.pinned.txt pins cbor2 6.1.4, which ships wheels up to cp314 only, so pip builds it from source, and its Rust extension needs a compiler the image does not have. The build failed with "can't find Rust compiler", was retried until the job's budget ran out, and killed Bridgy Fed#0 on #915 after 20460 s. Installing Debian's cargo does not help: cbor2 6.1.4 uses let chains, stable only from Rust 1.88, and trixie ships an older rustc. The image stays on 3.14-slim, with the docker-version marker so the updater leaves it there. Bridgy Fed#0 passed on 3.14-slim in run 38318560960, and the build stage builds on it with the cp314 wheel. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-bridgy-fed/meta/services.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/roles/web-app-bridgy-fed/meta/services.yml b/roles/web-app-bridgy-fed/meta/services.yml index fd40548d63..0575fca712 100644 --- a/roles/web-app-bridgy-fed/meta/services.yml +++ b/roles/web-app-bridgy-fed/meta/services.yml @@ -48,7 +48,8 @@ bridgy-fed: swarm: enabled: true image: python - version: 3.15-slim + # nocheck: docker-version upstream's requirements.pinned.txt pins cbor2 6.1.4, which ships wheels up to cp314 and needs Rust >= 1.88 to build from source + version: 3.14-slim name: web-app-bridgy-fed repository: https://github.com/snarfed/bridgy-fed.git # nocheck: repository-version -- upstream publishes no tags, so the pin is a commit From 45732329b6a51d5b64a021b49d019d72fedca235 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 14 Oct 2026 13:35:29 +0200 Subject: [PATCH 2/7] fix(lookup): keep the onion apex from redirecting to a clearnet homepage On a Tor node current_play_redirect_domains schedules the bare node onion as a redirect source targeting DOMAIN_HOMEPAGE. Without web-app-dashboard, DOMAIN_HOMEPAGE falls back to SOFTWARE_DOMAIN (infinito.nexus), so web-opt-rdr-domains rendered a 301 from http://.onion/ to http://infinito.nexus/: an onion visitor sent to a clearnet host over plain HTTP. The onion apex entry is now added only when DOMAIN_HOMEPAGE is an onion host. Otherwise the onion apex gets no redirect and the default server answers. Closes #924 Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/lookup/current_play_redirect_domains.py | 13 +++++++++---- .../lookup/test_current_play_redirect_domains.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/plugins/lookup/current_play_redirect_domains.py b/plugins/lookup/current_play_redirect_domains.py index e4317a0297..73d46715d7 100644 --- a/plugins/lookup/current_play_redirect_domains.py +++ b/plugins/lookup/current_play_redirect_domains.py @@ -125,9 +125,10 @@ class LookupModule(LookupBase): DOMAIN_PRIMARY even when no app claims it. On onion nodes (``svc-net-tor`` in ``group_names`` with a provisioned ``services.tor.node``) the bare node-onion apex is scheduled as an - additional source targeting the same ``DOMAIN_HOMEPAGE``, which is - already family-correct there (onion-injected domain lookup); both - entries require ``web-opt-rdr-domains`` in + additional source targeting the same ``DOMAIN_HOMEPAGE`` when that + is an onion host; a clearnet ``DOMAIN_HOMEPAGE`` (no dashboard + deployed) gets no onion apex entry, so an onion vhost never + redirects to clearnet. Both entries require ``web-opt-rdr-domains`` in ``lookup('deployment').deployed`` so isolated CI variants do not schedule a redirect whose target is not served. 3. per-app alias-to-canonical mappings from @@ -209,7 +210,11 @@ class LookupModule(LookupBase): primary_redirects.append( {"source": domain_primary, "target": domain_homepage} ) - if node_onion and node_onion != domain_homepage: + if ( + node_onion + and node_onion != domain_homepage + and domain_homepage.endswith(".onion") + ): primary_redirects.append( {"source": node_onion, "target": domain_homepage} ) diff --git a/tests/unit/python/plugins/lookup/test_current_play_redirect_domains.py b/tests/unit/python/plugins/lookup/test_current_play_redirect_domains.py index 76e92629e9..4ca9efbe04 100644 --- a/tests/unit/python/plugins/lookup/test_current_play_redirect_domains.py +++ b/tests/unit/python/plugins/lookup/test_current_play_redirect_domains.py @@ -334,6 +334,21 @@ class CurrentPlayRedirectDomainsLookupTests(unittest.TestCase): [{"source": "infinito.test", "target": "abcdefonion.onion"}], ) + def test_onion_apex_absent_when_homepage_is_clearnet(self): + lm = self._make_lookup() + variables = self._onion_variables( + DOMAIN_HOMEPAGE="infinito.nexus", + group_names=["svc-net-tor"], + ) + with self._patch_lookups( + deployed=["web-opt-rdr-domains"], current_play_apps={} + ): + result = lm.run(terms=[], variables=variables)[0] + self.assertEqual( + result, + [{"source": "infinito.test", "target": "infinito.nexus"}], + ) + def test_onion_apex_node_value_templated(self): class _ResolvingTemplar: def __init__(self, mapping): From c74a31e83e7304cdaa5671947c2111602dbe83ac Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 03:21:30 +0200 Subject: [PATCH 3/7] fix(lint): install the browser revision mmdc's own puppeteer pins install-lint provisioned chrome-headless-shell with `npx --yes puppeteer browsers install`, which runs the latest puppeteer from the registry. mmdc runs with the puppeteer npm installed next to it as a peer dependency when mmdc itself was installed, and looks for exactly the revision that puppeteer-core pins. Once puppeteer 25.14.0 was out, npx installed 155.0.8059.79 while mmdc with puppeteer-core 25.13.0 wanted 155.0.8059.39, and lint-mermaid failed on every file. Because browser_installed() accepted any chrome-headless-shell, a stack with the wrong revision never repaired itself. The browser is now installed through the puppeteer CLI next to mmdc, which installs its puppeteer-core's revision, and only that revision, read from puppeteer-core's revisions.js, counts as installed. Closes #957 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../utils/install/lint/test_mermaid_cli.py | 145 ++++++++++++++---- utils/install/lint/mermaid_cli.py | 56 ++++++- 2 files changed, 161 insertions(+), 40 deletions(-) diff --git a/tests/unit/python/utils/install/lint/test_mermaid_cli.py b/tests/unit/python/utils/install/lint/test_mermaid_cli.py index 600879cf61..243a8f8e46 100644 --- a/tests/unit/python/utils/install/lint/test_mermaid_cli.py +++ b/tests/unit/python/utils/install/lint/test_mermaid_cli.py @@ -2,20 +2,84 @@ 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" + def _done(returncode: int) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess([], returncode, stdout="out\n", stderr="err\n") +def _fake_mmdc(root: Path, *, revision: str | None = REVISION) -> 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 its puppeteer-core pins, or + None to install mmdc without a puppeteer next to it. + + 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) + revisions = package / "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", + ) + (root / "bin").mkdir() + mmdc = root / "bin" / "mmdc" + mmdc.symlink_to(package / "src" / "cli.js") + return mmdc + + +def _install_browser(cache: Path, revision: str) -> None: + build = cache / "chrome-headless-shell" / f"linux-{revision}" / "linux64" + build.mkdir(parents=True) + (build / "chrome-headless-shell").write_text("", encoding="utf-8") + + +@contextlib.contextmanager +def _environment(*, revision: str | None = REVISION) -> Iterator[tuple[Path, Path]]: + """Patch mmdc's location and the puppeteer cache onto temporary dirs. + + Args: + revision: 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) + 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)), + ): + yield Path(prefix), Path(cache) + + class TestBrowserProvisioning(unittest.TestCase): def provision(self, *, euid: int, results: list[int]) -> tuple[list, list[str]]: """Run ``ensure()`` with mmdc present and a stubbed puppeteer. @@ -29,9 +93,7 @@ class TestBrowserProvisioning(unittest.TestCase): """ warnings: list[str] = [] with ( - TemporaryDirectory() as cache, - mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}), - mock.patch.object(mermaid_cli, "which", return_value=True), + _environment(), mock.patch.object(mermaid_cli.os, "geteuid", return_value=euid), mock.patch.object(mermaid_cli, "warn", side_effect=warnings.append), mock.patch.object( @@ -51,6 +113,14 @@ class TestBrowserProvisioning(unittest.TestCase): ) self.assertEqual(warnings, []) + 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]) @@ -61,20 +131,16 @@ class TestBrowserProvisioning(unittest.TestCase): self.assertNotIn("PUPPETEER_SKIP_DOWNLOAD", calls[0].kwargs["env"]) def test_a_failed_install_clears_the_browser_cache_and_retries_once(self) -> None: - with TemporaryDirectory() as cache: - folder = Path(cache) / "chrome-headless-shell" / "linux-1" + seen: list[bool] = [] + with _environment() as (_, cache): + folder = cache / "chrome-headless-shell" / "linux-1" folder.mkdir(parents=True) - seen: list[bool] = [] def run(*_args, **_kwargs): seen.append(folder.exists()) return _done(1 if len(seen) == 1 else 0) - with ( - mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}), - mock.patch.object(mermaid_cli, "which", return_value=True), - mock.patch.object(mermaid_cli.subprocess, "run", side_effect=run), - ): + with mock.patch.object(mermaid_cli.subprocess, "run", side_effect=run): mermaid_cli.ensure() self.assertEqual(seen, [True, False]) @@ -84,28 +150,39 @@ class TestBrowserProvisioning(unittest.TestCase): self.assertEqual(len(warnings), 1) self.assertIn("out\nerr\n", warnings[0]) - def test_an_installed_browser_is_not_provisioned_again(self) -> None: - with TemporaryDirectory() as cache: - build = Path(cache) / "chrome-headless-shell" / "linux-1" / "linux64" - build.mkdir(parents=True) - (build / "chrome-headless-shell").write_text("", encoding="utf-8") - with ( - mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}), - mock.patch.object(mermaid_cli, "which", return_value=True), - mock.patch.object(mermaid_cli.subprocess, "run") as run, - ): + 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") + with mock.patch.object( + mermaid_cli.subprocess, "run", return_value=_done(0) + ) as run: + mermaid_cli.ensure() + run.assert_called_once() + + def test_mmdc_without_its_own_puppeteer_warns(self) -> None: + with ( + _environment(revision=None), + mock.patch.object(mermaid_cli, "warn") as warn, + mock.patch.object(mermaid_cli.subprocess, "run") as run, + ): + mermaid_cli.ensure() + run.assert_not_called() + warn.assert_called_once() + def test_a_timeout_warns_instead_of_raising(self) -> None: with ( - TemporaryDirectory() as cache, - mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}), - mock.patch.object(mermaid_cli, "which", return_value=True), + _environment(), mock.patch.object( mermaid_cli.subprocess, "run", - side_effect=subprocess.TimeoutExpired("npx", 900), + side_effect=subprocess.TimeoutExpired("puppeteer", 900), ), mock.patch.object(mermaid_cli, "warn") as warn, ): @@ -114,14 +191,16 @@ class TestBrowserProvisioning(unittest.TestCase): class TestBrowserInstalled(unittest.TestCase): - def test_the_executable_in_the_puppeteer_cache_counts(self) -> None: - with TemporaryDirectory() as cache: - build = Path(cache) / "chrome-headless-shell" / "linux-1" / "linux64" - build.mkdir(parents=True) - with mock.patch.dict(os.environ, {"PUPPETEER_CACHE_DIR": cache}): - self.assertFalse(mermaid_cli.browser_installed()) - (build / "chrome-headless-shell").write_text("", encoding="utf-8") - self.assertTrue(mermaid_cli.browser_installed()) + 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_the_revision_is_read_from_mmdcs_puppeteer_core(self) -> None: + with _environment(): + self.assertEqual(mermaid_cli.browser_revision(), REVISION) if __name__ == "__main__": diff --git a/utils/install/lint/mermaid_cli.py b/utils/install/lint/mermaid_cli.py index e60a96371a..2725779b70 100644 --- a/utils/install/lint/mermaid_cli.py +++ b/utils/install/lint/mermaid_cli.py @@ -1,7 +1,8 @@ """Install @mermaid-js/mermaid-cli (mmdc) and the chrome-headless-shell it renders with. mmdc comes from npm with ``PUPPETEER_SKIP_DOWNLOAD`` set; the browser is -provisioned through puppeteer afterwards, as root with its shared libraries +provisioned afterwards through the puppeteer that npm installed next to mmdc, +at the revision its puppeteer-core pins, as root with its shared libraries (``--install-deps``). ``unzip`` is ensured first because puppeteer extracts the browser with it. A failed install warns instead of aborting ``install-lint``, so sandboxes without npm or browser access still get the rest of the lint @@ -12,6 +13,7 @@ from __future__ import annotations import contextlib import os +import re import shutil import subprocess from pathlib import Path @@ -36,12 +38,48 @@ def browser_cache() -> Path: return Path(base) / BROWSER +def _mmdc_puppeteer() -> Path | None: + mmdc = shutil.which("mmdc") + if mmdc is None: + return None + for parent in Path(mmdc).resolve().parents: + puppeteer = parent / "node_modules" / "puppeteer" + if puppeteer.is_dir(): + return puppeteer + return None + + +def browser_revision() -> str | None: + puppeteer = _mmdc_puppeteer() + if puppeteer is None: + return None + revisions = ( + puppeteer.parent / "puppeteer-core" / "lib" / "puppeteer" / "revisions.js" + ) + try: + text = revisions.read_text(encoding="utf-8") + except OSError: + return None + match = re.search(rf"'{BROWSER}':\s*'([^']+)'", text) + return match.group(1) if match else None + + def browser_installed() -> bool: - return any(path.is_file() for path in browser_cache().glob(f"*/*/{BROWSER}")) + revision = browser_revision() + if revision is None: + return False + return any( + path.is_file() for path in browser_cache().glob(f"*-{revision}/*/{BROWSER}") + ) -def _provision_browser() -> subprocess.CompletedProcess[str]: - argv = ["npx", "--yes", "puppeteer", "browsers", "install", BROWSER] +def _provision_browser(puppeteer: Path) -> subprocess.CompletedProcess[str]: + argv = [ + str(puppeteer.parent / ".bin" / "puppeteer"), + "browsers", + "install", + BROWSER, + ] if os.geteuid() == 0: argv.append("--install-deps") env = {k: v for k, v in os.environ.items() if k != "PUPPETEER_SKIP_DOWNLOAD"} @@ -58,12 +96,16 @@ def _provision_browser() -> subprocess.CompletedProcess[str]: def _ensure_browser() -> None: if browser_installed(): return - log(f"Provisioning {BROWSER} for mmdc via puppeteer.") + puppeteer = _mmdc_puppeteer() + if puppeteer is None: + warn(f"{BROWSER} provisioning skipped: no puppeteer next to mmdc") + return + log(f"Provisioning {BROWSER} for mmdc via its own puppeteer.") try: - done = _provision_browser() + done = _provision_browser(puppeteer) if done.returncode != 0: shutil.rmtree(browser_cache(), ignore_errors=True) - done = _provision_browser() + done = _provision_browser(puppeteer) except (OSError, subprocess.TimeoutExpired) as exc: warn(f"{BROWSER} provisioning skipped (optional lint tool): {exc}") return From 64536382f06a445cefc602ed078501f9ccecc352 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 10:36:08 +0200 Subject: [PATCH 4/7] fix(lint): mark the puppeteer revisions read as outside the file cache test_no_uncached_file_reads flagged the read of puppeteer-core's revisions.js in CI. The file lives in the global npm prefix, outside the project tree, and a reinstall of mermaid-cli rewrites it, so the path-keyed read cache does not fit; the read keeps Path.read_text with the cache-read opt-out. Co-Authored-By: Claude Opus 5.5 (1M context) --- utils/install/lint/mermaid_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/install/lint/mermaid_cli.py b/utils/install/lint/mermaid_cli.py index 2725779b70..2cbf2398f0 100644 --- a/utils/install/lint/mermaid_cli.py +++ b/utils/install/lint/mermaid_cli.py @@ -57,7 +57,7 @@ def browser_revision() -> str | None: puppeteer.parent / "puppeteer-core" / "lib" / "puppeteer" / "revisions.js" ) try: - text = revisions.read_text(encoding="utf-8") + text = revisions.read_text(encoding="utf-8") # nocheck: cache-read npm prefix except OSError: return None match = re.search(rf"'{BROWSER}':\s*'([^']+)'", text) From e7e9bfe989d01a94dd1238b023ee465ea76fdcbb Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:54:21 +0000 Subject: [PATCH 5/7] update(versions): update pins with a declared source (web-app-n8n) --- roles/web-app-n8n/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-n8n/meta/services.yml b/roles/web-app-n8n/meta/services.yml index 46bb83d1b1..e1899e35c4 100644 --- a/roles/web-app-n8n/meta/services.yml +++ b/roles/web-app-n8n/meta/services.yml @@ -67,7 +67,7 @@ n8n: backup: no_stop_required: false image: docker.n8n.io/n8nio/n8n - version: "2.44.1" + version: "2.44.2" update: type: registry_tags image: docker.n8n.io/n8nio/n8n From 4aa6f54f4e1774060d12b57bdb03e71faa76245c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 11:01:47 +0200 Subject: [PATCH 6/7] docs(web-app-bridgy-fed): name the exit condition of the python 3.14 pin cbor2 6.1.5 ships cp315 wheels; the pin can return to 3.15-slim once the pinned upstream ref carries it. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-bridgy-fed/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-bridgy-fed/meta/services.yml b/roles/web-app-bridgy-fed/meta/services.yml index 0575fca712..c47b937e41 100644 --- a/roles/web-app-bridgy-fed/meta/services.yml +++ b/roles/web-app-bridgy-fed/meta/services.yml @@ -48,7 +48,7 @@ bridgy-fed: swarm: enabled: true image: python - # nocheck: docker-version upstream's requirements.pinned.txt pins cbor2 6.1.4, which ships wheels up to cp314 and needs Rust >= 1.88 to build from source + # nocheck: docker-version upstream's requirements.pinned.txt pins cbor2 6.1.4, which ships wheels up to cp314 and needs Rust >= 1.88 to build from source. TODO: return to 3.15-slim once the pinned upstream ref carries cbor2 >= 6.1.5 (cp315 wheels) version: 3.14-slim name: web-app-bridgy-fed repository: https://github.com/snarfed/bridgy-fed.git From 33bb7e99e954e4bbd78d7b5c2c8a43238167c5b0 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 15 Oct 2026 11:23:22 +0200 Subject: [PATCH 7/7] fix(lint): resolve puppeteer-core like node and keep other browser revisions - browser_revision() reads revisions.js from the puppeteer-core that node resolves for mmdc's puppeteer: puppeteer/node_modules first, then the node_modules directories above it. When mermaid-cli pins another puppeteer-core, npm nests puppeteer's own copy, and the sibling held a different revision. - A failed browser install removes only the directories of the pinned revision before the retry. Removing the whole chrome-headless-shell cache also deleted browsers that other puppeteer installs share. Addresses the CodeRabbit review on #958. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../utils/install/lint/test_mermaid_cli.py | 61 +++++++++++++------ utils/install/lint/mermaid_cli.py | 22 +++++-- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/tests/unit/python/utils/install/lint/test_mermaid_cli.py b/tests/unit/python/utils/install/lint/test_mermaid_cli.py index 243a8f8e46..5b66791177 100644 --- a/tests/unit/python/utils/install/lint/test_mermaid_cli.py +++ b/tests/unit/python/utils/install/lint/test_mermaid_cli.py @@ -23,13 +23,29 @@ def _done(returncode: int) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess([], returncode, stdout="out\n", stderr="err\n") -def _fake_mmdc(root: Path, *, revision: str | None = REVISION) -> Path: +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 its puppeteer-core pins, or - None to install mmdc without a puppeteer next to it. + 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. @@ -39,15 +55,9 @@ def _fake_mmdc(root: Path, *, revision: str | None = REVISION) -> Path: (package / "src" / "cli.js").write_text("", encoding="utf-8") if revision is not None: (package / "node_modules" / "puppeteer").mkdir(parents=True) - revisions = package / "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", - ) + _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") @@ -61,17 +71,20 @@ def _install_browser(cache: Path, revision: str) -> None: @contextlib.contextmanager -def _environment(*, revision: str | None = REVISION) -> Iterator[tuple[Path, Path]]: +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) + 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), @@ -130,19 +143,21 @@ class TestBrowserProvisioning(unittest.TestCase): calls, _ = self.provision(euid=0, results=[0]) self.assertNotIn("PUPPETEER_SKIP_DOWNLOAD", calls[0].kwargs["env"]) - def test_a_failed_install_clears_the_browser_cache_and_retries_once(self) -> None: - seen: list[bool] = [] + def test_a_failed_install_clears_its_revision_and_retries_once(self) -> None: + seen: list[tuple[bool, bool]] = [] with _environment() as (_, cache): - folder = cache / "chrome-headless-shell" / "linux-1" - folder.mkdir(parents=True) + 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(folder.exists()) + seen.append((partial.exists(), other.exists())) return _done(1 if len(seen) == 1 else 0) with mock.patch.object(mermaid_cli.subprocess, "run", side_effect=run): mermaid_cli.ensure() - self.assertEqual(seen, [True, False]) + self.assertEqual(seen, [(True, True), (False, True)]) def test_a_second_failure_warns_with_puppeteers_output(self) -> None: calls, warnings = self.provision(euid=0, results=[1, 1]) @@ -202,6 +217,12 @@ class TestBrowserInstalled(unittest.TestCase): 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() diff --git a/utils/install/lint/mermaid_cli.py b/utils/install/lint/mermaid_cli.py index 2cbf2398f0..f1782e0d3a 100644 --- a/utils/install/lint/mermaid_cli.py +++ b/utils/install/lint/mermaid_cli.py @@ -49,13 +49,24 @@ def _mmdc_puppeteer() -> Path | None: return None +def _puppeteer_core(puppeteer: Path) -> Path | None: + for base in (puppeteer, *puppeteer.parents): + if base.name == "node_modules": + continue + core = base / "node_modules" / "puppeteer-core" + if core.is_dir(): + return core + return None + + def browser_revision() -> str | None: puppeteer = _mmdc_puppeteer() if puppeteer is None: return None - revisions = ( - puppeteer.parent / "puppeteer-core" / "lib" / "puppeteer" / "revisions.js" - ) + core = _puppeteer_core(puppeteer) + if core is None: + return None + revisions = core / "lib" / "puppeteer" / "revisions.js" try: text = revisions.read_text(encoding="utf-8") # nocheck: cache-read npm prefix except OSError: @@ -104,7 +115,10 @@ def _ensure_browser() -> None: try: done = _provision_browser(puppeteer) if done.returncode != 0: - shutil.rmtree(browser_cache(), ignore_errors=True) + revision = browser_revision() + if revision is not None: + for partial in browser_cache().glob(f"*-{revision}"): + shutil.rmtree(partial, ignore_errors=True) done = _provision_browser(puppeteer) except (OSError, subprocess.TimeoutExpired) as exc: warn(f"{BROWSER} provisioning skipped (optional lint tool): {exc}")