check:studio-all: open every plugin in the studio, not a sample
Some checks are pending
build-gate / build (push) Waiting to run

smoke:studio samples 4 tabs. On 2026-09-25 a sweep of all of them found
people-groups-grid rendering 2 DOM nodes behind a page error, and 13
plugins mounting with their data 404ing on a fresh clone. The sample
had passed.

  bun run check:studio-all -- --url <studio page>

Opens every plugin row in the studio's tree and fails a plugin on the
studio's error overlay, a page error or failed request while it is
active (Mapbox hosts excluded), or a near-empty mount. Graded by exit
code. --url is required, because hardcoded :5173 gates have measured
another worktree's tree. The package.json entry carries no inline
comment: a `# …` in a script string swallows the arguments bun passes.

Negative-tested: re-introducing the unguarded useI18n() in
FilterDropdown fails it, naming people-groups-grid, research-map-plus-grid
and stack-demo. Against today's dev server: PASS, 31 of 31, in 3 of 4
runs; the fourth caught an intermittent gospel-dashboard page error,
reported to its owner.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 04:30:04 +00:00
parent e72ac1b4a2
commit e6f328b40b
2 changed files with 108 additions and 0 deletions

View file

@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* check-studio-every-plugin — open EVERY plugin in the plugin studio and fail on any that is broken.
*
* USAGE: bun run check:studio-all -- --url http://localhost:5299/ (dev server)
* bun run check:studio-all -- --url http://localhost:5478/index.html (built tree, serve-strict)
* Graded by EXIT CODE: 0 = every plugin mounted with content, 1 = at least one failed.
*
* ★ WHY THIS EXISTS WHEN smoke:studio ALREADY DOES. smoke:studio samples 4 tabs by index. On
* 2026-09-25 a first-run sweep of all 35 found people-groups-grid rendering 2 DOM nodes and a page
* error — standalone, in the studio and inside two composites — and 13 plugins mounting with
* their data 404ing on a fresh clone. The sample had passed. A plugin the gift ships must be
* checked, not sampled; this takes ~2 minutes.
*
* WHAT FAILS A PLUGIN (each measured on a real defect, none guessed):
* - the studio's own error overlay ("Could not load …") — the element never registered
* - a page error while that plugin was active (the i18n crash above)
* - a failed request (4xx/5xx) while it was active — a data file that is missing is a plugin
* that looks empty. Mapbox tile/event hosts are excluded: a URL-restricted token 403s there
* and that is a deployment setting, not a plugin defect.
* - a mount with almost nothing in it (< 8 elements and < 50 characters): the blank grid was 2.
*
* --url is REQUIRED. Several gates here hardcode :5173, and a drifted dev server made them measure
* another worktree's tree (see AGENT-BRIEF.md) — so this one refuses to guess.
*/
import { chromium } from 'playwright'
const i = process.argv.indexOf('--url')
const URL_ = i > -1 ? process.argv[i + 1] : ''
if (!URL_) {
console.error('check-studio-every-plugin: pass --url <studio page>, e.g. --url http://localhost:5299/')
process.exit(2)
}
const PER_PLUGIN_MS = Number(process.env.STUDIO_ALL_WAIT_MS || 16000)
const IGNORE_HOSTS = /(^|\.)(api\.mapbox\.com|events\.mapbox\.com|tiles\.mapbox\.com)$/
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
const page = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage()
let current = '(page load)'
const seen = {}
const note = (kind, v) => { const s = (seen[current] = seen[current] || { errors: [], failed: [] }); s[kind].push(v) }
page.on('pageerror', (e) => note('errors', String(e.message || e).slice(0, 160)))
page.on('response', (r) => {
if (r.status() < 400) return
let host = ''
try { host = new URL(r.url()).hostname } catch (_) { /* keep '' */ }
if (IGNORE_HOSTS.test(host)) return
note('failed', r.status() + ' ' + r.url().replace(/^https?:\/\/[^/]+/, '').slice(0, 100))
})
await page.goto(URL_, { waitUntil: 'networkidle' })
try {
await page.waitForFunction(() => {
const s = document.querySelector('plugin-studio')
return !!(s && s.shadowRoot && s.shadowRoot.querySelector('.st-row[aria-level="2"]'))
}, null, { timeout: 30000 })
} catch (_) {
console.error('STUDIO-ALL: FAIL — no <plugin-studio> with a plugin tree at ' + URL_)
await browser.close(); process.exit(1)
}
// Every PLUGIN row (aria-level 2 = a plugin in its capability section; deeper rows are parts that
// open one of these same plugins, and level 1 is a section header).
const rows = await page.evaluate(() => [...document.querySelector('plugin-studio').shadowRoot
.querySelectorAll('.st-row[aria-level="2"]')].map((r) => r.querySelector('.st-label').textContent.trim() + ' <' + r.querySelector('.st-desc').textContent.trim() + '>'))
const results = []
for (let n = 0; n < rows.length; n++) {
current = rows[n]
await page.evaluate((n) => document.querySelector('plugin-studio').shadowRoot.querySelectorAll('.st-row[aria-level="2"]')[n].click(), n)
let r = null
const deadline = Date.now() + PER_PLUGIN_MS
// Poll until the pane has real content, errors, or time runs out — a fixed sleep would either be
// too slow for the whole run or too short for a map.
while (Date.now() < deadline) {
await page.waitForTimeout(400)
r = await page.evaluate(() => {
const s = document.querySelector('plugin-studio').shadowRoot
const pane = [...s.querySelectorAll('.ps-pane')].find((x) => !x.hidden)
const hint = pane && pane.querySelector('.ps-hint')
const host = pane && pane.querySelector('.ps-slot') && pane.querySelector('.ps-slot').children[0]
let nodes = 0
const walk = (root) => { for (const e of root.querySelectorAll('*')) { nodes++; if (e.shadowRoot) walk(e.shadowRoot) } }
if (host) walk(host.shadowRoot || host)
const text = host ? ((host.shadowRoot || host).textContent || '').replace(/\s+/g, ' ').trim().length : 0
return { hint: hint ? hint.textContent.replace(/\s+/g, ' ').trim().slice(0, 140) : '', nodes, text }
})
if (r.hint.startsWith('Could not') || r.nodes > 40) break
}
const s = seen[current] || { errors: [], failed: [] }
const why = []
if (r.hint && !r.hint.startsWith('Loading')) why.push(r.hint)
if (r.nodes < 8 && r.text < 50) why.push('mounted almost nothing (' + r.nodes + ' elements)')
if (s.errors.length) why.push('page error: ' + [...new Set(s.errors)].join(' / '))
if (s.failed.length) why.push('failed requests: ' + [...new Set(s.failed)].slice(0, 3).join(' , '))
results.push({ row: current, nodes: r.nodes, why })
}
await browser.close()
const bad = results.filter((x) => x.why.length)
for (const x of results) console.log((x.why.length ? 'FAIL ' : 'ok ') + x.row.padEnd(60) + (x.why.length ? ' ' + x.why.join(' | ') : ' ' + x.nodes + ' elements'))
const loadErr = seen['(page load)']
if (loadErr && (loadErr.errors.length || loadErr.failed.length)) console.log('page load: ' + [...loadErr.errors, ...loadErr.failed].slice(0, 4).join(' | '))
console.log(bad.length
? 'STUDIO-ALL: FAIL — ' + bad.length + ' of ' + results.length + ' plugins broken at ' + URL_
: 'STUDIO-ALL: PASS — all ' + results.length + ' plugins mounted with content, no page errors, no failed requests, at ' + URL_)
process.exit(bad.length ? 1 : 0)

View file

@ -124,6 +124,7 @@
"smoke:aria-labels": "sh -c 'B=${BASE_URL:-http://127.0.0.1:4173}; exec node internal/check-aria-labels-localized.mjs \"$B/doxa-maps/my-upg-100-list/upg-100-list/index.html\" es,ar,zh'",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs # every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
"check:url-resolver": "node internal/check-url-resolver.mjs # cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",
"check:studio-all": "node internal/check-studio-every-plugin.mjs",
"smoke:studio": "node internal/smoke-studio.mjs # 5s DEV-surface check: / mounts the studio, tabs>0, 0 4xx (exit 2 = dev server down)",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs # every DISCOVERED (non-gated) bundle must be in the built manifest",
"check:csv-shape": "node internal/check-csv-export-shape.mjs # the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",