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.
This commit is contained in:
Doxa Maps 2026-09-28 06:59:59 +00:00
parent 80167548c5
commit fbc517c94a
77 changed files with 192 additions and 98 deletions

View file

@ -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'

View file

@ -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/<long-gate>.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

View file

@ -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'

View file

@ -26,7 +26,7 @@
// where no English word list could.
//
// Usage: node internal/check-aria-labels-localized.mjs <leafUrl> [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'

View file

@ -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'

View file

@ -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'

View file

@ -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 <name>`)
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).`)

View file

@ -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'

View file

@ -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)
}

View file

@ -13,7 +13,7 @@
// already gone and reports 0 cards for a correct deck.
//
// Usage: node internal/check-deck-click.mjs <wallUrl> [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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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=/<TREE_NAME>/... 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

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -25,7 +25,7 @@
* what makes the pair deadlock-free:
* flock -w 1200 <product-lock> flock -w 1200 <agent-lock> 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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -33,7 +33,7 @@
* node internal/check-leaf-english-leaks.mjs <bundle>/<leaf>:<tag> [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'

View file

@ -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'

View file

@ -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 <leafUrl> [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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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 <url> [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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -24,7 +24,7 @@
// `--directory public/doxa-maps`. Serve the TREE.
//
// Usage: node internal/check-poster-degrades-honestly.mjs <wallUrl> [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'

View file

@ -23,7 +23,7 @@
// the default-drop condition, and the .html is left reachable.
//
// Usage: node internal/check-poster-print-fallback.mjs <wallUrl> [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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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}`

View file

@ -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'

View file

@ -14,7 +14,7 @@
// Usage: node internal/check-provider-switch.mjs <leafUrl>
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'

View file

@ -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'

View file

@ -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)
}

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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)), '..')

View file

@ -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'

View file

@ -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' })

View file

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

View file

@ -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 }
}

View file

@ -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/

View file

@ -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 <script> (it exports DOXA_BROWSER_SLOT)\n' +
'NOT MEASURED (exit 75).')
process.exit(NOT_MEASURED)
}
const LAUNCHERS = new Set(['launch', 'launchPersistentContext', 'launchServer', 'connect', 'connectOverCDP'])
function guarded(browserType) {
return new Proxy(browserType, {
get(target, prop) {
const v = Reflect.get(target, prop, target)
if (typeof v !== 'function') return v
if (LAUNCHERS.has(prop)) return (...args) => { requireBrowserSlot(); return v.apply(target, args) }
return v.bind(target)
},
})
}
export const chromium = guarded(pw.chromium)
export const firefox = guarded(pw.firefox)
export const webkit = guarded(pw.webkit)
export const devices = pw.devices
export const errors = pw.errors
export default { chromium, firefox, webkit, devices, errors }

View file

@ -12,7 +12,7 @@
* to find what DOES wait on what. Every run is a FRESH browser context (cold HTTP cache) under
* Chrome DevTools network throttling, so a local server is not mistaken for a real connection.
*/
import { chromium } from 'playwright'
import { chromium } from './lib/playwright-guarded.mjs'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'

View file

@ -2,7 +2,7 @@
// Targeted re-capture: cap1 (vision-card WITH token so it renders, not the token-error state)
// and cap5 (SPA deep-linked to a plugin so a map shows, not "choose a map").
// Retries navigation while concurrent builds churn the tree.
import { chromium } from 'playwright'
import { chromium } from './lib/playwright-guarded.mjs'
import { createServer } from 'node:http'
import { readFile, stat } from 'node:fs/promises'
import { join, extname, resolve, dirname } from 'node:path'

View file

@ -10,7 +10,7 @@
*
* Output: documentation/readme-images/*.png
*/
import { chromium } from 'playwright'
import { chromium } from './lib/playwright-guarded.mjs'
import { createServer } from 'node:http'
import { readFile, stat, mkdir, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'

View file

@ -46,12 +46,14 @@
import { spawn, spawnSync } from 'node:child_process'
import { readdirSync, existsSync, readFileSync } from 'node:fs'
import { isWebBuilt } from './plugin-targets.mjs'
import { isBrowserGate as isBrowserGateAt } from './lib/browser-gates.mjs'
import { cpus } from 'node:os'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const isBrowserGate = (name) => isBrowserGateAt(ROOT, name)
const VERBOSE = argv.includes('--verbose')
const BUNDLES = argv.includes('--bundles')
const labelArg = argv.find((a) => a.startsWith('--label='))
@ -107,32 +109,6 @@ const LOCK_WAIT_S = Number(process.env.BROWSER_LOCK_WAIT || 900)
// EX_TEMPFAIL. Anything but 0 or 1, so it cannot be confused with a gate's own verdict.
const LOCK_BUSY_CODE = 75
/**
* 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.
*/
function isBrowserGate(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).
if (/from 'playwright'|require\('playwright'\)|chromium/.test(src)) return true
return /browser:\s*true/.test(src) && /--full\b/.test(String(scripts[name]))
} catch { return false }
}
/**
* Hold a browser gate back while the box is already overloaded. Same threshold and cadence as

View file

@ -46,7 +46,7 @@
* NEGATIVE-TESTED: re-introducing the base-concatenation bug makes this FAIL naming the 404 URL;
* removing tree-base makes it FAIL on the empty tab list. A gate that cannot fail is worse than none.
*/
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'

View file

@ -38,7 +38,7 @@
*
* Usage: bun run smoke (serves public/ on a free port, then drives it)
*/
import { chromium } from 'playwright'
import { chromium } from './lib/playwright-guarded.mjs'
import { createServer } from 'node:http'
import { readFile, stat } from 'node:fs/promises'
import { join, extname, resolve, dirname } from 'node:path'

View file

