doxa-maps/internal/check-research-gallery-embed.mjs
doxa-maps-88655c70 3a2cf0e8f2 Research Gallery: embed on someone else's page, in both modes
The gallery is going onto the DX-life research page, and nothing had tried
it on a page it didn't generate. From a second origin, a bare
<script> + <doxa-maps-spa> drew over the host's header, and 8 of 13 tabs
showed an error card.

Three causes, each fixed in the shell:

- No :host box. The layout root is position:absolute, so it anchored to the
  viewport instead of the element. :host is now a positioned block with a
  default height the host can override.
- A foreign page sets none of the globals a leaf page bakes. Every data call
  hit the host's own origin and got HTML back. The manifest now publishes a
  `runtime` block (apiBaseUrl + the public token every leaf already carries).
  The shell fills window.MAP_APP_API_URL / window.MAP_TOKEN only when the page
  hasn't set them, and forwards tk in profile-config.
- Nested elements resolved assets to <tree>/../assets, a sibling pool the
  publisher doesn't ship (404 live). The shell now forwards an absolute
  <tree>/assets. This also fixes the iframe mode, where Cities with Internet
  and Gospel Resources were failing the same way.

Also adds a `tab` attribute (plus ?tab=) to deep-link into one map, and a
`tab-change` event for the host.

check:gallery-embed drives every tab in both modes from a second origin. It
passes here and fails on the previous tree (7 findings).

EMBED.md documents the contract. The Traefik template now sends CORS for the
whole tree: live manifest.json answers without Access-Control-Allow-Origin.
2026-09-24 19:03:37 +00:00

135 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
/**
* check-research-gallery-embed.mjs — the cap-5 Research Gallery (<doxa-maps-spa>) 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 — `<script src=…/doxa-maps-spa.js>` + a bare `<doxa-maps-spa>` tag, NO attributes: the
* zero-config embed the docs promise.
* iframe — an <iframe> at the gallery's leaf page.
* and for every tab in the rail: select it, wait for it to mount, then assert.
*
* ASSERTIONS (a FAIL names the tab and the reason):
* 1. js mode: the layout root sits INSIDE the element's box (not at the viewport origin).
* 2. every tab reaches state 'ready' (never 'error', never stuck 'idle'/'loading').
* 3. every CURATED tab paints a WebGL canvas — they are all maps or map composites.
* Experimental tabs need only mount: some are cards or legacy frames with no canvas.
* 4. zero failed requests to the TREE's own origin (a 404/CORS on our assets is our bug; third-
* party tile/glyph hiccups are reported, not failed — they are not this repo's to fix).
*
* PRECONDITION: a built tree in public/doxa-maps (this starts its own servers; no dev server).
* Usage: node internal/check-research-gallery-embed.mjs [--mode js|iframe] [--only id,id]
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no browser)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const argOf = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const MODES = argOf('--mode', '') ? [argOf('--mode', '')] : ['js', 'iframe']
const ONLY = argOf('--only', '') ? argOf('--only', '').split(',') : null
// ★ FREE PORTS, ASKED OF THE OS — never fixed ones. A stray server left on a fixed port by an
// earlier crashed run answered in place of the tree under test, and this gate PASSED a known-broken
// tree (caught by its own negative control, 2026-09-24): spawn failed with EADDRINUSE silently and
// the old server served the fixed build. A port the OS just handed out cannot be someone else's.
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const TREE_PORT = await freePort()
const HOST_PORT = await freePort()
const TREE = `http://127.0.0.1:${TREE_PORT}/doxa-maps`
if (!existsSync(resolve(ROOT, 'public/doxa-maps/doxa-maps-spa/doxa-maps-spa.js'))) {
console.log('SKIP check-research-gallery-embed: no built gallery at public/doxa-maps/doxa-maps-spa/')
process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch {
console.log('SKIP check-research-gallery-embed: playwright not installed'); process.exit(2)
}
let mode = 'js'
const hostPage = () => {
const embed = mode === 'iframe'
? `<iframe src="${TREE}/doxa-maps-spa/doxa-maps-spa/index.html" style="width:100%;height:760px;border:0"></iframe>`
: `<script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa></doxa-maps-spa>`
return `<!doctype html><html><head><meta charset=utf-8><title>host</title>
<style>body{margin:0} header,footer{height:60px;background:#eee}</style></head>
<body><header id=hdr>HOST HEADER</header><main style="max-width:1200px;margin:0 auto">${embed}</main><footer>HOST FOOTER</footer></body></html>`
}
const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TREE_PORT)], { cwd: ROOT, stdio: 'ignore' })
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))
const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
const fails = []
try {
for (mode of MODES) {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
const own = new Set(), foreign = new Set()
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()
await frame.waitForFunction(() => { const s = document.querySelector('doxa-maps-spa'); return s && s._instance && s._instance.proxy.tabs.some(t => t.resolved) }, null, { timeout: 30000 })
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 <doxa-maps-spa> 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
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' : '')
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}`) }
for (const f of foreign) console.log(`note [${mode}] third-party request failed (not gated): ${f}`)
await page.close()
}
} finally { await browser.close(); cleanup() }
if (fails.length) {
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 in ' + MODES.join(' + ') + ' mode')