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.
158 lines
8.5 KiB
JavaScript
158 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// check-legend-info-localized — the legend's INFO POPOVERS must translate, in the
|
|
// real served page, not in a reimplementation of the lookup.
|
|
//
|
|
// ★ WHY THIS EXISTS BESIDE check:tabs-localized. That gate proves the dynamic-key
|
|
// mechanism reaches tabs.json — genuinely valuable, and it passes. But it tests SEVEN
|
|
// HARDCODED research-map tabs and it REIMPLEMENTS tabLabel() inside page.evaluate with
|
|
// catalogs fed in by hand. So it proves "tab localization works, for these seven",
|
|
// which is not the same claim as "this profile's tabs are localized". The UPG-100
|
|
// legend's three tabs were absent from tabs.json entirely and that gate stayed green.
|
|
//
|
|
// ⚠ AND IT NEVER LOOKED AT `info` AT ALL. SemanticTreeLegend rendered `tab.info` RAW in
|
|
// all three places it appears (the button's title=, and the popover body) while the tab
|
|
// LABEL beside it went through the catalog. Measured 2026-09-24 on the served leaf:
|
|
// ?lang=es changed 2 of 76 rendered strings ("POPULATION" -> "POBLACIÓN") and left all
|
|
// three tabs AND all three info popovers in English. The mechanism worked; the strings
|
|
// were never catalogued. The Driver named this affordance specifically — "the tab itself
|
|
// gives you a little information button that will explain what a UPG-100 list is" —
|
|
// which is exactly the sentence a reader who does not read English cannot use.
|
|
//
|
|
// ⚠ IT RENDERS THE REAL PAGE. A catalog-only check cannot tell a shipped translation
|
|
// from a dead one, and a reimplemented lookup cannot tell you the component actually
|
|
// calls it. This opens the served leaf, switches locale the way a reader does (?lang=),
|
|
// CLICKS each info button and reads the popover that paints.
|
|
//
|
|
// ⚠ WAIT FOR THE FLUSH. showInfo() sets a ref; reading the DOM in the same tick as the
|
|
// click finds no popover and reads as "the affordance is broken". Measured: the first
|
|
// version of this probe reported "(no popover)" in every locale against a working build.
|
|
//
|
|
// Usage: node internal/check-legend-info-localized.mjs <leafUrl> [locales]
|
|
import { chromium } from 'playwright'
|
|
import { readFileSync, existsSync } from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { requireThisTree } from './_serves-this-tree.mjs'
|
|
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
|
|
|
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const LOCALES = join(REPO, 'library/i18n/locales')
|
|
|
|
const URL_ = process.argv[2] || process.env.LEGEND_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, 'LEGEND-INFO-LOCALIZED')
|
|
if (!URL_) {
|
|
console.log('LEGEND-INFO-LOCALIZED: SKIP — no leaf url given.')
|
|
console.log(' Pass one, or set LEGEND_URL. Serve the TREE ROOT (public/doxa-maps) and point')
|
|
console.log(' at a profile whose legend carries per-tab info buttons, e.g.')
|
|
console.log(' 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)
|
|
|
|
const legendCat = (loc) => {
|
|
const p = join(LOCALES, loc, 'legend.json')
|
|
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null
|
|
}
|
|
|
|
// Read each locale's info popovers from the REAL page, clicking every info button.
|
|
async function readLegend(page, url, lang) {
|
|
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 walkFn = () => {
|
|
const walk = (root, out = []) => {
|
|
for (const el of root.querySelectorAll('*')) { out.push(el); if (el.shadowRoot) walk(el.shadowRoot, out) }
|
|
return out
|
|
}
|
|
return walk(document)
|
|
}
|
|
const count = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info')).length`)
|
|
const pops = []
|
|
for (let i = 0; i < count; i++) {
|
|
await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-info'))[${i}]?.click()`)
|
|
// ⚠ showInfo() sets a ref — read AFTER Vue flushes, or every popover reads empty.
|
|
await page.waitForTimeout(400)
|
|
pops.push(await page.evaluate(`(() => { const el = (${walkFn.toString()})().find(e => (e.className||'').toString().includes('stl-info-pop')); return el ? (el.innerText||'').trim() : '' })()`))
|
|
}
|
|
const tabs = await page.evaluate(`(${walkFn.toString()})().filter(e => (e.className||'').toString().includes('stl-tab-chip')).map(c => (c.innerText||'').trim().split('\\n')[0]).filter(Boolean)`)
|
|
return { tabs, pops, errs }
|
|
}
|
|
|
|
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
|
const seen = {}
|
|
try {
|
|
for (const loc of ['en', ...UNDER_TEST]) {
|
|
const page = await browser.newPage({ viewport: { width: 1500, height: 1000 } })
|
|
seen[loc] = await readLegend(page, URL_, loc)
|
|
await page.close()
|
|
}
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
|
|
const en = seen.en
|
|
const fails = []
|
|
|
|
// PRECONDITION: this page must actually HAVE the affordance, or every assertion below
|
|
// is vacuous. A leaf whose bundle failed to execute renders zero tabs with zero page
|
|
// errors — the stale-SRI signature — and would otherwise pass as "nothing untranslated".
|
|
if (!en.pops.length) {
|
|
console.error('\nLEGEND-INFO-LOCALIZED: FAIL — no info buttons rendered at all in English.')
|
|
console.error(' Either this profile has no per-tab info affordance (wrong url), or the bundle')
|
|
console.error(' did not execute. A stale SRI hash refuses the script SILENTLY, with no page')
|
|
console.error(` error and zero tabs — run \`bun run tree\` after \`BUNDLE=<n> bun x vite build\`.`)
|
|
console.error(` tabs rendered: ${JSON.stringify(en.tabs)} page errors: ${en.errs.length}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
// THE LOAD-BEARING ASSERTION. A chosen locale must CHANGE the info text — that is the
|
|
// difference between a translation that ships and one that merely exists on disk.
|
|
const separable = {}
|
|
for (const loc of UNDER_TEST) {
|
|
const s = seen[loc]
|
|
if (!s.pops.length) { fails.push(`${loc}: no info popovers rendered (English had ${en.pops.length})`); continue }
|
|
if (s.pops.length !== en.pops.length) {
|
|
fails.push(`${loc}: ${s.pops.length} info popover(s), English had ${en.pops.length}`)
|
|
}
|
|
// NEVER BLANK. The whole point of a per-key fallback is that an untranslated key
|
|
// shows English rather than nothing.
|
|
const blank = s.pops.filter((t) => !t.trim()).length
|
|
if (blank) fails.push(`${loc}: ${blank} info popover(s) rendered BLANK — the fallback should show English`)
|
|
// Count how many DIFFER from English. Zero means the catalog is not reached: either
|
|
// the entries are missing, or the component renders the raw prop as it used to.
|
|
const diff = s.pops.filter((t, i) => t && en.pops[i] && t !== en.pops[i]).length
|
|
separable[loc] = diff
|
|
const cat = legendCat(loc)
|
|
const catKeys = cat && cat.tabInfo ? Object.keys(cat.tabInfo).length : 0
|
|
if (catKeys > 0 && diff === 0) {
|
|
fails.push(`${loc}: legend.json declares ${catKeys} tabInfo key(s) but NOT ONE info popover `
|
|
+ 'differs from English — the catalog is present and unreached (the raw-prop bug)')
|
|
}
|
|
if (s.errs.length) fails.push(`${loc}: ${s.errs.length} page error(s): ${s.errs[0]}`)
|
|
}
|
|
|
|
console.log('LEGEND-INFO-LOCALIZED')
|
|
console.log(` leaf : ${URL_}`)
|
|
console.log(` info popups: ${en.pops.length} (English)`)
|
|
console.log(` tabs : ${en.tabs.join(' · ')}`)
|
|
for (const loc of UNDER_TEST) {
|
|
const s = seen[loc]
|
|
console.log(` ${loc.padEnd(10)}: ${separable[loc] ?? 0}/${s.pops.length} info text(s) differ from English, `
|
|
+ `${s.pops.filter((t) => !t.trim()).length} blank tabs: ${s.tabs.join(' · ')}`)
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error(`\nLEGEND-INFO-LOCALIZED: FAIL — ${fails.length} problem(s)`)
|
|
for (const f of fails) console.error(' - ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log('\nLEGEND-INFO-LOCALIZED: PASS — choosing a locale changes the rendered info text, so '
|
|
+ 'the legend\'s explanations reach the reader in their own language, never blank.')
|