#!/usr/bin/env node /** * check-research-gallery-embed.mjs — the cap-5 Research Gallery () must embed on * SOMEONE ELSE'S page, in both embed modes, with every nested map painting. * * WHAT THIS PROTECTS. The gallery is going onto the DX-life research page. Every other gate loads * the gallery on its OWN generated leaf page, which bakes the three things a nested map needs — * window.MAP_APP_API_URL, window.MAP_TOKEN and a correct assets path — and pins the tag * position:fixed. A foreign page supplies none of that, so those gates cannot see the embed failing. * Measured 2026-09-24 before the fix, from a second origin: the gallery drew OVER the host's header * (the element had no :host box, so the layout's position:absolute root anchored to the viewport), * and 8 of 13 tabs showed an error card because every data call hit the host's origin. * * HOW. Serves the built tree with internal/serve-strict.mjs (production parity: strict directories, * ACAO:*) on 127.0.0.1, and a stand-in host page on `localhost` — a DIFFERENT ORIGIN, so CORS is * genuinely exercised. The host page has its own header and footer around the embed. Then, per mode: * js — `

after

` const embed = mode === 'iframe' ? `` : `` return `host
HOST HEADER
${embed}
` } const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT), '--exit-with-parent'], { cwd: ROOT, stdio: 'ignore', env: { ...process.env, LIVE_CORS: LIVE_CORS ? '1' : '' } }) const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(hostPage()) }).listen(HOST_PORT, 'localhost') const cleanup = () => { try { tree.kill() } catch {} try { host.close() } catch {} } await new Promise(r => setTimeout(r, 800)) // ★ SERVER IDENTITY BY CONTENT, before a single measurement. A readiness check that only waits for // HTTP 200 can adopt a STALE server: if the spawn above died (EADDRINUSE; its output is ignored) and // something else answers on the port, every result would describe someone else's tree. So the served // manifest must be byte-identical to THIS checkout's. A mismatch is NOT MEASURED (exit 75), never a // pass or a fail — the same code the shared browser locks use for "busy, re-run later". { const { readFileSync } = await import('node:fs') const disk = readFileSync(resolve(ROOT, `public/${TREE_NAME}/manifest.json`), 'utf8') let served = '' for (let i = 0; i < 20 && !served; i++) { try { const r = await fetch(`http://127.0.0.1:${TREE_PORT}/${TREE_NAME}/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ } if (!served) await new Promise(r => setTimeout(r, 300)) } if (served !== disk) { console.log(`NOT MEASURED: the server on :${TREE_PORT} is not serving this checkout's tree (manifest ${served ? 'differs' : 'unreachable'}). Re-run.`) cleanup(); process.exit(75) } } const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] }) const fails = [] const checked = {} // mode -> tabs actually driven; printed, and 0 is a FAIL (see the summary) try { for (mode of MODES) { const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) const own = new Set(), foreign = new Set(), mapbox = new Set() let current = '(page load)' const mapboxBy = {} // tab id (the one open when the request fired) -> distinct mapbox URLs page.on('request', q => { if (!/^https:\/\/api\.mapbox\.com\//.test(q.url())) return const u = q.url().split('?')[0] mapbox.add(u); (mapboxBy[current] = mapboxBy[current] || new Set()).add(u) }) const note = (url, why) => (url.startsWith(`http://127.0.0.1:${TREE_PORT}/`) ? own : foreign).add(why + ' ' + url.split('?')[0]) page.on('response', r => { if (r.status() >= 400) note(r.url(), String(r.status())) }) page.on('requestfailed', q => { const e = (q.failure() && q.failure().errorText) || ''; if (!/ABORTED/.test(e)) note(q.url(), e) }) await page.goto(`http://localhost:${HOST_PORT}/`) const frame = mode === 'iframe' ? await (await page.waitForSelector('iframe')).contentFrame() : page.mainFrame() // A gallery that never boots is a named FAIL for this mode, never an uncaught crash: a crash // skips every later mode and the summary, so it reads as "the gate broke" rather than a finding. const booted = await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false) if (!booted) { console.log(`FAIL [${mode}] the gallery did not boot within 60s`); fails.push(`[${mode}] the gallery did not boot within 60s`); await page.close(); continue } if (mode === 'js') { const g = await page.evaluate(() => { const s = document.querySelector('doxa-maps-spa'), v = s.shadowRoot.querySelector('.vtl') const a = s.getBoundingClientRect(), b = v.getBoundingClientRect() return { host: [a.top, a.left, a.width, a.height].map(Math.round), vtl: [b.top, b.left, b.width, b.height].map(Math.round) } }) const inside = g.vtl[0] >= g.host[0] - 1 && g.vtl[1] >= g.host[1] - 1 && g.vtl[2] <= g.host[2] + 1 && g.vtl[3] <= g.host[3] + 1 && g.host[3] >= 400 console.log(`${inside ? 'PASS' : 'FAIL'} [js] layout inside the host box — host ${g.host} layout ${g.vtl}`) if (!inside) fails.push(`[js] layout escaped the box (host ${g.host}, layout ${g.vtl})`) } const tabs = await frame.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => ({ id: t.id, discovered: !!t.discovered }))) for (const { id, discovered } of tabs) { if (ONLY && !ONLY.includes(id)) continue current = id await frame.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id) let v for (let k = 0; k < 50; k++) { await page.waitForTimeout(400) v = await frame.evaluate(id => { const vm = document.querySelector('doxa-maps-spa')._instance.proxy const i = vm.tabs.findIndex(x => x.id === id), t = vm.tabs[i] const el = vm._slots && vm._slots[i] && vm._slots[i].firstElementChild let canvas = 0 const walk = r => { for (const n of r.querySelectorAll('*')) { if (n.tagName === 'CANVAS') canvas++; if (n.shadowRoot) walk(n.shadowRoot) } } if (el) { walk(el); if (el.shadowRoot) walk(el.shadowRoot) } return { state: t.state, error: t.error, canvas } }, id) if (v.state === 'error' || (v.state === 'ready' && (v.canvas || discovered && k > 5))) break } const needCanvas = !discovered const ok = v.state === 'ready' && (!needCanvas || v.canvas > 0) const why = v.state !== 'ready' ? `state ${v.state}${v.error ? ' — ' + v.error : ''}` : (needCanvas && !v.canvas ? 'mounted but painted no map canvas' : '') checked[mode] = (checked[mode] || 0) + 1 console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] ${id}${why ? ' — ' + why : ''}`) if (!ok) fails.push(`[${mode}] ${id}: ${why}`) } for (const f of own) { console.log(`FAIL [${mode}] tree request failed: ${f}`); fails.push(`[${mode}] ${f}`) } if (PROVIDER === 'maplibre') { for (const u of [...mapbox]) if (MAPBOX_KNOWN[u]) { console.log(`KNOWN [${mode}] ${u} — owner: ${MAPBOX_KNOWN[u]}`); mapbox.delete(u); for (const k in mapboxBy) { mapboxBy[k].delete(u); if (!mapboxBy[k].size) delete mapboxBy[k] } } const ok = mapbox.size === 0 console.log(`${ok ? 'PASS' : 'FAIL'} [${mode}] provider=maplibre made ${mapbox.size} request(s) to api.mapbox.com`) if (!ok) { for (const [tab, urls] of Object.entries(mapboxBy)) { console.log(` while ${tab} was open: ${urls.size} — ${[...urls].slice(0, 3).join(' , ')}`) fails.push(`[${mode}] provider=maplibre: ${tab} hit api.mapbox.com (${urls.size} distinct URL(s))`) } } } for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`) await page.close() } // ── GOOD-NEIGHBOUR PHASE (script embed only: an iframe has its own document, title and URL) ── // A guest on a partner page must leave the page as it found it. Four rules, each measured after the // gallery loads and again after every tab opens, at PHONE width (where Simple Research is the // default tab, and where a scroll jump on load was measured, 2026-09-25): // 1. the host page does not SCROLL (scrollIntoView on a nested element scrolls every ancestor, // including the partner's page — a visitor is yanked down to the embed with no action); // 2. the host URL (path, query, #anchor) is unchanged; 3. document.title is unchanged; // 4. the host's inherited typography does not reach the gallery root. if (!argOf('--mode', '') && !ONLY) { mode = 'neighbour' const page = await browser.newPage({ viewport: { width: 390, height: 800 } }) const START = `http://localhost:${HOST_PORT}/?page=research#top` await page.goto(START) const booted = await page.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false) if (!booted) { fails.push('[neighbour] the gallery did not boot within 60s'); console.log('FAIL [neighbour] the gallery did not boot within 60s') } await page.waitForTimeout(5000) const look = () => page.evaluate(() => ({ y: Math.round(scrollY), href: location.href, title: document.title, active: document.querySelector('doxa-maps-spa')._instance.proxy.activeKey })) const typo = await page.evaluate(() => { const c = getComputedStyle(document.querySelector('doxa-maps-spa').shadowRoot.querySelector('.vtl')); return { size: c.fontSize, ls: c.letterSpacing, lh: c.lineHeight } }) const typoOk = typo.size === '16px' && typo.ls === 'normal' && typo.lh === 'normal' console.log(`${typoOk ? 'PASS' : 'FAIL'} [neighbour] host typography stops at the box — gallery root ${JSON.stringify(typo)}`) if (!typoOk) fails.push(`[neighbour] host typography leaked into the gallery ${JSON.stringify(typo)}`) const judge = (at, v) => { const bad = [] if (v.y !== 0) bad.push(['scroll', `host page scrolled to y=${v.y}`]) if (v.href !== START) bad.push(['url', `host URL became ${v.href.replace(/^https?:\/\/[^/]+/, '')}`]) if (v.title !== NEIGHBOUR_TITLE) bad.push(['title', `host title became "${v.title}"`]) for (const [kind, why] of bad) { const known = NEIGHBOUR_KNOWN[`${at}:${kind}`] if (known) console.log(`KNOWN [neighbour] ${at}: ${why} — owner: ${known}`) else { console.log(`FAIL [neighbour] ${at}: ${why}`); fails.push(`[neighbour] ${at}: ${why}`) } } if (!bad.length) console.log(`PASS [neighbour] ${at} leaves the host page alone`) return bad.length } const first = await look() judge(first.active, first) const ids = await page.evaluate(() => document.querySelector('doxa-maps-spa')._instance.proxy.tabs.map(t => t.id)) for (const id of ids) { // Reset the host between tabs, so each tab is judged on what IT did, not on a predecessor's. await page.evaluate(({ u, t }) => { scrollTo(0, 0); history.replaceState(null, '', u); document.title = t }, { u: START, t: NEIGHBOUR_TITLE }) await page.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id) await page.waitForTimeout(4000) judge(id, await look()) } checked.neighbour = ids.length await page.close() // ── LINKED PHASE — matrix cell D for the composites: "a filter in one part updates the other", // driven INSIDE a gallery tab (the composites' own leaf pages are covered by verify:interop; this // proves the link survives being nested one level deeper, in the gallery's document). Each check // clicks the composite's REAL control and polls an observable in the OTHER part. Playwright CSS // pierces open shadow roots, so one locator reaches through the gallery and the composite. // Mechanics (found 2026-09-25, file:line in each owner's plugin): // Big Picture dashboard facet → `dashboard-filter` emit → grid props; the grid's count // line (.repel) is ABSENT until a filter applies, then reads "Showing N". // Simple Research the two halves are INDEPENDENT BY DESIGN (research-map-plus-grid.vue:49-56), // so D asserts each half narrows on its own control — the grid's search. // Regions list `region-selected` → window `doxa-regions-dashboard:select` → the // dashboard's h1.rd-title becomes the region. // UPG-100 "Show on map" must bring the map; selecting the ZONE on it is a routed gap. mode = 'js' const lp = await browser.newPage({ viewport: { width: 1400, height: 900 } }) await lp.goto(`http://localhost:${HOST_PORT}/`) const lbooted = await lp.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 60000 }).then(() => true, () => false) const pane = () => lp.locator('doxa-maps-spa section.dms-pane:not([hidden])') const open = async (id) => { await lp.evaluate(id => document.querySelector('doxa-maps-spa')._instance.proxy.onSelect(id), id) await lp.waitForFunction(id => { const vm = document.querySelector('doxa-maps-spa')._instance.proxy; const t = vm.tabs.find(x => x.id === id); return t && t.state === 'ready' }, id, { timeout: 60000 }) await lp.waitForTimeout(6000) // let the composite fetch + paint its data before touching it } const poll = async (fn, ms = 25000) => { const end = Date.now() + ms; let v; while (Date.now() < end) { v = await fn().catch(() => null); if (v && v.ok) return v; await lp.waitForTimeout(700) } return v || { ok: false, why: 'no observation' } } const LINKS = { 'dashboard-plus-grid': async () => { const count = pane().locator('.repel').filter({ hasText: /Showing/ }) const before = await count.count() const facet = pane().locator('[data-facet="wagf_region"][data-value]:not(.dash-only)').first() const val = await facet.getAttribute('data-value', { timeout: 20000 }) // The SUBJECT carries its own expected answer: the facet row prints how many groups it holds // (e.g. "Asia 1,309"). Asserting the grid shows EXACTLY that — not merely "Showing " — // fails on "Showing 0" (a subject with no data, or a broken link) AND on an unfiltered grid. const rowNums = ((await facet.innerText()).match(/\d[\d,]*/g) || []).map(x => Number(x.replace(/,/g, ''))) const expect = rowNums.length ? rowNums[rowNums.length - 1] : 0 await facet.click() return poll(async () => { const n = await count.count(); const txt = n ? await count.first().innerText() : ''; const got = Number(((txt.match(/Showing\s+([\d,]+)/) || [])[1] || '0').replace(/,/g, '')); return { ok: before === 0 && expect > 0 && got === expect, why: `facet wagf_region=${val} (row says ${expect}) → grid count line ${n ? '"' + txt.trim().slice(0, 40) + '"' : 'still absent'}` } }) }, 'research-map-plus-grid': async () => { const total = pane().locator('.repel').first() const num = async () => Number(((await total.innerText({ timeout: 20000 })).match(/[\d,]+/) || ['0'])[0].replace(/,/g, '')) const before = await num() await pane().locator('.pgg-search input').first().fill('Kurd') return poll(async () => { const after = await num(); return { ok: before > 0 && after > 0 && after < before, why: `grid search "Kurd": ${before} → ${after}` } }) }, 'regions-dashboard': async () => { const btn = pane().locator('button.region-card__link').first() const name = (await btn.innerText({ timeout: 20000 })).trim() const title = pane().locator('h1.rd-title').first() const before = (await title.innerText({ timeout: 20000 }).catch(() => '')).trim() await btn.click() return poll(async () => { const t = (await title.innerText()).trim(); return { ok: t !== before && t.includes(name), why: `list "${name}" → dashboard title "${before}" → "${t}"` } }) }, 'upg-100': async () => { const tape = pane().locator('.card[data-list-id] button.tape.tape-h').first() await tape.click({ timeout: 20000 }) return poll(async () => { const on = pane().locator('.viewsel button[aria-pressed="true"]'); const txt = (await on.first().innerText()).trim(); return { ok: /map/i.test(txt) || /карт|mapa|carte|地图|خريطة|मानचित्र|karte|mappa|hartă/i.test(txt), why: `"Show on map" → active view "${txt}"` } }) } } if (!lbooted) fails.push('[linked] the gallery did not boot within 60s') for (const [id, run] of Object.entries(lbooted ? LINKS : {})) { let r try { await open(id); r = await run() } catch (e) { r = { ok: false, why: 'threw: ' + String(e.message || e).split('\n')[0].slice(0, 160) } } console.log(`${r.ok ? 'PASS' : 'FAIL'} [linked] ${id}: ${r.why}`) if (!r.ok) fails.push(`[linked] ${id}: ${r.why}`) } for (const [k, owner] of Object.entries(LINKED_KNOWN)) console.log(`KNOWN [linked] ${k} — owner: ${owner}`) checked.linked = Object.keys(LINKS).length await lp.close() } } finally { await browser.close(); cleanup() } // ★ A MODE THAT RAN NOTHING HAS NOT PASSED. A flag lost on the way in (package.json once ended // this entry in an inline # comment, so an appended --mode landed inside the comment) must not // read as "both modes green": each requested mode has to have driven at least one tab. for (const m of MODES) if (!checked[m]) fails.push(`[${m}] checked 0 tabs — this mode did not run`) const tally = [...MODES, ...(checked.neighbour ? ['neighbour'] : []), ...(checked.linked ? ['linked'] : [])].map(m => `${m} ${checked[m] || 0} tab(s)`).join(', ') if (fails.length) { console.log(`\nchecked: ${tally}`) console.log(`\ncheck-research-gallery-embed: FAIL (${fails.length})`); for (const f of fails) console.log(' - ' + f) process.exit(1) } console.log(`\ncheck-research-gallery-embed: PASS — the Research Gallery embeds on a foreign origin (${tally})`)