@ -32,7 +32,7 @@
* BASE=http://127.0.0.1:8080/<TREE_NAME>/ node internal/verify-5-capabilities.mjs # a local serve
* Exits non-zero if any level fails, so it can gate a release.
*/
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'

View file

@ -41,7 +41,7 @@
* USAGE: node internal/verify-gospel-map.mjs [baseUrl] (no setup needed)
* Skips loudly (exit 0) only if the tree is not built, the way CI sees it.
*/
import { chromium } from 'playwright'
import { chromium } from './lib/playwright-guarded.mjs'
import { servesThisTree } from './_serves-this-tree.mjs'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'

View file

@ -29,7 +29,7 @@
*
* Exits non-zero if any leaf fails, so it can become a gate.
*/
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'

View file

@ -22,7 +22,7 @@
* Usage: node internal/verify-shell-scroll.mjs [baseUrl]
* (defaults to http://localhost:8899; serve ./public)
*/
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'

View file

@ -9,6 +9,7 @@
"apk:debug": "bun run build && bun run cap:sync && bun run app:start-page && bun run cap:verify && cd deployment/capacitor/android && ./gradlew assembleDebug",
"apk:install": "adb install -r deployment/capacitor/android/app/build/outputs/apk/debug/app-debug.apk",
"app:start-page": "node internal/set-app-start-page.mjs",
"browser-gates": "node internal/check-browser-gates-guarded.mjs --list",
"build": "bash internal/build-lock.sh",
"build:bundle": "bun x vite build",
"build:bundles": "node internal/run-parallel.mjs --bundles",
@ -24,6 +25,7 @@
"check:backlog-refs": "node internal/check-backlog-refs.mjs",
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
"check:browser-gates-guarded": "node internal/check-browser-gates-guarded.mjs",
"check:capability-citations": "node internal/check-capability-citations.mjs",
"check:capability-derived": "node internal/check-capability-derived.mjs",
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs",
@ -254,7 +256,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -303,6 +305,7 @@
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",
"capability-exemplars": "regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).",
"check:a11y-button-names": "every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `<button>` hits, most with visible text). Needs a browser, so NOT in build:unlocked and NOT in CI. ALL=1 sweeps every profile.",
@ -310,6 +313,7 @@
"check:auto-engine": "BROWSER. The default engine is auto: on one map leaf, no token draws with MapLibre and makes zero api.mapbox.com requests, a token draws with Mapbox, and an explicit provider wins. Under the fleet browser policy it is a rega run, not part of gates:post.",
"check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
"check:browser-gates-guarded": "no repo file imports playwright except through internal/lib/playwright-guarded.mjs, whose launch refuses (exit 75, NOT MEASURED) unless internal/browser-slot.sh has exported DOXA_BROWSER_SLOT. Browsers run only on the browser seat, inside the slot; this makes that rule enforced, not remembered.",
"check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
"check:capability-derived": "every bundle lands on a real capability 1-5, never 0, and a DECLARED tier must agree with the composes edges it declares. Tiers 3-5 used to be authored only - discover.mjs infers single and parameterized and nothing else - so a composite whose author forgot a meta.json shipped as capability 1; research-dashboard did exactly that while nesting ten views, and a missing file is indistinguishable from a deliberate choice, so nothing failed. The declaration stays authoritative: it is CHECKED, not overridden, because a meta that contradicts the structure means one of the two is stale and picking silently is how a tier rots. Two traps found by running the rule BEFORE writing it: composes is per-PROFILE at source but hoisted to the BUNDLE in the manifest, so deriving from the bundle row gave a composite's edges to its plain sibling leaves (11 false failures on correct data); and refs come in two forms in one field, 'bundle/profile' and a bare 'bundle', so resolving only the first demoted plugin-studio from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree. An unresolved ref is REPORTED, never counted as 'not a composite' - a typo and a genuine plain child look identical from here. Negative-tested on the research-dashboard shape (a composite declared single).",
"check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",

View file

@ -50,7 +50,7 @@ const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0)
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')

View file

@ -48,7 +48,7 @@ if (!existsSync(resolve(ROOT, `public/${TREE_NAME}/research-map-plus-grid/resear
console.log('SKIP check-export-poster-prints: no built research-map-plus-grid'); process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-export-poster-prints: playwright not installed'); process.exit(2) }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { console.log('SKIP check-export-poster-prints: playwright not installed'); process.exit(2) }
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const PROVIDER = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : '' // maplibre = the keyless engine

View file

@ -39,7 +39,7 @@ const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0)
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'doxa-research-map')

View file

@ -20,7 +20,7 @@
// compatible serialization). It does not import or mutate src/config/color-strategies,
// clusters.json, or any sibling bundle.
import { firefox } from 'playwright';
import { firefox } from '../../../internal/lib/playwright-guarded.mjs';
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

View file

@ -12,7 +12,7 @@
//
// Exit 0 = all checks PASS; non-zero = at least one FAIL.
import { firefox } from 'playwright';
import { firefox } from '../../../internal/lib/playwright-guarded.mjs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { mkdirSync, readFileSync } from 'node:fs';

View file

@ -99,7 +99,7 @@ const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
// The token ladder every other browser gate here uses: env first, then the build config. Presence

View file

@ -47,7 +47,7 @@ const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0)
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
if (!existsSync(LOCALES_DIR)) skip('no locale catalogues to compare against.')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
// The composites in this lane. Both, always: the two share the grid and half the chrome, and a fix
// proved on one has repeatedly been stale on the other.

View file

@ -55,7 +55,7 @@ const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0)
if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
let chromium
try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
try { ({ chromium } = await import('../../../internal/lib/playwright-guarded.mjs')) } catch { skip('playwright is not installed.') }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-map-plus-grid')