Some checks are pending
build-gate / build (push) Waiting to run
Checkpointing thirteen files of agent work that were sitting uncommitted while three agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work is work at risk, and a tree being edited under an observer oscillates between states that look like regressions and are really just partial saves. The second capability-5 shell now mounts its tabs as real elements too, so no iframe remains in either shell. It was the last holdout: eight tabs, all iframe mode, each pointing at a hardcoded built-tree leaf that does not exist under the dev server. The facet rail keeps its shape when a filter narrows it. Selecting one place took the rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row a user was about to click vanished under the cursor, which is what "the buttons went dead" actually was. Rows whose count falls to zero are now re-admitted at their original slot and depth, dimmed and inert, which also restores the component's own promise to lose nothing against the tree it replaced. Expanding a branch and filtering by it are now separate, visible affordances: the whole row filters, and expand is a real button with its own hit area and label. Rows that were never clickable are unmistakably inert rather than silently ignored. Three more facet axes reach the grid — affinity bloc, engagement status and primary language — each verified to share a field with an existing prop rather than being mapped on name resemblance. Two derived facets remain unmapped on purpose: they have no matching row field, and mapping them would filter confidently to the wrong rows.
159 lines
11 KiB
JavaScript
159 lines
11 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 /__gallery) 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] [--page <gallery url>]
|
|
* [--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(/\/+$/, '')
|
|
// ── THE GALLERY PAGE IS NO LONGER AT `/` (2026-09-22) ──────────────────────────────────────────
|
|
// `/` now mounts <plugin-studio> in both trees; the gallery moved to the explicit /__gallery.
|
|
// This gate asserts the GALLERY's DOM contract (a #slot the chosen element is mounted into), which
|
|
// the studio does not have and is not supposed to have — it mounts panes into its own shadow root
|
|
// on tab activation. So the two URLs must be separable:
|
|
// --url the SERVER ROOT, where /__plugin-gallery/registry.json lives (discovery)
|
|
// --page the GALLERY PAGE the deep links are appended to (defaults to <url>/__gallery)
|
|
// They were ONE argument, which is why pointing --url at the gallery broke discovery (the registry
|
|
// 404'd at /__gallery/__plugin-gallery/registry.json) while leaving it at the root made every
|
|
// target fail against the studio's DOM — 29 confident ✗ lines describing a page that was working.
|
|
// A gate aimed at the wrong surface reports FAIL just as loudly as a real regression, and the
|
|
// output gives no hint which it is; that is the expensive part, not the red text.
|
|
const PAGE = String(argOf('--page', `${BASE}/__gallery`)).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: `${PAGE}#/${key.split('/').map(encodeURIComponent).join('/')}` })
|
|
}
|
|
console.log(`[plugin-gallery-smoke] ${targets.length} element(s) from ${registry.length} bundle(s) @ ${PAGE} (registry @ ${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) }
|