doxa-maps/internal/check-auto-engine.mjs
Doxa Maps fbc517c94a 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.
2026-09-28 08:20:21 +00:00

134 lines
8 KiB
JavaScript

#!/usr/bin/env node
/**
* check-auto-engine — the default engine is 'auto': Mapbox WHEN a token is available, MapLibre otherwise.
*
* WHY (2026-09-25, by project decision). The public repo carries no Mapbox token. With a default of
* 'mapbox', a keyless visitor got a map that could not draw and a paste-a-token prompt, and the only
* way out was a token in the repo. 'auto' asks the token ladder instead (useMapProvider.js), and the
* generated leaf asks the same ladder before it mounts (generate-tree.mjs, PROVIDER).
*
* THREE CASES, on one map leaf of THIS tree (or --dir), each in a fresh browser context:
* 1 NO TOKEN the map draws with MapLibre, makes ZERO requests to any *.mapbox.com host, and
* PAINTS (distinct colours on the canvas, not a blank rectangle).
* 2 HOST TOKEN the host sets window.MAP_TOKEN before the page runs: the map draws with Mapbox,
* and THAT token is the access_token on its requests to Mapbox.
* 3 EXPLICIT WINS the same host token, with ?provider=maplibre: MapLibre, zero Mapbox requests.
* The token in case 2 is a well-formed FAKE, built at run time; Mapbox requests that carry it are
* recorded and aborted, never sent. The engine is read from the canvas that drew
* (mapboxgl-canvas / maplibregl-canvas), not from which globals happen to exist.
*
* USAGE node internal/check-auto-engine.mjs [--dir <tree root>] [--leaf <bundle>/<profile>]
* Run through internal/browser-slot.sh.
* EXIT 0 all three hold · 1 one does not · 75 not measured.
*/
import { chromium } from './lib/playwright-guarded.mjs'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
const MIN_COLOURS = 40
const MAPBOX_HOST = /(^|\.)mapbox\.com$/i
// A fake public token of the right SHAPE (pk.<base64url JSON>.<sig>), so the engine accepts it.
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const FAKE_TOKEN = ['pk', b64({ u: 'auto-engine-check', a: 'not-a-real-token' }), 'c2lnbmF0dXJl'].join('.')
if (!existsSync(join(DIR, TREE_NAME, LEAF, 'index.html'))) { console.log(`AUTO-ENGINE: NOT MEASURED — no ${LEAF} under ${DIR}/${TREE_NAME}/`); process.exit(75) }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`AUTO-ENGINE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
console.log(`AUTO-ENGINE: measuring THIS TREE — ${DIR} (127.0.0.1:${port}), leaf ${LEAF}`)
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
async function measure({ hostToken = '', query = '' }) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
const mapbox = []
// The engine script itself may come from Mapbox's CDN; everything that CARRIES a token is recorded
// and aborted, so the fake token never leaves this machine.
await page.route((url) => MAPBOX_HOST.test(url.hostname), (route) => {
const u = new URL(route.request().url())
mapbox.push(u)
if (u.searchParams.has('access_token')) return route.abort()
return route.continue()
})
if (hostToken) await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, hostToken)
try {
await page.goto(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html${query}`, { waitUntil: 'load', timeout: 90000 })
const deadline = Date.now() + 45000
let engine = ''
while (Date.now() < deadline && !engine) {
engine = await page.evaluate(() => {
const roots = [document]
for (const el of document.querySelectorAll('*')) if (el.shadowRoot) roots.push(el.shadowRoot)
for (const r of roots) {
if (r.querySelector('canvas.maplibregl-canvas')) return 'maplibre'
if (r.querySelector('canvas.mapboxgl-canvas')) return 'mapbox'
}
return ''
})
if (!engine) await page.waitForTimeout(500)
}
const prompt = await page.evaluate(() => !!document.getElementById('tkIn'))
let colours = 0
if (engine === 'maplibre') {
await page.waitForTimeout(8000) // tiles
// Straight from CDP: page.screenshot waits for web fonts first and timed out on this leaf.
const cdp = await ctx.newCDPSession(page)
const png = Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png' })).data, 'base64')
colours = await page.evaluate(async (data) => {
const img = new Image(); img.src = 'data:image/png;base64,' + data; await img.decode()
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height
const g = c.getContext('2d'); g.drawImage(img, 0, 0)
const d = g.getImageData(0, 0, c.width, c.height).data, seen = new Set()
for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 10) | ((d[i + 1] >> 3) << 5) | (d[i + 2] >> 3))
return seen.size
}, png.toString('base64'))
} else {
await page.waitForTimeout(3000)
}
return { engine: engine || 'none', prompt, colours, mapbox }
} catch (e) {
return { err: String(e.message).split('\n')[0].slice(0, 120), mapbox }
} finally { await ctx.close() }
}
const results = []
try {
results.push({ name: '1 no token', r: await measure({}) })
results.push({ name: '2 host token', r: await measure({ hostToken: FAKE_TOKEN }) })
results.push({ name: '3 explicit maplibre + token', r: await measure({ hostToken: FAKE_TOKEN, query: '?provider=maplibre' }) })
} finally { await browser.close(); stop() }
let bad = 0, unmeasured = 0
const say = (ok, name, msg) => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(30)} ${msg}`); if (!ok) bad++ }
for (const { name, r } of results) {
if (r.err) { console.log(` ?? ${name.padEnd(30)} NOT MEASURED — ${r.err}`); unmeasured++; continue }
const withToken = r.mapbox.filter((u) => u.searchParams.has('access_token'))
const summary = `engine ${r.engine}, ${r.mapbox.length} Mapbox request(s)${r.prompt ? ', TOKEN PROMPT shown' : ''}`
if (name.startsWith('1')) {
say(r.engine === 'maplibre' && r.mapbox.length === 0 && r.colours >= MIN_COLOURS && !r.prompt, name,
`${summary}, ${r.colours} colours (floor ${MIN_COLOURS}) — want maplibre, 0 Mapbox requests, painted, no prompt`)
} else if (name.startsWith('2')) {
const carries = withToken.length > 0 && withToken.every((u) => u.searchParams.get('access_token') === FAKE_TOKEN)
say(r.engine === 'mapbox' && carries, name,
`${summary}, ${withToken.length} carry a token${withToken.length && !carries ? ' — NOT the host token' : ''} — want mapbox, the host token on its requests`)
} else {
say(r.engine === 'maplibre' && r.mapbox.length === 0, name, `${summary} — want maplibre, 0 Mapbox requests`)
}
}
if (bad) { console.log(`AUTO-ENGINE: FAIL — ${bad} of 3 case(s) do not hold.`); process.exit(1) }
if (unmeasured) { console.log(`AUTO-ENGINE: NOT MEASURED — ${unmeasured} case(s) could not be judged.`); process.exit(75) }
console.log('AUTO-ENGINE: PASS — no token draws MapLibre keyless; a host token draws Mapbox with that token; an explicit provider wins.')