check:experimental-shelf looks at the map: paint arm with its own poisons, and settled counts
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

Every earlier assertion counted FEATURES, and queryRenderedFeatures answers happily over a white
rectangle (gospel's incident, 0574fe7f). Each map surface now also passes two looks:
  - COLOURS read off the map's own WebGL canvas inside its 'render' event (floor 40), so a legend
    or panel over the map cannot inflate the count and no per-plugin clip is needed;
  - FILL = the map's height over the plugin ELEMENT's (floor 70%), not canvas over container,
    which stayed 1.0 when a stylesheet collapsed both together.
Each arm has its own self-test poison and must be the one that catches it: 'blank' (every layer's
opacity 0) -> "only 1 colour(s) on its canvas ... while 12204 features still count as drawn";
'collapse' (container squeezed to 300px) -> "fills 38% of <all-people-groups>'s height".
Drawn counts are now read SETTLED (equal on two consecutive polls): a first-non-zero read took
1752 of 2107 mid-fit on Cities. The PASS line names only the surfaces that ran.
Measured: 7 bundles, 60 rows PASS, 3 n/a; lowest paint across the 40 map rows 264 colours, 92%.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 19:26:26 +00:00
parent 9fb4f8350a
commit a7c65c2bb9

View file

@ -18,6 +18,12 @@
* not one baked into the bundle). A MapLibre-only plugin takes no token: reported n/a
* D its tab inside the Research Gallery
* B and C test exactly what a partner pastes, so a broken snippet fails here too.
* Every surface with a map also gets the PAINT arm (after gospel's check-gospel-map-paints):
* distinct colours read off the map's own canvas (floor 40) and the map's height over the plugin
* element's (floor 70%). Feature counts cannot see a white rectangle; these two can. Each has its
* own self-test poison and must be the arm that catches it: 'blank' (every layer's opacity 0,
* every feature still "drawn") and 'collapse' (the container squeezed to 300px).
* Drawn counts are read SETTLED: equal on two consecutive polls, never the first non-zero read.
* A surface passes when:
* - the element mounted with content (its shadow tree has real nodes),
* - 0 page errors and 0 failed requests (4xx/5xx; Mapbox telemetry and favicons excluded),
@ -59,6 +65,10 @@ const ONLY = arg('--only', '') ? arg('--only', '').split(',') : null
const SURFACES = arg('--surfaces', 'A,B,C,D').split(',')
const SELF_TEST = !argv.includes('--no-self-test')
const WAIT_MS = Number(process.env.SHELF_WAIT_MS || 75000)
// the PAINT arm (after gospel's check-gospel-map-paints, 0574fe7f): every assertion above counts
// FEATURES, and queryRenderedFeatures answers happily over a white rectangle. These two look.
const MIN_COLOURS = Number(process.env.MIN_MAP_COLOURS || 40)
const MIN_FILL = Number(process.env.MIN_MAP_FILL || 0.7)
const { chromium } = await import(resolve(ROOT, 'node_modules/playwright/index.mjs'))
const manifest = JSON.parse(readFileSync(resolve(ROOT, `public/${TREE_NAME}/manifest.json`), 'utf8'))
@ -174,6 +184,48 @@ const PROBE = (tag) => `(() => {
return { own, sized, host: !!host, busy, maps }
})()`.replace('__TAG__', tag)
// ── PAINT: does each map put pixels on the screen, and does it fill its plugin? ───────────────
// COLOURS are read off the map's OWN WebGL canvas inside its 'render' event (the drawing buffer is
// still intact there), so a legend or panel floating over the map cannot inflate the count, and
// no per-plugin clip rectangle is needed. A basemap with land, water, borders and data has hundreds
// of colours; a blank map has one. FILL is the map's height over the PLUGIN ELEMENT's height, not
// canvas over container: in gospel's incident a stylesheet collapsed the container and the canvas
// followed it, so canvas/container stayed 1.0 over a 300px strip in a 900px box.
// POISON (self-test only): 'blank' zeroes every layer's opacity (the map is white and every
// feature still counts as drawn); 'collapse' squeezes the container to 300px (the real incident).
const PAINT = (tag, poison) => `(async () => {
const acc = []; const w = (r) => { for (const el of r.querySelectorAll('*')) { acc.push(el); if (el.shadowRoot) w(el.shadowRoot) } }; w(document)
const host = acc.find(e => e.tagName === ${JSON.stringify('__TAG__')}.toUpperCase())
const hb = host ? host.getBoundingClientRect() : null
const maps = (window.__shelfMaps || []).filter(m => { try { return m.getContainer() && m.getContainer().isConnected } catch (_) { return false } })
const poison = ${JSON.stringify('__POISON__')}
const OPACITY = { background: ['background-opacity'], fill: ['fill-opacity'], line: ['line-opacity'], circle: ['circle-opacity', 'circle-stroke-opacity'], symbol: ['text-opacity', 'icon-opacity'], raster: ['raster-opacity'], 'fill-extrusion': ['fill-extrusion-opacity'], heatmap: ['heatmap-opacity'] }
for (const m of maps) {
if (poison === 'blank') for (const l of (m.getStyle().layers || [])) for (const k of (OPACITY[l.type] || [])) { try { m.setPaintProperty(l.id, k, 0) } catch (_) {} }
if (poison === 'collapse') { const c = m.getContainer(); c.style.position = 'relative'; c.style.inset = 'auto'; c.style.height = '300px'; c.style.flex = 'none'; m.resize() }
}
if (poison) await new Promise(r => setTimeout(r, 1200))
const out = []
for (const m of maps) {
const cb = m.getContainer().getBoundingClientRect()
const colours = await new Promise(res => {
const t = setTimeout(() => res(-1), 8000)
m.once('render', () => { clearTimeout(t); try {
const cv = m.getCanvas(), W = Math.min(480, cv.width), H = Math.max(1, Math.round(cv.height * W / cv.width))
const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); x.drawImage(cv, 0, 0, W, H)
const d = x.getImageData(0, 0, W, H).data, seen = new Set()
for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 11) | ((d[i + 1] >> 3) << 6) | ((d[i + 2] >> 3) << 1) | (d[i + 3] > 127 ? 1 : 0))
res(seen.size)
} catch (_) { res(-2) } })
m.triggerRepaint()
})
let drawn = 0
try { const st = m.getStyle(), srcs = st.sources || {}, geo = Object.keys(srcs).filter(k => srcs[k].type === 'geojson'); const layers = (st.layers || []).filter(l => geo.includes(l.source)).map(l => l.id); if (layers.length) drawn = m.queryRenderedFeatures({ layers }).length } catch (_) {}
out.push({ colours, fill: hb && hb.height ? cb.height / hb.height : 0, drawn })
}
return out
})()`.replace('__TAG__', tag).replace('__POISON__', poison || '')
const b = await chromium.launch({ headless: true, args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] })
async function measure(url, s, poisonBundle, opts = {}) {
@ -193,16 +245,21 @@ async function measure(url, s, poisonBundle, opts = {}) {
const host0 = opts.frame ? await p.evaluate(() => ({ title: document.title, href: location.href, y: scrollY })) : null
// B: the plugin lives in the iframe whose document comes from the tree
const ctx = () => (opts.frame ? p.frames().find(f => f.url().startsWith(BASE)) : p.mainFrame()) || p.mainFrame()
let st = null
let st = null, prevDrawn = ''
const t0 = Date.now()
// provenance needs one tile request, not a drawn map: a fake token cannot draw anything
while (Date.now() - t0 < (opts.provenanceOnly ? Math.min(WAIT_MS, 25000) : WAIT_MS)) {
await p.waitForTimeout(2500)
try { st = await ctx().evaluate(PROBE(s.tag)) } catch (_) { st = null; continue }
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0)
// SETTLED, not first-non-zero: the drawn counts must hold across two polls, or a count read
// mid-fit (1752 of 2107 while tiles were still arriving) passes as the answer
const drawnNow = st.maps.map(m => m.drawn).join(','), steady = drawnNow === prevDrawn; prevDrawn = drawnNow
const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0) && steady
if (opts.provenanceOnly ? tokens.has(FAKE_TK) : (st.own > 8 && st.sized && st.busy === 0 && mapsReady && Date.now() - t0 > 6000)) break
}
const host1 = opts.frame ? await p.evaluate(() => ({ title: document.title, href: location.href, y: scrollY })) : null
let paint = []
if (!opts.provenanceOnly && st && st.maps.length) { try { paint = await ctx().evaluate(PAINT(s.tag, opts.poisonPaint)) } catch (e) { paint = [{ colours: -3, fill: 0, drawn: 0 }] } }
await p.close()
if (opts.provenanceOnly) {
// C provenance: with a FAKE host token the tiles cannot load, so only one thing is asserted:
@ -215,11 +272,16 @@ async function measure(url, s, poisonBundle, opts = {}) {
else if (st.own <= 8 || !st.sized) why.push(`<${s.tag}> mounted empty (${st.own} node(s)${st.sized ? '' : ', not sized'})`)
if (st && st.busy) why.push(`${st.busy} loading label(s) left`)
if (st) st.maps.forEach((m, i) => { if (!m.loaded) why.push(`map ${i + 1}: 'load' never fired`); else if (!m.drawn) why.push(`map ${i + 1}: no own data drawn (${m.geo} geojson source(s))`) })
paint.forEach((q, i) => {
if (q.colours < 0) why.push(`map ${i + 1}: could not read its pixels (${q.colours})`)
else if (q.colours < MIN_COLOURS) why.push(`map ${i + 1}: only ${q.colours} colour(s) on its canvas, a blank map (floor ${MIN_COLOURS})${q.drawn ? `, while ${q.drawn} features still count as drawn` : ''}`)
if (q.fill < MIN_FILL) why.push(`map ${i + 1}: fills ${Math.round(q.fill * 100)}% of <${s.tag}>'s height, collapsed (floor ${Math.round(MIN_FILL * 100)}%)`)
})
if (host0 && host1 && (host0.title !== host1.title || host0.href !== host1.href || host0.y !== host1.y)) why.push(`the host page changed (title/URL/scroll): ${JSON.stringify(host0)} -> ${JSON.stringify(host1)}`)
if (opts.noMapbox && mapboxReqs.length) why.push(`${mapboxReqs.length} api.mapbox.com request(s) under maplibre, e.g. ${mapboxReqs[0]}`)
if (errs.length) why.push(`page error: ${errs[0]}`)
if (fails.length) why.push(`failed request: ${fails[0]}`)
return { pass: !why.length, why, maps: st ? st.maps.length : 0, mapboxMaps: st ? st.maps.filter(m => m.mapbox).length : 0 }
return { pass: !why.length, why, maps: st ? st.maps.length : 0, paint, drawn: st ? st.maps.map(m => m.drawn) : [], mapboxMaps: st ? st.maps.filter(m => m.mapbox).length : 0 }
}
const leaf = (s, eng) => `${BASE}/${s.bundle}/${s.profile}/index.html?provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '')
@ -233,6 +295,14 @@ try {
const c = await measure(`${HOST}/C/${s.bundle}/maplibre`, s, s.bundle, { noMapbox: true })
if (a.pass || d.pass || c.pass) { console.error(`SELF-TEST FAILED: a deliberately broken ${s.bundle} still passed (${a.pass ? 'leaf ' : ''}${d.pass ? 'gallery ' : ''}${c.pass ? 'script-embed' : ''}). This gate cannot see a broken bundle.`); exit = 3 }
else console.log(`self-test: a deliberately broken ${s.bundle} fails as it must (leaf: ${a.why[0]}; script-embed: ${c.why[0]}; gallery: ${d.why[0]})`)
// each PAINT arm gets its own poison and must be the arm that catches it: a collapse caught
// only by the colour floor (or a blank map caught only by fill) proves nothing about the other
const sm = shelf.find(x => x.isMap)
if (sm) for (const [poison, arm] of [['blank', /colour\(s\) on its canvas/], ['collapse', /of <.*>'s height, collapsed/]]) {
const r = await measure(leaf(sm, 'maplibre'), sm, null, { poisonPaint: poison })
if (!r.why.some(w => arm.test(w))) { console.error(`SELF-TEST FAILED: a ${poison} ${sm.bundle} map was not caught by its own arm (${r.pass ? 'it passed' : r.why.join('; ')}). This gate cannot see a ${poison} map.`); exit = 3 }
else console.log(`self-test: a ${poison} ${sm.bundle} map fails as it must (${r.why.find(w => arm.test(w))})`)
}
}
if (exit !== 3) {
for (const [k, v] of Object.entries(SEED)) if (shelf.some(s => s.bundle === k)) console.log(`SEEDED ${k}: ${v.why}`)
@ -242,7 +312,7 @@ try {
// That is a fleet-wide leaf property, reported separately; C is where a partner chooses.
const row = (r, s, eng, surf, t0) => {
if (!r.pass) exit = 1
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${(r.maps ? r.maps + ' map(s)' : 'no map').padEnd(9)} ${String(Math.round((Date.now() - t0) / 1000)).padStart(3)}s${r.pass ? '' : ' — ' + r.why.join('; ')}`)
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${(r.maps ? r.maps + ' map(s)' : 'no map').padEnd(9)} ${(r.paint && r.paint.length ? r.paint.map(q => `${q.colours}c ${Math.round(q.fill * 100)}%`).join(' ') : '').padEnd(12)} ${String(Math.round((Date.now() - t0) / 1000)).padStart(3)}s${r.pass ? '' : ' — ' + r.why.join('; ')}`)
}
for (const s of shelf) for (const eng of ENGINES) for (const [surf, url, opts] of [
['A leaf', leaf(s, eng), {}],
@ -261,7 +331,7 @@ try {
row(await measure(`${HOST}/C/${s.bundle}/mapbox/fake`, s, null, { provenanceOnly: true }), s, eng, 'C host-tk', t0)
}
}
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin works on its own page, iframed and script-embedded on another site, and in the Gallery, under ${ENGINES.join(' and ')}`)
console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin works ${SURFACES.map(x => ({ A: 'on its own page', B: 'iframed on another site', C: 'script-embedded on another site', D: 'in the Gallery' })[x]).filter(Boolean).join(', ')}, and every map paints, under ${ENGINES.join(' and ')}`)
}
} finally { await b.close(); reap() }
process.exit(exit)