doxa-maps/internal/plugin-gallery-smoke.mjs
swift-river542 9cefd6faa4 Plugin gallery: one generator for the dev server and the build
- internal/playground → internal/plugin-gallery; playground-smoke → plugin-gallery-smoke;
  generate-spa → generate-gallery (scripts: gallery, smoke:gallery; smoke:playground stays as alias)
- generate-gallery exports renderGallery(model, { mode }) — the dev plugin serves the same page
  at / from the discovered registry (dev mode imports the dev entries, ladder popovers on);
  the build writes public/index.html from the manifest (byte-identical to before)
- Docs and probes use the gallery vocabulary
2026-09-16 20:04:18 -05:00

146 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
/**
* plugin-gallery-smoke.mjs — prove EVERY element in the auto-generated dev SPA
* (the plugin gallery served by `bun x vite` at /) actually works.
*
* WHY: the plugin gallery is the architecture demo and the pre-handoff gate — "all
* the maps work in the auto-generated SPA" must be a checked fact, not a hope.
* embed-smoke.mjs proves the BUILT pages boot; THIS proves each element boots
* inside the plugin gallery shell: element defined, shadow DOM populated, no
* ProfileLoader error card, and (for map profiles) a WebGL canvas present.
*
* HOW: reads the plugin gallery's own registry (GET /__plugin-gallery/registry.json —
* the same discovery the page renders), then opens each element by DEEP LINK
* (/#/<bundle>/<profile>[/<instance>]) in a FRESH page, one at a time (sequential by
* default so a laptop's memory / WebGL-context budget is never blown), and audits the mount.
* Browser: Firefox by default (house rule: never Google Chrome; Playwright's
* bundled Chromium is also allowed via --browser chromium when installed). If
* Playwright's pinned Firefox revision is missing, falls back to any cached one.
*
* Usage: node internal/plugin-gallery-smoke.mjs [--url http://localhost:5174]
* [--grep <substr>] [--browser firefox|chromium] [--parallel 1]
* [--wait 12000] [--json <out.json>]
* Exit: 0 all pass · 1 any fail (list printed)
* Every failure is a builder-cycle backlog candidate — the --json report carries
* the evidence (console errors, page errors, mount state, timing).
*/
import { writeFileSync } from 'node:fs'
import { launch, deepEvaluate, TELEMETRY } from './lib/browser.mjs'
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const BASE = String(argOf('--url', 'http://localhost:5174')).replace(/\/+$/, '')
const GREP = argOf('--grep', '')
const BROWSER = argOf('--browser', 'firefox')
const PARALLEL = Math.max(1, Number(argOf('--parallel', 1)))
const WAIT = Number(argOf('--wait', 12000))
const JSON_OUT = argOf('--json', '')
// browser launch (Firefox first; cached-Firefox fallback), TELEMETRY filter and the
// shadow-piercing deepFind live in internal/lib/browser.mjs — shared with share-roundtrip-smoke.
// ── targets: every element the plugin gallery lists ─────────────────────────
const regRes = await fetch(`${BASE}/__plugin-gallery/registry.json`).catch(() => null)
if (!regRes || !regRes.ok) { console.error(`[plugin-gallery-smoke] cannot read ${BASE}/__plugin-gallery/registry.json — is \`bun x vite\` running?`); process.exit(2) }
const registry = await regRes.json()
const targets = []
for (const b of registry) for (const it of b.items) {
// The gallery's deep-link key: <bundle>/<profile>, plus /<id> for a preset instance.
const key = `${b.bundle}/${it.profile}${it.type === 'parameterized' ? `/${it.id}` : ''}`
if (GREP && !key.includes(GREP) && !String(it.label).includes(GREP)) continue
// A WebGL canvas is expected for map profiles and for composites (their maps live inside
// NESTED custom elements — checked deep); lists/tables/charts/poster walls are DOM-only.
const expectCanvas = (/map/i.test(it.profile) && !/list|table|chart|wall|dashboard/i.test(it.profile))
|| it.type === 'nested-elements-mfe' || it.type === 'nested-mfe-spa'
targets.push({ bundle: b.bundle, tag: b.tag, id: it.id, key, label: it.label, type: it.type, profile: it.profile, expectCanvas, url: `${BASE}/#/${key.split('/').map(encodeURIComponent).join('/')}` })
}
console.log(`[plugin-gallery-smoke] ${targets.length} element(s) from ${registry.length} bundle(s) @ ${BASE}`)
const browser = await launch(BROWSER, { label: 'plugin-gallery-smoke' })
async function check(t) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const consoleErrors = [], pageErrors = [], dataProblems = []
page.on('console', m => { if (m.type() === 'error' && !TELEMETRY.test(m.text())) consoleErrors.push(m.text().slice(0, 240)) })
page.on('pageerror', e => pageErrors.push(String(e.message || e).slice(0, 240)))
// Data contract evidence: a .json/.geojson request answered with HTML (dev-server
// fallback for a path nobody serves) or 4xx/5xx is the classic silent map killer —
// the profile's JSON.parse throws before it ever creates the map.
page.on('response', res => {
const u = res.url()
if (!/\.(geo)?json(\?|$)/i.test(u) || TELEMETRY.test(u)) return
const ct = res.headers()['content-type'] || ''
if (res.status() >= 400) dataProblems.push(`${res.status()} ${u}`)
else if (/text\/html/i.test(ct)) dataProblems.push(`HTML instead of JSON ← ${u}`)
})
const t0 = Date.now()
const r = { ...t, pass: false, why: '', ms: 0, defined: false, shadowChildren: 0, errorCard: null, statusText: null, canvas: false, activeRow: null, consoleErrors, pageErrors, dataProblems }
try {
await page.goto(t.url, { waitUntil: 'load', timeout: 60000 })
// poll until the element mounts a canvas / an error card / or we run out of patience
const deadline = Date.now() + WAIT
let state = null
while (Date.now() < deadline) {
// Composite MFEs nest further custom elements (each with its own shadow root):
// deepFind (bound by deepEvaluate) searches every nested shadow root, not just the outer one.
state = await deepEvaluate(page, (deepFind, tag) => {
const el = document.querySelector('#slot > *')
const defined = !!customElements.get(tag)
const sr = el && el.shadowRoot
const children = sr ? sr.childElementCount : 0
const err = sr ? deepFind(sr, '.profile-error') : null
// The profile's OWN status line (e.g. "JSON.parse: unexpected character…") — the
// ProfileLoader card only covers boot failures; init failures land here.
const st = sr ? deepFind(sr, '[role="alert"], [role="status"], [class*="status"], [class*="error"], [class*="err"]') : null
const canvas = !!(sr && deepFind(sr, 'canvas'))
const row = document.querySelector('button.row[aria-current="true"]')
// Host contract: some profiles take the engine as a page global (window.mapboxgl).
const hostHasMapbox = typeof window.mapboxgl !== 'undefined'
return { mountedTag: el ? el.tagName.toLowerCase() : null, defined, children, errorCard: err ? err.textContent.trim().slice(0, 160) : null, statusText: st && st.textContent.trim() ? st.textContent.trim().slice(0, 160) : null, canvas, activeRow: row ? row.dataset.k : null, hostHasMapbox }
}, t.tag)
if (state.errorCard || (state.children > 0 && (state.canvas || !t.expectCanvas))) break
await page.waitForTimeout(400)
}
await page.waitForTimeout(1200) // let late errors surface
Object.assign(r, { defined: state?.defined, shadowChildren: state?.children ?? 0, errorCard: state?.errorCard, statusText: state?.statusText ?? null, canvas: state?.canvas, activeRow: state?.activeRow, mountedTag: state?.mountedTag })
r.ms = Date.now() - t0
const problems = []
if (r.activeRow !== t.key) problems.push(`deep link resolved to ${r.activeRow}`)
if (!r.mountedTag) problems.push('nothing mounted in #slot')
else if (r.mountedTag !== t.tag) problems.push(`mounted <${r.mountedTag}>, expected <${t.tag}>`)
if (!r.defined) problems.push(`custom element <${t.tag}> not defined`)
if (r.shadowChildren === 0) problems.push('shadow DOM empty')
if (r.errorCard) problems.push(`ProfileLoader error: ${r.errorCard}`)
if (t.expectCanvas && !r.canvas) {
let why = 'no WebGL canvas (map did not initialize)'
if (state?.hostHasMapbox === false) why += ' — window.mapboxgl is UNDEFINED on the host page (profile expects the Mapbox peer)'
if (r.statusText) why += ` — profile status: "${r.statusText}"`
if (dataProblems.length) why += ` — data: ${dataProblems[0]}${dataProblems.length > 1 ? ` (+${dataProblems.length - 1} more)` : ''}`
problems.push(why)
}
if (pageErrors.length) problems.push(`page error: ${pageErrors[0]}`)
r.pass = problems.length === 0
r.why = problems.join(' · ')
} catch (e) {
r.ms = Date.now() - t0; r.why = 'exception: ' + String(e.message || e).slice(0, 200)
} finally { await page.close() }
return r
}
// sequential by default (memory/WebGL budget); --parallel N to widen
const results = []
for (let i = 0; i < targets.length; i += PARALLEL) {
const slice = targets.slice(i, i + PARALLEL)
const rs = await Promise.all(slice.map(check))
for (const r of rs) {
results.push(r)
const warn = r.consoleErrors.length ? ` · ${r.consoleErrors.length} console error(s)` : ''
console.log(`${r.pass ? '✓' : '✗'} ${r.key} (${r.type}) canvas:${r.canvas ? 'y' : 'n'} ${r.ms}ms${warn}${r.pass ? '' : '\n ' + r.why}`)
}
}
await browser.close()
const failed = results.filter(r => !r.pass)
const noisy = results.filter(r => r.pass && r.consoleErrors.length)
console.log(`\n[plugin-gallery-smoke] ${results.length - failed.length}/${results.length} pass · ${failed.length} fail · ${noisy.length} pass-with-console-errors`)
if (JSON_OUT) { writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2)); console.log(`[plugin-gallery-smoke] report → ${JSON_OUT}`) }
if (failed.length) { console.log('FAILED:'); failed.forEach(f => console.log(` ✗ ${f.key} — ${f.why}`)); process.exit(1) }