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.
171 lines
8.9 KiB
JavaScript
171 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// check-aria-labels-localized — no control may announce a RAW I18N KEY, and the
|
|
// accessible names must change with the locale.
|
|
//
|
|
// ★ THE ONE STRING A SIGHTED TESTER NEVER SEES. Every other untranslated string in
|
|
// this tree got caught because somebody looked at the screen. aria-label is only ever
|
|
// spoken aloud, so it survived every visual QA pass. Measured 2026-09-24 on the served
|
|
// upg-100-list leaf: the legend's toggle button announced the literal string
|
|
// "aria.toggleLegend" — in English too, not just translated locales — because vue-i18n
|
|
// ECHOES THE KEY when a lookup misses and `toggleLegend` was absent from aria.json
|
|
// while its two siblings (backToLegend, closeDetailView) were present. Six call sites
|
|
// across LegendDesktop.vue and LegendMobile.vue, desktop AND mobile, every locale.
|
|
//
|
|
// A second instance in the same sweep had the opposite shape: SemanticTreeLegend's
|
|
// collapse button hardcoded 'Collapse legend' / 'Expand legend' with no t() at all, so
|
|
// it was correct English and untranslatable. One key missing FROM the catalog, one
|
|
// string never routed TO it — the gate below catches both, because it compares what is
|
|
// ANNOUNCED across locales rather than reading the source.
|
|
//
|
|
// ⚠ TWO ASSERTIONS, NOT ONE. "No raw keys" alone passes a hardcoded English label, and
|
|
// "labels differ across locales" alone passes a raw key (a key is identical in every
|
|
// locale — which is exactly why the SEPARABILITY floor below is per-locale).
|
|
//
|
|
// ⚠ A RAW KEY HAS A SHAPE NO HUMAN LABEL HAS: dotted, camelCase, no spaces. That is the
|
|
// whole detector, and it is locale-independent — it works on Arabic and Chinese pages
|
|
// where no English word list could.
|
|
//
|
|
// Usage: node internal/check-aria-labels-localized.mjs <leafUrl> [locales]
|
|
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'
|
|
import { TREE_NAME } from './_tree-name.mjs'
|
|
|
|
const URL_ = process.argv[2] || process.env.ARIA_URL
|
|
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, 'ARIA-LABELS-LOCALIZED')
|
|
if (!URL_) {
|
|
console.log('ARIA-LABELS-LOCALIZED: SKIP — no leaf url given.')
|
|
console.log(' Pass one, or set ARIA_URL. Serve the TREE ROOT (public/) and point at a leaf,')
|
|
console.log(` e.g. ${TREE_NAME}/my-upg-100-list/upg-100-list/index.html`)
|
|
process.exit(0)
|
|
}
|
|
const UNDER_TEST = (process.argv[3] || 'es,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
|
|
|
// A raw vue-i18n key: starts lowercase, dotted, no whitespace. Human labels have spaces
|
|
// ("Zoom in") or are non-Latin; neither matches.
|
|
const RAW_KEY = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/
|
|
|
|
// How many accessible names MUST change when the locale changes. Measured on the
|
|
// upg-100-list leaf 2026-09-24: 6 of 13, desktop and mobile alike. The rest are map-
|
|
// engine controls (Mapbox's own zoom/fullscreen buttons) whose labels this tree does
|
|
// not own. Override for a leaf with a different control set.
|
|
const LOCALIZED_FLOOR = Number(process.env.ARIA_LOCALIZED_FLOOR || 6)
|
|
|
|
async function readLabels(browser, lang, viewport, mobile) {
|
|
const page = await browser.newPage({ viewport, isMobile: mobile, hasTouch: mobile })
|
|
const errs = []
|
|
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
|
await page.goto(`${URL_}?lang=${lang}`, { waitUntil: 'load', timeout: 45000 })
|
|
await page.waitForTimeout(9000)
|
|
const labels = await page.evaluate(() => {
|
|
const walk = (root, out = []) => {
|
|
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
|
|
return out
|
|
}
|
|
// Shadow-piercing: these controls live several shadow roots deep.
|
|
return walk(document)
|
|
.filter((e) => e.getAttribute && e.getAttribute('aria-label') !== null)
|
|
.map((e) => (e.getAttribute('aria-label') || '').trim())
|
|
})
|
|
await page.close()
|
|
return { labels, errs }
|
|
}
|
|
|
|
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
|
const VIEWPORTS = [
|
|
{ name: 'desktop', viewport: { width: 1500, height: 1000 }, mobile: false },
|
|
{ name: 'mobile', viewport: { width: 390, height: 844 }, mobile: true },
|
|
]
|
|
const seen = {}
|
|
try {
|
|
for (const vp of VIEWPORTS) {
|
|
seen[vp.name] = {}
|
|
for (const loc of ['en', ...UNDER_TEST]) {
|
|
seen[vp.name][loc] = await readLabels(browser, loc, vp.viewport, vp.mobile)
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
|
|
const fails = []
|
|
const report = []
|
|
|
|
for (const vp of VIEWPORTS) {
|
|
const byLoc = seen[vp.name]
|
|
const en = byLoc.en
|
|
|
|
// PRECONDITION: a page with no aria-labels at all makes every assertion vacuous. A
|
|
// bundle that failed to execute renders nothing with zero page errors (the stale-SRI
|
|
// signature), and would otherwise read as "nothing untranslated".
|
|
if (!en.labels.length) {
|
|
fails.push(`${vp.name}: no aria-labels rendered at all — either the wrong url, or the `
|
|
+ 'bundle did not execute (a stale SRI hash refuses the script SILENTLY; run '
|
|
+ '`bun run tree` after `BUNDLE=<n> bun x vite build`)')
|
|
continue
|
|
}
|
|
|
|
for (const loc of ['en', ...UNDER_TEST]) {
|
|
const s = byLoc[loc]
|
|
// ASSERTION 1 — NO RAW KEYS, in every locale INCLUDING English. A missing catalog
|
|
// entry is announced as its own key name, which is gibberish to a screen reader.
|
|
const raw = [...new Set(s.labels.filter((t) => RAW_KEY.test(t)))]
|
|
if (raw.length) {
|
|
fails.push(`${vp.name}/${loc}: ${raw.length} control(s) announce a RAW I18N KEY — `
|
|
+ `${JSON.stringify(raw)} — vue-i18n echoes the key when the lookup misses, so a `
|
|
+ 'screen reader reads the key name aloud')
|
|
}
|
|
// NEVER BLANK either: an empty aria-label is worse than none, it suppresses the
|
|
// fallback to the element's own text.
|
|
const blank = s.labels.filter((t) => !t).length
|
|
if (blank) fails.push(`${vp.name}/${loc}: ${blank} EMPTY aria-label(s)`)
|
|
if (s.errs.length) fails.push(`${vp.name}/${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
|
|
}
|
|
|
|
// ASSERTION 2 — THE LOCALE MUST MOVE THEM. Raw-key-free is not the same as localized:
|
|
// a hardcoded English label passes assertion 1 forever. At least one accessible name
|
|
// must differ from English in each locale, or nothing on this surface is translated.
|
|
for (const loc of UNDER_TEST) {
|
|
const s = byLoc[loc]
|
|
const diff = s.labels.filter((t, i) => en.labels[i] && t !== en.labels[i]).length
|
|
if (s.labels.length && diff === 0) {
|
|
fails.push(`${vp.name}/${loc}: NOT ONE of ${s.labels.length} aria-label(s) differs from `
|
|
+ 'English — the accessible names are hardcoded, not catalogued')
|
|
}
|
|
// ⚠ A FLOOR, NOT JUST "> 0". `diff === 0` alone is decoration: it prints a count and
|
|
// only fails when EVERY label regresses. Measured 2026-09-24 — hardcoding ONE label
|
|
// back to English dropped this from 6 to 5 and the gate still passed, which is the
|
|
// exact shape ("a number printed but not asserted on") this tree keeps rediscovering.
|
|
// The floor is the count that currently holds, so any single regression goes red.
|
|
// RAISE IT when you localize more; never lower it to make a red build green.
|
|
if (s.labels.length && diff < LOCALIZED_FLOOR) {
|
|
fails.push(`${vp.name}/${loc}: only ${diff} of ${s.labels.length} aria-label(s) differ from `
|
|
+ `English, expected at least ${LOCALIZED_FLOOR} — a label that used to be catalogued `
|
|
+ 'has been hardcoded back to English (it leaves no raw key behind, so assertion 1 '
|
|
+ 'cannot see it)')
|
|
}
|
|
report.push(` ${vp.name.padEnd(8)} ${loc.padEnd(3)}: ${diff}/${s.labels.length} differ from English, `
|
|
+ `${s.labels.filter((t) => RAW_KEY.test(t)).length} raw key(s), ${s.labels.filter((t) => !t).length} blank`)
|
|
}
|
|
}
|
|
|
|
console.log('ARIA-LABELS-LOCALIZED')
|
|
console.log(` leaf : ${URL_}`)
|
|
console.log(` labels : ${seen.desktop?.en?.labels.length ?? 0} desktop / ${seen.mobile?.en?.labels.length ?? 0} mobile (English)`)
|
|
for (const line of report) console.log(line)
|
|
|
|
if (fails.length) {
|
|
console.error(`\nARIA-LABELS-LOCALIZED: FAIL — ${fails.length} problem(s)`)
|
|
for (const f of fails) console.error(' - ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log('\nARIA-LABELS-LOCALIZED: PASS — no control announces a raw i18n key, none is blank, '
|
|
+ 'and choosing a locale changes what a screen reader says.')
|