check:snippets-draw: every published snippet, pasted verbatim on another origin, must draw (awaiting its first run)

Ported from rega's snipgate and mbxcheck probes (card GATE-EVERY-PUBLISHED-SNIPPET-DRAWS-A-MAP). Two scripts so the keyless arm never waits on a token: check:snippets-draw pastes each production embed.script and requires a MapLibre map to draw (a hooked instance with its style loaded via a setter trap on the engine global, a sized canvas, tiles 2xx, no failure note, 0 page errors); check:snippets-draw:mapbox does the same over embed.scriptMapbox with only the token filled from MAP_TOKEN, NOT MEASURED without one and never printing it. Composites that open on a list (regions-mfe, upg-100-mfe) are pressed to their map view under the settle rule first, since a hidden pane still draws a canvas. The browser slot is checked before any server starts, and the tree server is spawned --exit-with-parent. Registered and declared on demand until rega's first run; it joins verify-browser-all after.
This commit is contained in:
Doxa Maps 2026-09-29 14:18:09 +00:00
parent 7c99d1a06c
commit 9d00aa3db4
3 changed files with 191 additions and 1 deletions

View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* check-snippets-draw — every PUBLISHED embed snippet draws, pasted verbatim on a partner page on another origin.
* (card GATE-EVERY-PUBLISHED-SNIPPET-DRAWS-A-MAP; ported from rega's probes snipgate.mjs and mbxcheck.mjs)
*
* WHY. The manifest's embed snippets are the one artefact a partner copies. check:snippet-engine proves what a
* snippet SAYS (the engine it preloads, the provider it names); only a browser proves it DRAWS. The probe this is
* ported from caught what no static check can: country-map's null centre, a dead map from an unedited paste.
*
* TWO ARMS, TWO SCRIPTS, so the keyless arm is never held hostage by a missing token:
* default check:snippets-draw each production profile's embed.script, verbatim: only YOUR_TREE_URL and
* YOUR_SOURCE filled, "tk":"YOUR_MAPBOX_TOKEN" left as an unedited partner
* leaves it. A map must draw with MapLibre.
* --mapbox check:snippets-draw:mapbox each production profile's embed.scriptMapbox, verbatim, with ONLY
* YOUR_MAPBOX_TOKEN filled from env MAP_TOKEN. No token: NOT MEASURED (75),
* never a pass. The token is never printed: pk.* and access_token= are
* redacted in every logged line.
*
* PASS, per profile:
* draws a map a hooked engine instance on the EXPECTED engine reports isStyleLoaded() with a sized canvas, >= 1
* tile answered 2xx (glyphs and sprites excluded), no failure note ("could not start" / "token or
* connection"), 0 page errors. The hook is a SETTER TRAP: Object.defineProperty on window.maplibregl /
* mapboxgl wraps Map the moment the UMD assigns the global (a 3 ms poll alone missed upg-100-mfe).
* If no instance is caught, the evidence must hold instead: style 2xx + tiles 2xx + a sized canvas of
* the expected engine's class + no note.
* no map >= 20 elements in the element's shadow tree and >= 50 characters of visible text, 0 page errors.
* ★ A COMPOSITE THAT OPENS ON A LIST is pressed to its map view first (PRESS), with the settle rule: click only when
* the target is the top element at its centre and that centre held still across two reads 200 ms apart.
* regions-mfe's engine draws while its map pane is hidden, so without the press a canvas check passes on a map
* nobody sees.
*
* Browser gate: through lib/playwright-guarded, so NOT MEASURED outside the browser slot.
* Usage: node internal/check-snippets-draw.mjs [--mapbox] [--only bundle/profile,…] [--all]
* EXIT 0 every production snippet draws · 1 one does not · 2 usage · 75 not measured
*/
import { chromium, NOT_MEASURED, requireBrowserSlot } from './lib/playwright-guarded.mjs'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, existsSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
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 MAPBOX = argv.includes('--mapbox')
const ALL = argv.includes('--all')
const ONLY = argv.includes('--only') ? String(argv[argv.indexOf('--only') + 1] || '').split(',').filter(Boolean) : null
const TK = process.env.MAP_TOKEN || ''
const red = (s) => String(s).replace(/pk\.eyJ[A-Za-z0-9_.-]*/g, 'pk.R').replace(/access_token=[^&\s"]+/g, 'access_token=R')
const LABEL = MAPBOX ? 'SNIPPETS-DRAW (mapbox)' : 'SNIPPETS-DRAW'
// Composites that open on a list: the button that shows the map. Pressed in both arms.
const PRESS = {
'regions-dashboard/regions-mfe': '^region dashboards$',
'my-upg-100-list/upg-100-mfe': '^map$',
}
if (MAPBOX && !/^pk\./.test(TK)) { console.log(`${LABEL}: NOT MEASURED — no Mapbox token in MAP_TOKEN (never a pass without one)`); process.exit(NOT_MEASURED) }
const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(MANIFEST)) { console.log(`${LABEL}: NOT MEASURED — no public/${TREE_NAME}/manifest.json; build the tree first`); process.exit(NOT_MEASURED) }
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const every = []
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
const script = MAPBOX ? p.embed?.scriptMapbox : p.embed?.script
if (script) every.push({ id: `${b.bundle}/${p.profile}`, bundle: b.bundle, stage: p.stage || '?', isMap: !!p.isMap || !!p.embed?.scriptMapbox, script })
}
const unknown = (ONLY || []).filter((x) => !every.some((y) => y.id === x))
if (unknown.length) { console.error(`${LABEL}: --only names no ${MAPBOX ? 'scriptMapbox ' : ''}snippet: ${unknown.join(', ')}`); process.exit(2) }
const LIST = every.filter((x) => (ONLY ? ONLY.includes(x.id) : x.stage === 'production' || ALL))
.sort((a, c) => (a.stage === 'production' ? 0 : 1) - (c.stage === 'production' ? 0 : 1))
const free = () => new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) })
// The slot is checked BEFORE anything is started: the guard refuses with process.exit, and a server spawned
// before that refusal outlives the gate (measured 2026-09-29: 41 orphaned serve-strict processes on this box).
requireBrowserSlot('check-snippets-draw')
const TP = await free()
const srv = spawn(process.execPath, [join(ROOT, 'internal/serve-strict.mjs'), '--port', String(TP), '--directory', join(ROOT, 'public'), '--exit-with-parent'], { stdio: 'ignore' })
const TREE = `http://127.0.0.1:${TP}/${TREE_NAME}`
// The partner page is served from ANOTHER origin (localhost, not 127.0.0.1), as a partner's site would be.
const fill = (x) => {
let s = x.script.replaceAll(`./${x.bundle}/`, `${TREE}/${x.bundle}/`).replaceAll('YOUR_TREE_URL', TREE).replaceAll('YOUR_SOURCE', 'check-snippets-draw')
if (MAPBOX) s = s.replaceAll('YOUR_MAPBOX_TOKEN', TK)
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>partner · ${x.id}</title></head><body><main style="max-width:1200px;margin:0 auto"><h1>Partner page</h1><div style="min-height:820px">${s}</div></main></body></html>`
}
const psrv = createServer((req, res) => {
const id = decodeURIComponent((req.url.match(/\/s\/(.+)$/) || [])[1] || '')
const x = every.find((y) => y.id === id)
res.writeHead(x ? 200 : 404, { 'content-type': 'text/html; charset=utf-8' }); res.end(x ? fill(x) : 'no snippet')
})
const PP = await free()
await new Promise((ok) => psrv.listen(PP, '127.0.0.1', ok))
const stop = () => { try { srv.kill() } catch {} try { psrv.close() } catch {} }
for (const s of ['SIGTERM', 'SIGINT']) process.on(s, () => { stop(); process.exit(143) })
process.on('exit', () => { try { srv.kill() } catch {} }) // every exit path, crashes included
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`${TREE}/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`${LABEL}: NOT MEASURED — could not serve public/`); process.exit(NOT_MEASURED) }
const TILE = /\.(pbf|mvt)(\?|$)|\/tiles?\/|\/v4\/|\/\d+\/\d+\/\d+(@2x)?(\.(png|jpe?g|webp|pbf|mvt))?(\?|$)/i
const WANT = MAPBOX ? 'mapboxgl' : 'maplibregl'
const DEEP = `const all = []; const w = (r) => { for (const e of r.querySelectorAll('*')) { all.push(e); if (e.shadowRoot) w(e.shadowRoot) } }; w(document);`
const OUT = mkdtempSync(join(tmpdir(), 'snippets-draw-'))
const browser = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const results = []
try {
for (const x of LIST) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
const errs = []; const engines = new Set(); let tilesOk = 0; let tilesBad = 0; const styleOk = []
page.on('pageerror', (e) => errs.push(red(e.message).slice(0, 110)))
page.on('request', (r) => { const u = r.url(); if (/maplibre-gl|mapbox-gl/.test(u)) engines.add(/maplibre-gl/.test(u) ? 'maplibre-gl' : 'mapbox-gl') })
page.on('response', (r) => {
const u = r.url()
if (/^https?:\/\/(127\.0\.0\.1|localhost)/.test(u)) return // our own tree and page, not the basemap
if (TILE.test(u) && !/\/(fonts|glyphs|sprites?)\//.test(u)) { if (r.status() < 300) tilesOk++; else tilesBad++ }
else if (/\/styles?\/|style\.json/.test(u) && r.status() < 300) styleOk.push(red(u).split('?')[0].slice(-60))
})
await page.addInitScript(() => {
window.__sdMaps = []
const wrap = (k, g) => { if (g && g.Map && !g.Map.prototype.__sd) { const O = g.Map; g.Map = class extends O { constructor(...a) { super(...a); window.__sdMaps.push({ k, m: this }) } }; g.Map.prototype.__sd = 1 } return g }
for (const k of ['maplibregl', 'mapboxgl']) { let v; try { Object.defineProperty(window, k, { configurable: true, get() { return v }, set(y) { v = wrap(k, y) } }) } catch {} }
const t = setInterval(() => { for (const k of ['maplibregl', 'mapboxgl']) wrap(k, window[k]) }, 3); setTimeout(() => clearInterval(t), 120000)
})
const t0 = Date.now(); let st = null; let pressed = null
try {
await page.goto(`http://localhost:${PP}/s/${encodeURIComponent(x.id)}`, { waitUntil: 'domcontentloaded', timeout: 90000 })
if (PRESS[x.id]) {
// The settle rule: the target must be the top element at its centre, and hold still across two reads 200 ms apart.
const find = `(() => { ${DEEP} const t = all.find((e) => e.tagName === 'BUTTON' && new RegExp(${JSON.stringify(PRESS[x.id])}, 'i').test((e.textContent || '').replace(/\\s+/g, ' ').trim()) && e.getBoundingClientRect().width > 0); if (!t) return null; const q = t.getBoundingClientRect(); const cx = q.left + q.width / 2, cy = q.top + q.height / 2; let top = document.elementFromPoint(cx, cy); while (top && top.shadowRoot) { const d = top.shadowRoot.elementFromPoint(cx, cy); if (!d || d === top) break; top = d } return { x: Math.round(cx), y: Math.round(cy), hit: !!top && (top === t || t.contains(top)) } })()`
let prev = null
for (let k = 0; k < 150; k++) {
const r = await page.evaluate(find)
if (r && r.hit && prev && prev.x === r.x && prev.y === r.y) { await page.mouse.click(r.x, r.y); pressed = r; break }
prev = r; await page.waitForTimeout(200)
}
}
for (let k = 0; k < 40; k++) {
await page.waitForTimeout(1000)
st = await page.evaluate(`(() => { ${DEEP}
const canv = all.filter((e) => e.tagName === 'CANVAS' && /gl-canvas/.test(String(e.className))).map((e) => ({ cls: String(e.className).split(' ')[0], w: e.clientWidth, h: e.clientHeight }))
const maps = (window.__sdMaps || []).map((y) => { try { return { k: y.k, loaded: y.m.isStyleLoaded(), w: y.m.getCanvas().clientWidth, h: y.m.getCanvas().clientHeight } } catch { return { k: y.k, loaded: false, w: 0, h: 0 } } })
const host = [...document.querySelectorAll('main *')].find((e) => e.shadowRoot); const inner = []
if (host) { const ww = (n) => { for (const e of n.querySelectorAll('*')) { inner.push(e); if (e.shadowRoot) ww(e.shadowRoot) } }; ww(host.shadowRoot) }
const text = inner.filter((e) => { const q = e.getBoundingClientRect(); return q.width > 0 && q.height > 0 && e.children.length === 0 }).map((e) => (e.textContent || '').trim()).join(' ').length
const note = (all.find((e) => e.children.length < 3 && /could not start|token or connection/i.test(e.textContent || '')) || {}).textContent || null
return { canv, maps, shadow: inner.length, text, note: note ? note.trim().slice(0, 90) : null } })()`)
const drew = st.maps.some((m) => m.k === WANT && m.loaded && m.w > 0 && m.h > 0) && tilesOk > 0
if (x.isMap ? drew : (st.shadow >= 20 && st.text >= 50 && Date.now() - t0 > 12000)) break
}
} catch (e) { errs.push('PROBE ' + red(String(e.message || e)).split('\n')[0].slice(0, 100)) }
const why = []
if (!st) why.push('page never measured')
else if (x.isMap) {
const hooked = st.maps.find((m) => m.k === WANT && m.loaded && m.w > 0 && m.h > 0)
const canvasOk = st.canv.some((c) => c.cls === `${WANT}-canvas` && c.w > 0 && c.h > 0)
const evidence = !st.maps.length && canvasOk && styleOk.length > 0 && tilesOk > 0 && !st.note
if (!hooked && !evidence) why.push(`no ${WANT} map with its style loaded and a sized canvas`)
if (!tilesOk) why.push('0 tiles answered 2xx')
if (st.note) why.push(`failure note shown: "${st.note}"`)
if (MAPBOX && engines.has('maplibre-gl')) why.push('fetched maplibre-gl on the Mapbox snippet')
if (!MAPBOX && engines.has('mapbox-gl')) why.push('fetched mapbox-gl on the default snippet')
if (PRESS[x.id] && !pressed) why.push('its map view could not be pressed (the settle rule never held)')
} else {
if (st.shadow < 20) why.push(`shadow tree has ${st.shadow} elements`)
if (st.text < 50) why.push(`visible text ${st.text} chars`)
}
if (errs.length) why.push(`${errs.length} page error(s): ${errs[0]}`)
const verdict = why.length ? 'FAIL' : 'PASS'
if (verdict === 'FAIL') await page.screenshot({ path: join(OUT, `${x.id.replace('/', '_')}.png`) }).catch(() => {})
results.push({ ...x, verdict, why })
console.log(`${verdict} [${x.stage}] ${x.id} · ${x.isMap ? `map on ${WANT}` : 'no map'}${pressed ? ' · pressed its map view' : ''} · ${why.join('; ') || 'ok'} · tiles 2xx ${tilesOk} / other ${tilesBad} · ${Math.round((Date.now() - t0) / 1000)} s`)
await ctx.close()
}
} finally { await browser.close(); stop() }
const prod = results.filter((r) => r.stage === 'production'); const failed = prod.filter((r) => r.verdict === 'FAIL')
const extra = results.filter((r) => r.stage !== 'production')
if (!prod.length) { console.log(`${LABEL}: NOT MEASURED — no production ${MAPBOX ? 'scriptMapbox ' : ''}snippets selected`); process.exit(NOT_MEASURED) }
console.log(`\n${LABEL}: ${failed.length ? 'FAIL' : 'PASS'} — production ${prod.length - failed.length}/${prod.length} draw`
+ (failed.length ? ` · FAIL: ${failed.map((r) => `${r.id} (${r.why.join('; ')})`).join(' | ')} · screenshots in ${OUT}` : '')
+ (extra.length ? ` · experimental (information only) ${extra.filter((r) => r.verdict === 'PASS').length}/${extra.length}` : ''))
process.exit(failed.length ? 1 : 0)

