doxa-maps/internal/verify-map-paint.mjs

151 lines
7.4 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'
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',
]
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)
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, 100)) })
try {
await page.goto(url, { 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 ok = r.shadow && r.glContext && r.attribution && !r.asksForToken && errors.length === 0
if (ok) {
console.log(' PASS ' + name + ' — canvas ' + r.canvas + ', live GL, attribution, 0 errors')
} 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)')
for (const e of errors.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.')