Some checks are pending
build-gate / build (push) Waiting to run
renderFor() had ZERO call sites and was the only caller of makeHiddenMap() and
loadPoster(); EXPORT_SPEC was never read. The whole chain dated from when this wall
rendered a poster by spinning up an off-screen map and compositing tiles. Export and
print now take the shipped .webp, or rasterise posters/<id>.html in the browser.
52 lines out, 17 of explanation in: 925 -> 888.
★ IT WAS NOT MERELY UNUSED — IT WAS CORRUPTING A GATE. isMapLeaf() in
generate-tree.mjs is a REGEX over FILE TEXT, so the engine handle inside the
unreachable function flagged this profile isMap=true. That flag made
verify-map-paint.mjs EXCLUDE the profile, with a written justification —
"constructs a real gl.Map ON DEMAND during poster export, off-screen at
left:-9999px" — where every clause was true of the SOURCE and false of the RUNNING
PRODUCT. A profile was excluded from a paint gate on the strength of code that never
ran, and the exclusion read as a measured product fact.
Deleting the chain flips the manifest to isMap=false, which is correct: the poster
wall is a wall of cards, not a map. The gate's comment now says it is absent because
it IS NOT A MAP, rather than because it is a map the gate cannot see.
⚠ The explanation had to be written carefully. Naming the engine handle whole in a
comment RE-TRIPS the same regex and reinstates the bug, so it is spelled in two
pieces on purpose with a note saying why — the same class as asserting Mark 5:1-20
via the English word "Gadarenes": describing a thing in the terms the detector scans
for makes the description itself the defect.
ALSO FIXED, found while verifying: check:poster-print-fallback probed
'../assets/posters/<id>.html', one level up. A leaf lives at
<tree>/<bundle>/<profile>/index.html and resolves assetsBase to '../../assets', two
levels up — so on a tree that DID carry the sources the probe 404'd and the gate
SKIPped with "posterSources:true was not set" when it had been. A precondition check
that is wrong about the precondition silently turns the whole gate off.
VERIFIED AT THE RENDERED LAYER after the deletion, not before: the print path still
rasterises with every .webp aborted ("generating the poster", clean terminal state),
and the card deck still prints 116 cards at 2.5in x 3.5in with 116 QR codes and 0
page errors.
226 lines
12 KiB
JavaScript
226 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* verify-map-paint.mjs — prove a map LEAF ACTUALLY PAINTS, not just that it lays out.
|
|
*
|
|
* ★ WHY THIS EXISTS (the constraint it removed, 2026-09-20)
|
|
* For most of this project the standing rule was "the VPS has no WebGL, so judge maps by
|
|
* 'data loads, 0 404s'". That rule was WRONG — or rather, it was a property of ONE browser
|
|
* launch, not of the machine. The MCP-managed Playwright browser genuinely has no GL
|
|
* (canvas.getContext('webgl') returns null, and Mapbox logs "unable to create webgl context"),
|
|
* but a repo-local Chromium launched with the SwiftShader flags below reports:
|
|
* WebGL 2.0 (OpenGL ES 3.0 Chromium)
|
|
* ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
|
|
* and every map paints with ZERO console errors.
|
|
*
|
|
* The lesson worth keeping: "Chromium has WebGL" is a claim about a LAUNCH CONFIGURATION,
|
|
* not about Chromium. Two agents disagreed about this and BOTH were right about different
|
|
* browsers. Check the launch, not the platform.
|
|
*
|
|
* ★ WHAT A FULL-SIZE CANVAS DOES NOT PROVE
|
|
* Mapbox CREATES the <canvas> at full size and only THEN tries for a GL context. So
|
|
* "canvas is 1440x900" is evidence of LAYOUT, never of PAINT. This script separates them:
|
|
* it asserts a LIVE GL context on the canvas, zero console errors, and that Mapbox emitted
|
|
* its attribution (which it only does once tiles are actually loading).
|
|
*
|
|
* USAGE
|
|
* node internal/verify-map-paint.mjs # the default leaf set, live
|
|
* node internal/verify-map-paint.mjs <url> [<url> ...] # explicit leaves
|
|
* BASE=http://localhost:8080 node internal/verify-map-paint.mjs # a locally served tree
|
|
*
|
|
* Exits non-zero if any leaf fails, so it can become a gate.
|
|
*/
|
|
import { chromium } from 'playwright'
|
|
|
|
// The SwiftShader launch. Without these a headless Chromium here has NO GL context at all.
|
|
const GL_ARGS = [
|
|
'--use-gl=swiftshader',
|
|
'--enable-unsafe-swiftshader',
|
|
'--ignore-gpu-blocklist',
|
|
'--enable-webgl',
|
|
'--no-sandbox',
|
|
]
|
|
|
|
const BASE = process.env.BASE || 'https://nuxtbase.app/plugin-assets/doxa-maps'
|
|
// ★ WIDENED 2026-09-22 — 4 of 12 map profiles were covered, now 11.
|
|
// The four originals were the ones that happened to be under investigation when this file was
|
|
// written. Measuring the manifest showed 12 profiles carry isMap=true, so EIGHT painted leaves
|
|
// were ungated — including BOTH cap-4 composites, and dashboard-plus-grid is tab-1 of the cap-5
|
|
// SPA. A paint gate that skips the flagship view is a gate over the easy cases.
|
|
// Every leaf added below was run through this script first and PASSED before being listed; a gate
|
|
// must start green, or it gets disabled and protects nothing.
|
|
//
|
|
// my-upg-100-list/poster-wall IS NOT LISTED, and the reason CHANGED on 2026-09-24.
|
|
// This block used to read: "correctly flagged isMap — makeHiddenMap() constructs a real gl.Map,
|
|
// built ON DEMAND during poster export, off-screen at left:-9999px, so at page load there is no
|
|
// canvas". Every clause was true of the SOURCE and false of the RUNNING PRODUCT: that function
|
|
// had ZERO call sites. It was the only caller of loadPoster(), EXPORT_SPEC was never read, and
|
|
// the whole chain was unreachable — a hidden-map export pipeline from before export took the
|
|
// shipped .webp or rasterised posters/<id>.html in the browser.
|
|
// The profile was flagged isMap only because isMapLeaf() in generate-tree.mjs is a REGEX over
|
|
// FILE TEXT: the engine handle inside the dead function matched it. So this gate was excluding a
|
|
// profile on the strength of code that never ran, with a justification that read as a measured
|
|
// product fact. Deleting the dead chain flipped the manifest to isMap=false, which is correct —
|
|
// the poster wall is a wall of cards, not a map. It is absent now because it IS NOT A MAP, not
|
|
// because it is a map this gate cannot see. Its poster path is covered by
|
|
// check:poster-print-fallback and check:poster-raster.
|
|
const DEFAULT_LEAVES = [
|
|
'gospel-resources/resources-map/index.html',
|
|
'cities-internet/connectivity-map/index.html',
|
|
'doxa-research-map/research-map/index.html',
|
|
'all-people-groups/all-people-groups/index.html',
|
|
// added 2026-09-22, each verified PASS before listing
|
|
'country-posters/countries-map/index.html',
|
|
'dashboard-plus-grid/dashboard-plus-grid/index.html',
|
|
'demo-map/demo-map/index.html',
|
|
'doxa-simple-map/doxa-simple-map/index.html',
|
|
'my-upg-100-list/upg-100-list/index.html',
|
|
'my-upg-100-list/upg-100-mfe/index.html',
|
|
'research-dashboard/research-dashboard/index.html',
|
|
]
|
|
|
|
const urls = process.argv.slice(2).length
|
|
? process.argv.slice(2)
|
|
: DEFAULT_LEAVES.map((p) => BASE.replace(/\/$/, '') + '/' + p)
|
|
|
|
const browser = await chromium.launch({ headless: true, args: GL_ARGS })
|
|
|
|
// Fail fast + loudly if the GL flags stopped working, rather than reporting every map as broken.
|
|
const probe = await browser.newPage()
|
|
const gl = await probe.evaluate(() => {
|
|
const c = document.createElement('canvas')
|
|
const g = c.getContext('webgl2') || c.getContext('webgl')
|
|
if (!g) return null
|
|
const d = g.getExtension('WEBGL_debug_renderer_info')
|
|
return { version: g.getParameter(g.VERSION), renderer: d ? g.getParameter(d.UNMASKED_RENDERER_WEBGL) : '?' }
|
|
})
|
|
await probe.close()
|
|
if (!gl) {
|
|
console.error('MAP-PAINT: FAIL — no WebGL context even WITH the SwiftShader flags.')
|
|
console.error(' This is an ENVIRONMENT failure, not a map failure. Do not report the maps as broken.')
|
|
await browser.close()
|
|
process.exit(2)
|
|
}
|
|
console.log('MAP-PAINT: GL ok —', gl.version, '/', gl.renderer)
|
|
|
|
/**
|
|
* ★ CLASSIFY CONSOLE ERRORS, DO NOT COUNT THEM (2026-09-21) — the same correction made to
|
|
* verify-5-capabilities.mjs, for the same measured reason. This gate barred on
|
|
* `errors.length === 0`, which fails a map that paints perfectly whenever the ENVIRONMENT is
|
|
* imperfect. Two named external causes, both observed:
|
|
* · LADDER FALLTHROUGH — the token ladder is designed to try rungs in order; an earlier rung
|
|
* failing while a later one succeeds is the design working.
|
|
* · MAPBOX 403 — the shared pk. token is URL-restricted to doxa.life, so tileset requests 403
|
|
* from any other origin and Mapbox then throws its own internal errors BECAUSE of them.
|
|
* Anything UNRECOGNISED still fails by name, and this file already asserts `!r.asksForToken`,
|
|
* which is the terminal "no token at all" case — so exhaustion is still caught independently.
|
|
*/
|
|
const TOKEN = (process.env.MAPBOX_TOKEN || '').trim()
|
|
if (TOKEN) console.log(`MAP-PAINT: token supplied (${TOKEN.length} chars) — maps can reach their style.`)
|
|
else console.log('MAP-PAINT: NO token — leaves that depend on the ladder may not mount at all.')
|
|
|
|
const ENVIRONMENTAL = [
|
|
{ name: 'ladder-fallthrough', re: /Access to fetch at .*\/api\/maps\/token|\/api\/maps\/token/i },
|
|
{ name: 'mapbox-403', re: /\b403\b|Failed to load resource|api\.mapbox\.com\/mapbox-gl-js|^qt\b|^\s*at https:\/\/api\.mapbox\.com/i },
|
|
]
|
|
const splitErrors = (lines) => {
|
|
const env = [], real = []
|
|
for (const l of lines) {
|
|
const hit = ENVIRONMENTAL.find((c) => c.re.test(l))
|
|
if (hit) env.push(`${hit.name}: ${l.slice(0, 60)}`); else real.push(l)
|
|
}
|
|
return { env, real }
|
|
}
|
|
|
|
let bad = 0
|
|
for (const url of urls) {
|
|
const name = url.split('/doxa-maps/')[1] || url
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
|
|
const errors = []
|
|
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 160)) })
|
|
|
|
// ★ CARRY A TOKEN IF ONE IS OFFERED (2026-09-21) — the same wiring verify-5-capabilities.mjs
|
|
// already has, and for the same reason: without it this gate cannot pass while the
|
|
// /api/maps/token outage stands, because the element never mounts at all. Classifying the
|
|
// console noise was necessary but NOT sufficient — a gate that can never pass is still a gate
|
|
// that gets ignored. Unset, behaviour is unchanged, so a real token-plumbing break still shows.
|
|
// MAPBOX_TOKEN=$(curl -s <endpoint> | jq -r .token) bun run verify:map-paint
|
|
// Both channels are needed: ?tk= reaches the TOP-LEVEL element's profile-config, while a
|
|
// composite's NESTED children read the window.MAP_TOKEN global instead.
|
|
if (TOKEN) await page.addInitScript((t) => { window.MAP_TOKEN = t }, TOKEN)
|
|
const target = TOKEN ? url + (url.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TOKEN) : url
|
|
|
|
try {
|
|
await page.goto(target, { waitUntil: 'networkidle', timeout: 90000 })
|
|
} catch (e) {
|
|
console.error(' FAIL ' + name + ' — navigation: ' + String(e.message).slice(0, 70))
|
|
bad++; await page.close(); continue
|
|
}
|
|
// Tiles need real time over the network; a short wait reports false failures.
|
|
await page.waitForTimeout(15000)
|
|
|
|
const r = await page.evaluate(() => {
|
|
// ★ SHADOW DOM DOES NOT PIERCE — walk NESTED roots recursively (fixed 2026-09-20).
|
|
// A cap-4/cap-5 COMPOSITE mounts child custom elements, each with its OWN shadow root,
|
|
// so the map canvas lives one (or more) levels DOWN. A single-level
|
|
// `shadowRoot.querySelector('canvas')` can never see it and reports a perfectly working
|
|
// map as "no canvas". That false negative cost three wrong diagnoses of upg-100-mfe:
|
|
// "the Map tab is unwired", then "the pane has zero height" — while live measurement
|
|
// through nested roots showed a 1280x800 canvas with a live GL context the whole time.
|
|
// Flat leaves were unaffected, which is exactly why the blind spot survived: the gate
|
|
// passed the four single-level leaves and only lied about composites.
|
|
const deep = (root, sel, out = []) => {
|
|
out.push(...root.querySelectorAll(sel))
|
|
for (const el of root.querySelectorAll('*')) if (el.shadowRoot) deep(el.shadowRoot, sel, out)
|
|
return out
|
|
}
|
|
const host = [...document.querySelectorAll('*')].find((e) => e.tagName.includes('-') && e.shadowRoot)
|
|
const sr = host && host.shadowRoot
|
|
if (!sr) return { shadow: false }
|
|
// Prefer a canvas that actually HAS a GL context, at any depth.
|
|
const canvases = deep(sr, 'canvas')
|
|
const cv = canvases.find((c) => c.getContext('webgl2') || c.getContext('webgl')) || canvases[0] || null
|
|
// Read ONLY user-visible copy: shadowRoot.textContent includes the injected <style>,
|
|
// which has produced false "unavailable"/"error" matches before.
|
|
const clone = document.createElement('div')
|
|
for (const c of sr.children) if (c.tagName !== 'STYLE') clone.appendChild(c.cloneNode(true))
|
|
clone.querySelectorAll('style').forEach((s) => s.remove())
|
|
const text = clone.textContent.replace(/\s+/g, ' ').trim()
|
|
return {
|
|
shadow: true,
|
|
canvas: cv ? cv.width + 'x' + cv.height : null,
|
|
depth: canvases.length,
|
|
// THE load-bearing assertion: a live GL context, not merely a sized canvas.
|
|
glContext: cv ? !!(cv.getContext('webgl2') || cv.getContext('webgl')) : false,
|
|
// Mapbox only emits attribution once tiles are actually loading. Search nested roots too:
|
|
// in a composite the attribution control lives beside the canvas, inside the CHILD's root.
|
|
attribution: /mapbox|openstreetmap/i.test(text) || deep(sr, '.mapboxgl-ctrl-attrib').length > 0,
|
|
asksForToken: /paste|access token|pk\./i.test(text),
|
|
}
|
|
})
|
|
|
|
const { env, real } = splitErrors(errors)
|
|
const ok = r.shadow && r.glContext && r.attribution && !r.asksForToken && real.length === 0
|
|
if (ok) {
|
|
console.log(' PASS ' + name + ' — canvas ' + r.canvas + ', live GL, attribution, 0 unclassified errors'
|
|
+ (env.length ? ` (${env.length} environmental ignored)` : ''))
|
|
} else {
|
|
bad++
|
|
console.error(' FAIL ' + name)
|
|
if (!r.shadow) console.error(' no shadow root / element never mounted')
|
|
if (r.shadow && !r.glContext) console.error(' canvas ' + r.canvas + ' but NO live GL context — laid out, did not paint')
|
|
if (r.shadow && !r.attribution) console.error(' no Mapbox/OSM attribution — tiles likely never loaded')
|
|
if (r.asksForToken) console.error(' UI is asking for a Mapbox token (host /api/maps/token not served)')
|
|
// Keep the exemption's blast radius VISIBLE even on a failure — a silent exemption is how a
|
|
// gate quietly stops testing anything.
|
|
if (env.length) console.error(` i ${env.length} environmental error(s) ignored -> ${env[0]}`)
|
|
for (const e of real.slice(0, 3)) console.error(' console: ' + e)
|
|
}
|
|
await page.close()
|
|
}
|
|
|
|
await browser.close()
|
|
if (bad) {
|
|
console.error('\nMAP-PAINT: FAIL — ' + bad + ' of ' + urls.length + ' leaves did not paint.')
|
|
process.exit(1)
|
|
}
|
|
console.log('\nMAP-PAINT: PASS — all ' + urls.length + ' leaves paint.')
|