View file

@ -7,6 +7,8 @@
"check:composite": "rebuilds every bundle with vite to compare its bytes, minutes long by design; on every landing the precondition's fresh step rebuilds the composites that landing reaches",
"check:hardcoded-english": "report-only while its findings are triaged (97 user-visible English strings on 2026-09-29); chaining it now would hold every gate red. Its :self-test IS chained, so the matcher stays proven",
"check:shot-fonts": "checks that the MACHINE taking README screenshots can render emoji (fc-list): a property of that box, not of the product, so it runs before a screenshot session, not in the gates",
"check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained"
"check:coverage-consistent": "the full run includes clause L, the age of the oldest link verification (45 days), which depends on the CALENDAR: chained, it would turn gates red on a date (first 2026-11-08) with no code change. Run it with the link re-verification; every other clause runs in gates:pre as check:coverage-consistent:chained",
"check:snippets-draw": "AWAITING ITS FIRST BROWSER RUN (rega): the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded.",
"check:snippets-draw:mapbox": "AWAITING ITS FIRST BROWSER RUN (rega): the browser suite lists a gate only after it has run. Moves into verify-browser-all's GATES, and out of this list, once that run is recorded. Needs MAP_TOKEN."
}
}

View file

@ -387,6 +387,8 @@
"check:wall-print-watch": "node plugin-designs/wall-of-people-groups/scripts/check-wall-print-watch.mjs",
"check:wall-no-bg-photos": "node plugin-designs/wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs",
"check:template-i18n": "node plugin-designs/template-bundle/scripts/check-template-i18n.mjs",
"check:snippets-draw": "node internal/check-snippets-draw.mjs",
"check:snippets-draw:mapbox": "node internal/check-snippets-draw.mjs --mapbox",
"pin:poster-render": "node library/scripts/posters/pin-poster-render.mjs",
"check:board-map-options": "node internal/check-board-map-options.mjs"
},
@ -401,6 +403,8 @@
"check:auto-engine": "BROWSER. The default engine (auto) is MapLibre, token or not: on one map leaf, no token AND a host token alone both draw with MapLibre, paint, and make zero api.mapbox.com requests; ?provider=mapbox with that token draws Mapbox and carries it. In gates:post, NOT MEASURED outside the browser slot.",
"check:no-new-driver-references": "shipped text (library, plugins, internal, docs, skills, README) neither quotes nor cites the Driver: a line that refers to the Driver and is not in internal/fixtures/driver-references-baseline.json FAILS. Forward-only: the baseline, keyed by file and line text with counts (not line numbers), may only shrink as owners sweep their areas (--shrink tidies it; it never grows). Matches the role word only, not a GPU or device driver. Node only.",
"check:no-orphan-gates": "every gate-shaped internal/*.mjs is reachable or tagged @one-shot; and every check:/verify:/smoke: script is run by a standing chain, matched by EXACT name (gates:pre, gates:post, the browser suite GATES, the build, and what those bun run), or declared in internal/fixtures/on-demand-checks.json with its reason, or in the shrink-only baseline of the 2026-09-29 orphans. A chained :self-test does not chain its gate. --shrink / --init maintain the baseline. And every gate-shaped FILE under plugin-designs/ or library/ is run by PATH from a script or another file's code (comments stripped; a basename counts only when no other gate file shares it), tagged @one-shot, or in the baseline's `files` list.",
"check:snippets-draw": "BROWSER. Every production profile's published embed.script, pasted VERBATIM on a partner page on another origin (only YOUR_TREE_URL / YOUR_SOURCE filled), must DRAW: a hooked MapLibre instance with its style loaded, a sized canvas and tiles 2xx, no failure note, 0 page errors (a no-map profile: a rendered shadow tree with text). Composites that open on a list are pressed to their map view first (settle rule). Ported from rega's snipgate probe. NOT MEASURED outside the browser slot.",
"check:snippets-draw:mapbox": "BROWSER. The same over every embed.scriptMapbox, verbatim with ONLY YOUR_MAPBOX_TOKEN filled from env MAP_TOKEN, and the map must draw on Mapbox fetching no maplibre-gl. NOT MEASURED without a token, never a pass; the token is redacted from every logged line.",
"check:wall-wysiwyg": "the Wall's screen IS its poster: the Bless view's on-screen poster and its print come from one layout, so for every preset (3x4ft, 2x3ft, 5x8ft) the sheets, the 2,107 card rects, the card text, the verse reference and the chrome agree. Two-sided on planted layout defects. Headless.",
"check:wall-print-watch": "the Wall's HD poster print never prints silently incomplete: with fake timers and a fake frame, the watch counts photos as they load, counts the ones that could not, and reports both. Two-sided on planted printWatch copies. Headless.",
"check:wall-no-bg-photos": "the Wall's store downloads no photos on its own (it used to test all 932, 132 MB, on every visit): the REAL store runs through Vite against the committed fixtures/pray-list-sample.json with fetch stubbed and every Image request counted. Two-sided with the old background test planted. Headless, no network.",