doxa-maps/internal/check-studio-every-plugin.mjs
doxa-maps-88655c70 a9550858ed Browser gates: measure THIS build, or measure nothing
About thirty browser gates could pass while describing another worktree's build.
This machine runs several checkouts and a default port belongs to whichever
server started first — measured 2026-09-24, :4173 was another worktree's, and an
hour of probing reported confidently on code nobody here had touched. check:sri
cannot see it: a stale tree and its stale leaf agree perfectly.

Twenty gates now call requireThisTree() right after they resolve their target and
exit 75 on a mismatch — EX_TEMPFAIL, the code this repo already uses for "not
measured", which the wave runner reports as ⊘ rather than ✗. Exiting 1 would turn
a mis-pointed probe into a red product gate, which is the failure this whole
family of checks exists to prevent.

★ NARROWER THAN THE PROBLEM STATEMENT, ON PURPOSE. Three classes do NOT get it,
because there it would be wrong rather than missing:
  self-serving gates  start their own server on an OS-assigned port
  file:// gates       read this checkout directly — no server to be wrong about
  remote targets      verify-map-paint defaults to the LIVE site, and "does
                      production match what we built" is a deploy question asked
                      deliberately. MEASURED mid-rollout: it reported NOT
                      MEASURED against the live host, which was the guard being
                      wrong, not the gate. It now enforces on localhost only.

TWO THINGS THE ROLLOUT TAUGHT, both caught by running rather than reading:
  · node --check passes a file that will die on ReferenceError. Adding a ROOT
    constant to sixteen gates that do not import resolve/dirname/fileURLToPath
    parsed perfectly and crashed on first run — the same shape that broke
    check-locale-reachable twice. The imports are added under aliased names so
    they cannot collide with a gate's own.
  · Five gates take their URL from argv with no default and are left for a
    per-file pass rather than patched blind.

CONTROLS: against another worktree's server a guarded gate prints NOT MEASURED
naming the mismatch and exits 75; against this checkout's server it runs and
reports normally; against the live site it proceeds.
2026-09-25 08:20:11 +00:00

123 lines
7.2 KiB
JavaScript

#!/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 — so this one refuses to guess.
*/
import { chromium } from 'playwright'
import { requireThisTree } from './_serves-this-tree.mjs'
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
import { fileURLToPath as _fileURLToPath } from 'node:url'
const i = process.argv.indexOf('--url')
const URL_ = i > -1 ? process.argv[i + 1] : ''
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port
// belongs to whichever server started first — measured 2026-09-24 on :4173, where browser gates
// reported confidently about a build nobody here had touched. 75 (EX_TEMPFAIL), not 1: the wrong
// tree is a precondition, not a product defect, and the wave runner reports 75 as ⊘ rather than ✗.
// A file:// target passes straight through — there is no server there to be wrong about.
await requireThisTree(URL_, ROOT, 'STUDIO-EVERY-PLUGIN')
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) }
// Keep the top of the STACK, not just the message: a message like "reading 'getRootNode'" names no
// caller, and the owner cannot fix it without knowing which call site threw (asked 2026-09-25).
page.on('pageerror', (e) => {
const frames = String(e.stack || '').split('\n').slice(1, 5).map((l) => l.trim().replace(/https?:\/\/[^/]+/, '').replace(/\?v=[0-9a-f]+/, '')).join(' <- ')
note('errors', String(e.message || e).slice(0, 160) + (frames ? ' [' + frames + ']' : ''))
})
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)