Merge main into bundler-2/work

This commit is contained in:
doxa-maps-88655c70 2026-09-25 20:00:13 +00:00
commit 2590fe5eb1
6 changed files with 79 additions and 1657 deletions

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)

View file

@ -199,7 +199,10 @@ const rootEntries = [...new Set(files.map(f => f.rel.split('/')[0]))].sort()
for (const e of rootEntries) {
if (CONTRACT.has(e)) continue
const label = files.some(f => f.rel.startsWith(`${e}/`)) ? `${e}/` : e
hit(STRICT ? findings : warnings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} only)`)
// ALWAYS a finding, not a strict-mode warning: public/ holding ONLY the output folder + README.md is a
// Driver decision (BUILD-OUTPUT-IS-ONE-FOLDER), not a style preference. MEASURED 2026-09-25: a rebuild
// under the OLD tree name re-added public/doxa-maps/ beside public/dx-maps/, and this gate only warned.
hit(findings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} only)`)
}
// inventory

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long