From fbc517c94acba0b279def418fd7705c99fc093e6 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 06:59:59 +0000 Subject: [PATCH] A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way Only the browser seat runs browsers, and only inside internal/browser-slot.sh. That rule was remembered, not enforced: an agent ran a gate it took for a headless source check and it launched Chromium outside the slot. internal/lib/playwright-guarded.mjs is now the one way a repo file reaches playwright: its launch(), launchPersistentContext(), launchServer(), connect() and connectOverCDP() exit 75 (NOT MEASURED) with a message naming browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it for the command it runs, so a bash -c grandchild inherits it. All 72 files that imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates; plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2, research-map-plus-grid 2, research-dashboard 1): a mechanical import swap. check:browser-gates-guarded (gates:pre) fails a direct import; bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate moves to internal/lib/browser-gates.mjs so the runner and the check share it. --- internal/acceptance/interop-harness.mjs | 2 +- internal/browser-slot.sh | 3 ++ internal/check-a11y-button-names.mjs | 2 +- internal/check-aria-labels-localized.mjs | 2 +- internal/check-auto-engine.mjs | 2 +- internal/check-basemap-labels-localized.mjs | 2 +- internal/check-browser-gates-guarded.mjs | 38 +++++++++++++++++ internal/check-card-deck.mjs | 2 +- internal/check-composite-embed-knobs.mjs | 2 +- internal/check-deck-click.mjs | 2 +- internal/check-element-census-vs-live.mjs | 2 +- internal/check-embed-basemap-paints.mjs | 2 +- internal/check-embedded-rtl.mjs | 2 +- internal/check-facet-changes-map.mjs | 2 +- internal/check-filter-width-stable.mjs | 2 +- internal/check-fits-the-screen.mjs | 2 +- internal/check-fixed-anchoring.mjs | 2 +- internal/check-gospel-composed-filters.mjs | 2 +- internal/check-gospel-embed-contract.mjs | 2 +- internal/check-gospel-engine-contract.mjs | 2 +- internal/check-gospel-english-leaks.mjs | 2 +- internal/check-gospel-leaves-mount.mjs | 2 +- internal/check-gospel-map-paints.mjs | 2 +- internal/check-gospel-rtl-shaping.mjs | 2 +- internal/check-host-locale-contract.mjs | 2 +- internal/check-leaf-engine-per-provider.mjs | 2 +- internal/check-leaf-english-leaks.mjs | 2 +- internal/check-legend-export-reachable.mjs | 2 +- internal/check-legend-info-localized.mjs | 2 +- internal/check-map-data-painted.mjs | 2 +- internal/check-map-embed-cells.mjs | 2 +- internal/check-map-unmount-mid-init.mjs | 2 +- internal/check-maplibre-layer-drops.mjs | 2 +- internal/check-mfe-two-tab.mjs | 2 +- internal/check-nav-reachable-mobile.mjs | 2 +- internal/check-packet-browser.mjs | 2 +- internal/check-packet-render.mjs | 2 +- internal/check-poster-card-fits.mjs | 2 +- internal/check-poster-degrades-honestly.mjs | 2 +- internal/check-poster-print-fallback.mjs | 2 +- internal/check-poster-prints.mjs | 2 +- internal/check-poster-raster.mjs | 2 +- internal/check-poster-wall-localised.mjs | 2 +- internal/check-prayer-walk-planner.mjs | 2 +- internal/check-print-geometry.mjs | 2 +- internal/check-provider-switch.mjs | 2 +- internal/check-raw-map-rtl-shaping.mjs | 2 +- internal/check-research-gallery-embed.mjs | 2 +- internal/check-scrim-covers-viewport.mjs | 2 +- internal/check-spa-tab-order.mjs | 2 +- internal/check-studio-every-plugin.mjs | 2 +- internal/check-tabs-localized.mjs | 2 +- internal/check-upg-map-data-painted.mjs | 2 +- internal/check-vision-card-numbers.mjs | 2 +- internal/embed-smoke.mjs | 2 +- internal/lib/browser-gates.mjs | 32 +++++++++++++++ internal/lib/browser.mjs | 2 +- internal/lib/playwright-guarded.mjs | 41 +++++++++++++++++++ internal/measure-map-load.mjs | 2 +- internal/readme-shots-fix.mjs | 2 +- internal/readme-shots.mjs | 2 +- internal/run-parallel.mjs | 28 +------------ internal/smoke-studio.mjs | 2 +- internal/smoke.mjs | 2 +- internal/verify-5-capabilities.mjs | 2 +- internal/verify-gospel-map.mjs | 2 +- internal/verify-map-paint.mjs | 2 +- internal/verify-shell-scroll.mjs | 2 +- package.json | 6 ++- .../scripts/check-clustering-modes.mjs | 2 +- .../scripts/check-export-poster-prints.mjs | 2 +- .../scripts/check-pin-dim-slider.mjs | 2 +- .../my-upg-100-list/data/palette-preview.mjs | 2 +- .../my-upg-100-list/data/visual-gate.mjs | 2 +- .../check-dashboard-geocoder-provider.mjs | 2 +- .../scripts/check-composite-locale-leaks.mjs | 2 +- .../scripts/check-linked-vocabulary.mjs | 2 +- 77 files changed, 192 insertions(+), 98 deletions(-) create mode 100644 internal/check-browser-gates-guarded.mjs create mode 100644 internal/lib/browser-gates.mjs create mode 100644 internal/lib/playwright-guarded.mjs diff --git a/internal/acceptance/interop-harness.mjs b/internal/acceptance/interop-harness.mjs index 71fec1c68..0fbbb7fb6 100644 --- a/internal/acceptance/interop-harness.mjs +++ b/internal/acceptance/interop-harness.mjs @@ -62,7 +62,7 @@ * node internal/acceptance/interop-harness.mjs --self-test [--self-test-mode=grid-prop-drop|camera-freeze] * PORT=4180 node internal/acceptance/interop-harness.mjs */ -import { chromium } from 'playwright' +import { chromium } from '../lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { setTimeout as sleep } from 'node:timers/promises' import { fileURLToPath } from 'node:url' diff --git a/internal/browser-slot.sh b/internal/browser-slot.sh index fdf8d8043..8ba711cbc 100755 --- a/internal/browser-slot.sh +++ b/internal/browser-slot.sh @@ -72,6 +72,9 @@ fi # the command named so its owner can find it. A long gate that legitimately needs more says so: # BROWSER_MAX_HOLD=1800 internal/browser-slot.sh node internal/.mjs run_held() { + # EXPORTED, so the gate's own children (a `bash -c "…"` wrapper, a node grandchild) inherit it: + # internal/lib/playwright-guarded.mjs refuses to launch a browser without it. + export DOXA_BROWSER_SLOT="${DOXA_BROWSER_SLOT:-held}" # The gate's output still flows to the caller untouched (stdout and stderr kept apart); a COPY goes to # RAM (/dev/shm, never the disk that may be full) so a failure can be classified afterwards. local log="" rc diff --git a/internal/check-a11y-button-names.mjs b/internal/check-a11y-button-names.mjs index 0c0dc46e8..148337476 100644 --- a/internal/check-a11y-button-names.mjs +++ b/internal/check-a11y-button-names.mjs @@ -33,7 +33,7 @@ import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { servesThisTree } from './_serves-this-tree.mjs' import { TREE_NAME } from './_tree-name.mjs' diff --git a/internal/check-aria-labels-localized.mjs b/internal/check-aria-labels-localized.mjs index 0ae75d9a2..7af708d78 100644 --- a/internal/check-aria-labels-localized.mjs +++ b/internal/check-aria-labels-localized.mjs @@ -26,7 +26,7 @@ // where no English word list could. // // Usage: node internal/check-aria-labels-localized.mjs [locales] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-auto-engine.mjs b/internal/check-auto-engine.mjs index f71c71beb..40b76ab90 100644 --- a/internal/check-auto-engine.mjs +++ b/internal/check-auto-engine.mjs @@ -21,7 +21,7 @@ * Run through internal/browser-slot.sh. * EXIT 0 all three hold · 1 one does not · 75 not measured. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { existsSync } from 'node:fs' diff --git a/internal/check-basemap-labels-localized.mjs b/internal/check-basemap-labels-localized.mjs index 79ac7eda5..442981d17 100644 --- a/internal/check-basemap-labels-localized.mjs +++ b/internal/check-basemap-labels-localized.mjs @@ -25,7 +25,7 @@ * NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels * existed), served with --dir, FAILS on both engines. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { resolve, dirname } from 'node:path' diff --git a/internal/check-browser-gates-guarded.mjs b/internal/check-browser-gates-guarded.mjs new file mode 100644 index 000000000..7bcf2f4f3 --- /dev/null +++ b/internal/check-browser-gates-guarded.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * check-browser-gates-guarded — no repo gate reaches Playwright except through the slot guard. Headless. + * + * Browsers run only on the browser seat and only inside internal/browser-slot.sh. The rule is enforced + * by internal/lib/playwright-guarded.mjs (a launch refuses, exit 75, without DOXA_BROWSER_SLOT), so a + * file that imports 'playwright' DIRECTLY would bypass it. This fails any such file under internal/ or + * plugin-designs/. `--list` prints every gate script that launches a browser instead. + * EXIT 0 pass · 1 fail + */ +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isBrowserGate } from './lib/browser-gates.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +if (process.argv.includes('--list')) { + const scripts = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')).scripts || {} + const names = Object.keys(scripts).filter((n) => !n.startsWith('gates:') && isBrowserGate(ROOT, n)).sort() + console.log(names.join('\n')) + console.error(`${names.length} script(s) launch a browser: run each as internal/browser-slot.sh bun run `) + process.exit(0) +} +const DIRECT = /from\s+['"]playwright['"]|import\(\s*['"]playwright['"]\s*\)|require\(\s*['"]playwright['"]\s*\)/ +const files = execFileSync('git', ['ls-files', '--', 'internal', 'plugin-designs'], { cwd: ROOT, encoding: 'utf8' }) + .split('\n').filter((f) => /\.(mjs|js|cjs)$/.test(f) && f !== 'internal/lib/playwright-guarded.mjs' && f !== 'internal/check-browser-gates-guarded.mjs' && f !== 'internal/run-parallel.mjs') +const bad = [] +for (const f of files) { + const code = readFileSync(resolve(ROOT, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + if (DIRECT.test(code)) bad.push(f) +} +if (bad.length) { + for (const f of bad) console.log(` FAIL ${f} imports playwright directly; import it from internal/lib/playwright-guarded.mjs`) + console.log(`BROWSER-GATES-GUARDED: FAIL — ${bad.length} file(s) can launch a browser outside the slot.`) + process.exit(1) +} +console.log(`BROWSER-GATES-GUARDED: PASS — no repo file imports playwright except through the slot guard (${files.length} files read).`) diff --git a/internal/check-card-deck.mjs b/internal/check-card-deck.mjs index 1beaf8587..0799fd703 100644 --- a/internal/check-card-deck.mjs +++ b/internal/check-card-deck.mjs @@ -12,7 +12,7 @@ // // Usage: node internal/check-card-deck.mjs [lang] [stock] // PACKET_ORIGIN (default http://127.0.0.1:4898) · PACKET_API · PACKET_ISO3 -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-composite-embed-knobs.mjs b/internal/check-composite-embed-knobs.mjs index 6220e29c1..d4e347e75 100644 --- a/internal/check-composite-embed-knobs.mjs +++ b/internal/check-composite-embed-knobs.mjs @@ -60,7 +60,7 @@ if (!existsSync(MANIFEST)) { process.exit(0) } let chromium -try { ({ chromium } = await import('playwright')) } catch { +try { ({ chromium } = await import('./lib/playwright-guarded.mjs')) } catch { console.log('COMPOSITE-EMBED-KNOBS: SKIP — playwright is not installed.') process.exit(0) } diff --git a/internal/check-deck-click.mjs b/internal/check-deck-click.mjs index 153637296..f2270de69 100644 --- a/internal/check-deck-click.mjs +++ b/internal/check-deck-click.mjs @@ -13,7 +13,7 @@ // already gone and reports 0 cards for a correct deck. // // Usage: node internal/check-deck-click.mjs [stockId] [expectedPage] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-element-census-vs-live.mjs b/internal/check-element-census-vs-live.mjs index 2aeaeeda8..98b6db1b0 100644 --- a/internal/check-element-census-vs-live.mjs +++ b/internal/check-element-census-vs-live.mjs @@ -39,7 +39,7 @@ * EXIT 0 no unaccounted addition and no omission · 1 either · 2 bad preset/arguments · * 75 NOT MEASURED (live page unreachable, a side never rendered, or the census is blind). */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { readFileSync, readdirSync, existsSync } from 'node:fs' diff --git a/internal/check-embed-basemap-paints.mjs b/internal/check-embed-basemap-paints.mjs index 3c15e7b66..abe1b4cf5 100644 --- a/internal/check-embed-basemap-paints.mjs +++ b/internal/check-embed-basemap-paints.mjs @@ -26,7 +26,7 @@ * 2. PIXELS — colour variety in a slice of the canvas. A page can 200 on everything and * still paint nothing, and a screenshot is the only witness to that. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { servesThisTree } from './_serves-this-tree.mjs' import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs' diff --git a/internal/check-embedded-rtl.mjs b/internal/check-embedded-rtl.mjs index 6a3215207..a46b2cc3d 100644 --- a/internal/check-embedded-rtl.mjs +++ b/internal/check-embedded-rtl.mjs @@ -20,7 +20,7 @@ * * Usage: node internal/check-embedded-rtl.mjs [baseUrl] */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { createServer } from 'node:http' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-facet-changes-map.mjs b/internal/check-facet-changes-map.mjs index fa9cdb81d..bcbbb98ba 100644 --- a/internal/check-facet-changes-map.mjs +++ b/internal/check-facet-changes-map.mjs @@ -46,7 +46,7 @@ * node internal/check-facet-changes-map.mjs --self-test # NEGATIVE test: the gate must FAIL * BASE_URL=http://127.0.0.1:4173 PROBE_PATH=//... node internal/check-facet-changes-map.mjs */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' // Defaults to the DEV server, because this gate drives a live interaction and the dev surface is // the one that always reflects current source. Point BASE_URL/PROBE_PATH at the built tree diff --git a/internal/check-filter-width-stable.mjs b/internal/check-filter-width-stable.mjs index 2ad7a6099..087e0568b 100644 --- a/internal/check-filter-width-stable.mjs +++ b/internal/check-filter-width-stable.mjs @@ -38,7 +38,7 @@ */ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { servesThisTree } from './_serves-this-tree.mjs' import { TREE_NAME } from './_tree-name.mjs' diff --git a/internal/check-fits-the-screen.mjs b/internal/check-fits-the-screen.mjs index dc9dca409..b65cfdf3c 100644 --- a/internal/check-fits-the-screen.mjs +++ b/internal/check-fits-the-screen.mjs @@ -58,7 +58,7 @@ * Grades by EXIT CODE. Teaching prose after the verdict — the per-clause table comes first so a * human sees which clause failed without reading the essay. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn, execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' diff --git a/internal/check-fixed-anchoring.mjs b/internal/check-fixed-anchoring.mjs index 5c007e584..c1a4f41c4 100644 --- a/internal/check-fixed-anchoring.mjs +++ b/internal/check-fixed-anchoring.mjs @@ -33,7 +33,7 @@ * plugin-studio host fail with dLeft=256, while standalone continues to pass — i.e. the gate fails * exactly where the old probe passed, which is the property that makes it worth having. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-composed-filters.mjs b/internal/check-gospel-composed-filters.mjs index 642fc891e..13710b15d 100644 --- a/internal/check-gospel-composed-filters.mjs +++ b/internal/check-gospel-composed-filters.mjs @@ -25,7 +25,7 @@ * what makes the pair deadlock-free: * flock -w 1200 flock -w 1200 bun run check:composed-filters */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-embed-contract.mjs b/internal/check-gospel-embed-contract.mjs index ad408c1a3..460568b55 100644 --- a/internal/check-gospel-embed-contract.mjs +++ b/internal/check-gospel-embed-contract.mjs @@ -21,7 +21,7 @@ * Self-test: `--poison` asks for three codes that exist and asserts against a DIFFERENT number, * and asks for a country that does not exist and requires the sheet NOT to open. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-engine-contract.mjs b/internal/check-gospel-engine-contract.mjs index 59cd364dc..050f5db62 100644 --- a/internal/check-gospel-engine-contract.mjs +++ b/internal/check-gospel-engine-contract.mjs @@ -23,7 +23,7 @@ * window.mapboxgl before the bundle runs while maplibre is blocked) and requires the maplibre arm * to FAIL — if it passes, this check cannot see the defect it exists for. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-english-leaks.mjs b/internal/check-gospel-english-leaks.mjs index bd05512cd..06428bf12 100644 --- a/internal/check-gospel-english-leaks.mjs +++ b/internal/check-gospel-english-leaks.mjs @@ -22,7 +22,7 @@ * one, and a check that conflated them would have sent someone to translate strings that are * already translated. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-leaves-mount.mjs b/internal/check-gospel-leaves-mount.mjs index eb8a1a4ec..0e68e0d93 100644 --- a/internal/check-gospel-leaves-mount.mjs +++ b/internal/check-gospel-leaves-mount.mjs @@ -27,7 +27,7 @@ * Run with --poison to prove it can fail: it asks the same questions of a URL that serves no * element at all, and requires every leaf-shaped assertion to go red. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-map-paints.mjs b/internal/check-gospel-map-paints.mjs index 4501c6d16..cdf67cbcc 100644 --- a/internal/check-gospel-map-paints.mjs +++ b/internal/check-gospel-map-paints.mjs @@ -20,7 +20,7 @@ * * Run with --poison to prove it can fail: it hides the canvas and requires a red verdict. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-gospel-rtl-shaping.mjs b/internal/check-gospel-rtl-shaping.mjs index dd757b59c..302360830 100644 --- a/internal/check-gospel-rtl-shaping.mjs +++ b/internal/check-gospel-rtl-shaping.mjs @@ -30,7 +30,7 @@ * Run with --poison to prove it can fail: the poison neuters setRTLTextPlugin in the page, which * reproduces the real pre-fix state — the plugin never registered — rather than a synthetic one. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { TREE_NAME } from './_tree-name.mjs' import { resolve, dirname } from 'node:path' diff --git a/internal/check-host-locale-contract.mjs b/internal/check-host-locale-contract.mjs index 94667c2e0..2684c1c91 100644 --- a/internal/check-host-locale-contract.mjs +++ b/internal/check-host-locale-contract.mjs @@ -34,7 +34,7 @@ * @browser-slot-max-hold 2700 * EXIT 0 every judged leaf enters the host's locale · 1 one does not · 75 not measured. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { readFileSync, existsSync } from 'node:fs' diff --git a/internal/check-leaf-engine-per-provider.mjs b/internal/check-leaf-engine-per-provider.mjs index aa04e3d19..ef0efa4ec 100644 --- a/internal/check-leaf-engine-per-provider.mjs +++ b/internal/check-leaf-engine-per-provider.mjs @@ -29,7 +29,7 @@ * @browser-slot-max-hold 2700 * EXIT 0 pass · 1 a leaf calls Mapbox under maplibre, or a map stopped drawing · 75 not measured. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { readFileSync, existsSync } from 'node:fs' diff --git a/internal/check-leaf-english-leaks.mjs b/internal/check-leaf-english-leaks.mjs index 658c37bc5..527215966 100644 --- a/internal/check-leaf-english-leaks.mjs +++ b/internal/check-leaf-english-leaks.mjs @@ -33,7 +33,7 @@ * node internal/check-leaf-english-leaks.mjs /: [more...] * LOCALES=ar,zh,ru,hi BASE_URL=http://127.0.0.1:4173 */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync, readdirSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-legend-export-reachable.mjs b/internal/check-legend-export-reachable.mjs index e918f4d3f..2e0b7667a 100644 --- a/internal/check-legend-export-reachable.mjs +++ b/internal/check-legend-export-reachable.mjs @@ -27,7 +27,7 @@ // // Usage: node internal/check-legend-export-reachable.mjs [leafUrl] // SKIPs when nothing is served — this needs the built tree. -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { requireThisTree } from './_serves-this-tree.mjs' diff --git a/internal/check-legend-info-localized.mjs b/internal/check-legend-info-localized.mjs index 6e2e700d4..509e8e910 100644 --- a/internal/check-legend-info-localized.mjs +++ b/internal/check-legend-info-localized.mjs @@ -28,7 +28,7 @@ // version of this probe reported "(no popover)" in every locale against a working build. // // Usage: node internal/check-legend-info-localized.mjs [locales] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync, existsSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-map-data-painted.mjs b/internal/check-map-data-painted.mjs index f9224be33..c6ea90a03 100644 --- a/internal/check-map-data-painted.mjs +++ b/internal/check-map-data-painted.mjs @@ -81,7 +81,7 @@ * node internal/check-map-data-painted.mjs --poison # must go RED; proves it can fail * Defaults to the LOCAL tree's decoupled leaves. Pass URLs explicitly to audit what is published. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { servesThisTree } from './_serves-this-tree.mjs' import { TREE_NAME } from './_tree-name.mjs' diff --git a/internal/check-map-embed-cells.mjs b/internal/check-map-embed-cells.mjs index 43508ace4..8dd41e846 100644 --- a/internal/check-map-embed-cells.mjs +++ b/internal/check-map-embed-cells.mjs @@ -20,7 +20,7 @@ * is then forwarded with the real token so the map can still paint. A map drawn with zero Mapbox * requests is fine for maps that need none; that is reported, not failed. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { readFileSync, existsSync } from 'node:fs' diff --git a/internal/check-map-unmount-mid-init.mjs b/internal/check-map-unmount-mid-init.mjs index 9b1c2c748..d35f07bec 100644 --- a/internal/check-map-unmount-mid-init.mjs +++ b/internal/check-map-unmount-mid-init.mjs @@ -23,7 +23,7 @@ * It refuses to pass vacuously: if the script was never requested (preloaded) or the pane was not * actually removed before release, the race was not staged and it exits 2, not 0. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-maplibre-layer-drops.mjs b/internal/check-maplibre-layer-drops.mjs index d4930928b..ef2ec6b60 100644 --- a/internal/check-maplibre-layer-drops.mjs +++ b/internal/check-maplibre-layer-drops.mjs @@ -36,7 +36,7 @@ * node internal/check-maplibre-layer-drops.mjs * BASE_URL=http://127.0.0.1:4399 node internal/check-maplibre-layer-drops.mjs */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync, existsSync } from 'node:fs' import { resolve, join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-mfe-two-tab.mjs b/internal/check-mfe-two-tab.mjs index 2d4e467b1..e4f01b951 100644 --- a/internal/check-mfe-two-tab.mjs +++ b/internal/check-mfe-two-tab.mjs @@ -12,7 +12,7 @@ // tab — a strip that paints but does not switch is the other silent half. // // Usage: node internal/check-mfe-two-tab.mjs [expectedTabCount] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-nav-reachable-mobile.mjs b/internal/check-nav-reachable-mobile.mjs index 0be4169f1..fdb4c8723 100644 --- a/internal/check-nav-reachable-mobile.mjs +++ b/internal/check-nav-reachable-mobile.mjs @@ -36,7 +36,7 @@ import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { TREE_NAME } from './_tree-name.mjs' diff --git a/internal/check-packet-browser.mjs b/internal/check-packet-browser.mjs index 7f91b4360..8cbe0c21a 100644 --- a/internal/check-packet-browser.mjs +++ b/internal/check-packet-browser.mjs @@ -19,7 +19,7 @@ // PACKET_ISO3 country to build a test packet for (default LAO) // Needs a static server over the repo root, e.g.: python3 -m http.server 4898 -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index 5aa962774..4d3835c18 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -26,7 +26,7 @@ // Usage: node internal/check-packet-render.mjs [path/to/packet.html] // Default path is overridable with PACKET_HTML so this is not machine-specific. -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { existsSync, readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' diff --git a/internal/check-poster-card-fits.mjs b/internal/check-poster-card-fits.mjs index 7282403cf..7f74e14b1 100644 --- a/internal/check-poster-card-fits.mjs +++ b/internal/check-poster-card-fits.mjs @@ -20,7 +20,7 @@ * * Usage: node internal/check-poster-card-fits.mjs [leafUrl] — SKIPs when nothing is served. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { servesThisTree } from './_serves-this-tree.mjs' diff --git a/internal/check-poster-degrades-honestly.mjs b/internal/check-poster-degrades-honestly.mjs index 0ff59870e..5fd08b5d0 100644 --- a/internal/check-poster-degrades-honestly.mjs +++ b/internal/check-poster-degrades-honestly.mjs @@ -24,7 +24,7 @@ // `--directory public/doxa-maps`. Serve the TREE. // // Usage: node internal/check-poster-degrades-honestly.mjs [zoneId] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-poster-print-fallback.mjs b/internal/check-poster-print-fallback.mjs index a9aaadb3a..2193adf19 100644 --- a/internal/check-poster-print-fallback.mjs +++ b/internal/check-poster-print-fallback.mjs @@ -23,7 +23,7 @@ // the default-drop condition, and the .html is left reachable. // // Usage: node internal/check-poster-print-fallback.mjs [zoneId] -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-poster-prints.mjs b/internal/check-poster-prints.mjs index 0ef468efb..5c44946f5 100644 --- a/internal/check-poster-prints.mjs +++ b/internal/check-poster-prints.mjs @@ -17,7 +17,7 @@ // Runs the composable in a browser page (it calls document/DOM), driving it with a // fake records provider so the assertion is about the DOCUMENTS, not about any // bundle's API being reachable. -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-poster-raster.mjs b/internal/check-poster-raster.mjs index 100ffce67..3d554bb74 100644 --- a/internal/check-poster-raster.mjs +++ b/internal/check-poster-raster.mjs @@ -16,7 +16,7 @@ // // Usage: node internal/check-poster-raster.mjs [zoneId] // PACKET_ORIGIN (default http://127.0.0.1:4898) -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-poster-wall-localised.mjs b/internal/check-poster-wall-localised.mjs index 72b6dd05a..3b9470806 100644 --- a/internal/check-poster-wall-localised.mjs +++ b/internal/check-poster-wall-localised.mjs @@ -39,7 +39,7 @@ * Usage: node internal/check-poster-wall-localised.mjs [baseUrl] * SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { readFileSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/internal/check-prayer-walk-planner.mjs b/internal/check-prayer-walk-planner.mjs index 9e9742a8a..2bb58fbae 100644 --- a/internal/check-prayer-walk-planner.mjs +++ b/internal/check-prayer-walk-planner.mjs @@ -30,7 +30,7 @@ import { TREE_NAME } from './_tree-name.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') if (!existsSync(resolve(ROOT, `public/${TREE_NAME}/prayer-walk-planner/prayer-walk-planner.js`))) { console.log('SKIP check-prayer-walk-planner: no built planner'); process.exit(2) } let chromium -try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-prayer-walk-planner: playwright not installed'); process.exit(2) } +try { ({ chromium } = await import('./lib/playwright-guarded.mjs')) } catch { console.log('SKIP check-prayer-walk-planner: playwright not installed'); process.exit(2) } const OUT = process.env.OUT || '/tmp' const free = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) const TP = await free(), HP = await free(), TREE = `http://127.0.0.1:${TP}/${TREE_NAME}` diff --git a/internal/check-print-geometry.mjs b/internal/check-print-geometry.mjs index 642b94c98..28c2ac1d9 100644 --- a/internal/check-print-geometry.mjs +++ b/internal/check-print-geometry.mjs @@ -13,7 +13,7 @@ // // Usage: node internal/check-print-geometry.mjs // PACKET_ORIGIN (default http://127.0.0.1:4898) — a static server over the repo root -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-provider-switch.mjs b/internal/check-provider-switch.mjs index 270a62945..c8213f3fa 100644 --- a/internal/check-provider-switch.mjs +++ b/internal/check-provider-switch.mjs @@ -14,7 +14,7 @@ // Usage: node internal/check-provider-switch.mjs import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { servesThisTree, serveThisTree } from './_serves-this-tree.mjs' import { TREE_NAME } from './_tree-name.mjs' diff --git a/internal/check-raw-map-rtl-shaping.mjs b/internal/check-raw-map-rtl-shaping.mjs index dc0a5092d..d596ebce0 100644 --- a/internal/check-raw-map-rtl-shaping.mjs +++ b/internal/check-raw-map-rtl-shaping.mjs @@ -25,7 +25,7 @@ * --poison neuters setRTLTextPlugin in the page (the real pre-fix state) and must be caught on * every measured row. Exit: 0 pass · 1 fail · 2 not measured. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { serveReaped } from './_serve-reaped.mjs' import { TREE_NAME } from './_tree-name.mjs' import { readFileSync } from 'node:fs' diff --git a/internal/check-research-gallery-embed.mjs b/internal/check-research-gallery-embed.mjs index 7867b7bb9..90b2d7a29 100644 --- a/internal/check-research-gallery-embed.mjs +++ b/internal/check-research-gallery-embed.mjs @@ -120,7 +120,7 @@ if (!existsSync(resolve(ROOT, `public/${TREE_NAME}/doxa-maps-spa/doxa-maps-spa.j process.exit(2) } let chromium -try { ({ chromium } = await import('playwright')) } catch { +try { ({ chromium } = await import('./lib/playwright-guarded.mjs')) } catch { console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2) } diff --git a/internal/check-scrim-covers-viewport.mjs b/internal/check-scrim-covers-viewport.mjs index 24865c1dc..be72b6a47 100644 --- a/internal/check-scrim-covers-viewport.mjs +++ b/internal/check-scrim-covers-viewport.mjs @@ -43,7 +43,7 @@ * * Exit: 0 all pass · 1 any fail · 2 dev server unreachable (a precondition, not a defect). */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-spa-tab-order.mjs b/internal/check-spa-tab-order.mjs index 88cf72228..f1acbaf99 100644 --- a/internal/check-spa-tab-order.mjs +++ b/internal/check-spa-tab-order.mjs @@ -31,7 +31,7 @@ * NEGATIVE-TESTED: swapping the first two entries of NAV_MODEL fails by name, printing expected vs * actual side by side. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireDevServesThisCheckout as __devGuard, requireThisTree as __treeGuard } from './_serves-this-tree.mjs' import { fileURLToPath as __f2p } from 'node:url' import { dirname as __dn, resolve as __rs } from 'node:path' diff --git a/internal/check-studio-every-plugin.mjs b/internal/check-studio-every-plugin.mjs index 319ce116d..8a8fac474 100644 --- a/internal/check-studio-every-plugin.mjs +++ b/internal/check-studio-every-plugin.mjs @@ -23,7 +23,7 @@ * --url is REQUIRED. Several gates here hardcode :5173, and a drifted dev server made them measure * another worktree's tree — so this one refuses to guess. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { requireThisTree } from './_serves-this-tree.mjs' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { fileURLToPath as _fileURLToPath } from 'node:url' diff --git a/internal/check-tabs-localized.mjs b/internal/check-tabs-localized.mjs index efcee808c..78d3f33d0 100644 --- a/internal/check-tabs-localized.mjs +++ b/internal/check-tabs-localized.mjs @@ -43,7 +43,7 @@ import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..') diff --git a/internal/check-upg-map-data-painted.mjs b/internal/check-upg-map-data-painted.mjs index feeb82523..bcbeb4938 100644 --- a/internal/check-upg-map-data-painted.mjs +++ b/internal/check-upg-map-data-painted.mjs @@ -31,7 +31,7 @@ * Defaults to the LOCAL tree. Pass live URLs explicitly to audit what is published — the two * diverge, and conflating them cost a whole tick of wrong diagnoses. */ -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { servesThisTree } from './_serves-this-tree.mjs' diff --git a/internal/check-vision-card-numbers.mjs b/internal/check-vision-card-numbers.mjs index 7b37d4e79..3f790f104 100644 --- a/internal/check-vision-card-numbers.mjs +++ b/internal/check-vision-card-numbers.mjs @@ -50,7 +50,7 @@ if (i > -1) { // Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs). const { requireThisTree } = await import('./_serves-this-tree.mjs') await requireThisTree(process.argv[i + 1], ROOT, 'VISION-CARD-NUMBERS') - const { chromium } = await import('playwright') + const { chromium } = await import('./lib/playwright-guarded.mjs') const b = await chromium.launch() const p = await b.newPage({ viewport: { width: 900, height: 1000 } }) await p.goto(process.argv[i + 1], { waitUntil: 'networkidle' }) diff --git a/internal/embed-smoke.mjs b/internal/embed-smoke.mjs index c71d5b697..ee79bd8eb 100644 --- a/internal/embed-smoke.mjs +++ b/internal/embed-smoke.mjs @@ -21,7 +21,7 @@ import { createServer } from 'node:http' import { readFileSync, existsSync, statSync, createReadStream } from 'node:fs' import { resolve, join, extname } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { chromium } from './lib/playwright-guarded.mjs' import { TREE_NAME } from './_tree-name.mjs' const __dirname = fileURLToPath(new URL('.', import.meta.url)) diff --git a/internal/lib/browser-gates.mjs b/internal/lib/browser-gates.mjs new file mode 100644 index 000000000..bbb734724 --- /dev/null +++ b/internal/lib/browser-gates.mjs @@ -0,0 +1,32 @@ +/** internal/lib/browser-gates.mjs — which package.json scripts drive a real browser (read from the gate FILE). + * Shared by run-parallel (to route them through browser-slot.sh) and check-browser-gates-guarded (--list). */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' + +/** + * Does this step drive a real browser? Asked of the GATE FILE, not of a hand-kept list: + * a list goes stale the first time someone adds a gate, and a browser gate missing from it + * is exactly the one that rejoins the dogpile. package.json names the file; the file says + * whether it imports playwright. + */ +export function isBrowserGate(ROOT, name) { + try { + const scripts = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')).scripts || {} + const m = String(scripts[name] || '').match(/([\w./-]+\.mjs)/) + if (!m) return false + const file = resolve(ROOT, m[1]) + if (!existsSync(file)) return false + const src = readFileSync(file, 'utf8') + // Two ways a step can drive chrome: it launches one itself, or it is a RUNNER that + // launches gates which do. `verify` is the second kind — verify-all.mjs imports no + // playwright and would have been graded static, while three of the gates it spawns + // (posters, app-tables, map-paint) render pages. It already marks those `browser: true` + // for its own reasons, so that declaration is honoured here rather than duplicated. + // …but a runner only drives chrome when ASKED to: verify-all runs its browser guards only with --full, + // so plain `verify` is a static step (it printed "ALL GUARDS PASS (browser guards skipped)" and was + // still routed through the browser slot, where a headless seat could only fail or skip it). + // playwright-guarded.mjs is how every repo gate reaches playwright now (a firefox-only gate names no chromium). + if (/from 'playwright'|require\('playwright'\)|from\s+['"][^'"]*playwright-guarded\.mjs['"]|import\(\s*['"][^'"]*playwright-guarded\.mjs|chromium/.test(src)) return true + return /browser:\s*true/.test(src) && /--full\b/.test(String(scripts[name])) + } catch { return false } +} diff --git a/internal/lib/browser.mjs b/internal/lib/browser.mjs index 41c262848..bbd69c078 100644 --- a/internal/lib/browser.mjs +++ b/internal/lib/browser.mjs @@ -25,7 +25,7 @@ import { readdirSync, existsSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' -import * as pw from 'playwright' +import * as pw from './playwright-guarded.mjs' export const TELEMETRY = /events\.mapbox\.com/ diff --git a/internal/lib/playwright-guarded.mjs b/internal/lib/playwright-guarded.mjs new file mode 100644 index 000000000..16d4c0b28 --- /dev/null +++ b/internal/lib/playwright-guarded.mjs @@ -0,0 +1,41 @@ +/** + * internal/lib/playwright-guarded.mjs — the ONE way a repo gate reaches Playwright. + * + * Only the browser seat runs browsers, and only inside the shared slot (internal/browser-slot.sh, a + * memory-floored semaphore). That rule used to be remembered, not enforced: an agent ran a gate it took + * for a headless source check and it launched Chromium outside the slot. Every browser launch now goes + * through this module, and launch()/launchPersistentContext()/launchServer()/connect()/connectOverCDP() + * refuse unless browser-slot.sh has EXPORTED DOXA_BROWSER_SLOT (exported, so a `bash -c "…"` grandchild + * inherits it). Refusal is exit 75, NOT MEASURED, never a failure and never a pass. + * check:browser-gates-guarded fails any repo file that imports playwright directly instead. + */ +import * as pw from 'playwright' + +export const NOT_MEASURED = 75 + +export function requireBrowserSlot(who = process.argv[1] || 'this gate') { + if (process.env.DOXA_BROWSER_SLOT) return + console.error(`${who}: this gate launches a browser. Browsers run only on the browser seat, inside the slot:\n` + + ' internal/browser-slot.sh bun run