Only the browser seat runs browsers, and only inside internal/browser-slot.sh. That rule was remembered, not enforced: an agent ran a gate it took for a headless source check and it launched Chromium outside the slot. internal/lib/playwright-guarded.mjs is now the one way a repo file reaches playwright: its launch(), launchPersistentContext(), launchServer(), connect() and connectOverCDP() exit 75 (NOT MEASURED) with a message naming browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it for the command it runs, so a bash -c grandchild inherits it. All 72 files that imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates; plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2, research-map-plus-grid 2, research-dashboard 1): a mechanical import swap. check:browser-gates-guarded (gates:pre) fails a direct import; bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate moves to internal/lib/browser-gates.mjs so the runner and the check share it.
123 lines
7.2 KiB
JavaScript
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 './lib/playwright-guarded.mjs'
|
|
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)
|