The shared poster wall's locale is asserted, in locales that can show a leak
UPG-100 carried a ❌ for a PosterWall i18n leak: useI18n() said to throw where the wall mounts, leaving the print buttons English in every locale on upg-100-mfe, poster-wall and country-poster-wall. IT DOES NOT REPRODUCE. Measured on the current build, three leaves x ro/ar/zh, tree identity checked against the served manifest first: zero English leaks, zero page errors. ro "Listă de rugăciune / Harta afișului / Pachet de cartonașe", ar "قائمة الصلاة / خريطة الملصق", zh "禱告名單 / 海报地图", legend "Opțiuni de tipărire" / "خيارات الطباعة" / "列印選項". The diagnosis does not fit either: this bundle installs i18n per element via configureApp, and createI18n is legacy:false, so useI18n() resolves for every descendant including the wall. THE REAL DEFECT WAS THE MISSING ASSERTION, and it is worse than the bug would have been: nothing in the repo checked the shared wall's RENDERED locale, so a ❌ could sit on the scoreboard with nothing able to confirm or refute it. ★ AND IT CANNOT BE CHECKED IN ENGLISH. The wall's guard HUMANIZES the key when useI18n() throws - buttons.prayerList becomes "Prayer List" - which is deliberate, so a bare host shows readable English rather than a raw key. It also means "the button says Prayer List" is true both when i18n worked and when it threw. An en-only check passes forever while every other locale leaks. This gate therefore runs ONLY in non-English locales, where the translated string differs from the humanized key and seeing the key IS the defect. It covers FIVE consumer leaves, not the three the report named: key-cities and regions-dashboard mount the same shared wall and were never checked. A gate that copies a report's sampling inherits its blind spot. TWO WAYS IT COULD HAVE PASSED WITHOUT CHECKING ANYTHING, both real on the first run and both now hard skips rather than green ticks: a wall with no cards renders no chrome, and regions-list rendered 7 cards with ZERO wall chrome because that profile mounts the wall without the print row. Skips are named in the output with their reason - a summary that counts only the green ones reads as full coverage. MUTATION TESTED by reproducing the reported condition exactly - forcing the useI18n() guard to throw - rather than corrupting a string: the gate goes red with 28-33 leaked strings on every measured combination, which proves both that it detects the defect and that the defect is genuinely absent from the unmutated build. Restored, 9 combinations PASS, 6 reported as not covered. No source change: there was nothing to fix.
This commit is contained in:
parent
c77207b53a
commit
cd53582ea4
2 changed files with 170 additions and 1 deletions
168
internal/check-poster-wall-localised.mjs
Normal file
168
internal/check-poster-wall-localised.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-poster-wall-localised — the SHARED poster wall's own chrome must be translated
|
||||
* on every leaf that mounts it, in every locale.
|
||||
*
|
||||
* ★ WHY THIS CANNOT BE CHECKED IN ENGLISH, which is the whole reason the gap survived.
|
||||
* PosterWall guards useI18n() (it THROWS on a host that never installed vue-i18n, which blanked
|
||||
* people-groups-grid once via library FilterDropdown). The fallback HUMANIZES the key:
|
||||
* buttons.prayerList -> "Prayer List" buttons.printOptions -> "Print Options"
|
||||
* That is deliberate and good — a bare host shows readable English rather than a raw dotted key
|
||||
* or a blank button. But it means "the button says Prayer List" is TRUE both when i18n worked and
|
||||
* when it threw. An en-only check passes forever while every other locale leaks English.
|
||||
*
|
||||
* So this gate runs in NON-ENGLISH locales only. There, the translated string differs from the
|
||||
* humanized key, so seeing the humanized key IS the defect, with no ambiguity.
|
||||
*
|
||||
* ⚠ IT CHECKS THE RENDERED PAGE, NOT THE SOURCE. A static scan can see `useI18n()` wrapped in a
|
||||
* try/catch and conclude either "guarded, fine" or "might throw, bad" — neither is a measurement.
|
||||
* Whether the catch FIRES depends on what the host installed at the mount point, which only
|
||||
* exists at runtime. A scoreboard entry for this was open with nothing able to confirm or refute
|
||||
* it; that is what this closes.
|
||||
*
|
||||
* Usage: node internal/check-poster-wall-localised.mjs [baseUrl]
|
||||
* SKIPs when nothing is served; exits 2 (NOT MEASURED) when the server is not this tree.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { servesThisTree } from './_serves-this-tree.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const BASE = process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:4173'
|
||||
|
||||
// EVERY leaf that mounts the shared wall. key-cities and regions-dashboard are here because they
|
||||
// consume PosterWall too — the original report named only three, and a gate that copies a
|
||||
// report's sampling inherits its blind spot.
|
||||
const LEAVES = [
|
||||
'my-upg-100-list/upg-100-mfe',
|
||||
'my-upg-100-list/poster-wall',
|
||||
'country-posters/country-poster-wall',
|
||||
'key-cities/city-poster-wall',
|
||||
'regions-dashboard/regions-list',
|
||||
]
|
||||
// ro is the widest-rendering Latin locale, ar is RTL, zh is double-width CJK: three different
|
||||
// ways a translation can be wrong, and all three differ from the humanized English key.
|
||||
const LANGS = (process.env.PW_LANG || 'ro,ar,zh').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
// The humanizeKey outputs for every key the wall renders. Seeing ONE of these in a non-English
|
||||
// locale means the fallback fired. Derived from the same transform the component uses, so the
|
||||
// two cannot drift: last path segment, camelCase split, first letter upper.
|
||||
const KEYS = ['buttons.prayerList', 'buttons.posterMap', 'buttons.cardDeck', 'buttons.printOptions',
|
||||
'buttons.postersPerRow', 'buttons.showOnMap', 'buttons.clearSelection', 'buttons.cardStockAria']
|
||||
const humanizeKey = (k) => String(k).split('.').pop()
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
|
||||
const FALLBACKS = new Set(KEYS.map(humanizeKey))
|
||||
|
||||
let reachable = false
|
||||
try { const r = await fetch(`${BASE}/doxa-maps/manifest.json`, { signal: AbortSignal.timeout(4000) }); reachable = r.ok } catch { /* precondition */ }
|
||||
if (!reachable) {
|
||||
console.log(`POSTER-WALL-LOCALISED: SKIP — nothing served at ${BASE}.`)
|
||||
console.log(' Serve the built tree: bun run serve (or set BASE_URL).')
|
||||
process.exit(0)
|
||||
}
|
||||
const treeCheck = await servesThisTree(BASE, ROOT)
|
||||
if (!treeCheck.ok) {
|
||||
console.error('POSTER-WALL-LOCALISED: NOT MEASURED — the server is not serving this checkout.')
|
||||
console.error(` ${treeCheck.why}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const WALK = `(root) => { const o=[]; const rec=(r)=>{ for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`
|
||||
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const rows = []
|
||||
const fails = []
|
||||
|
||||
for (const leaf of LEAVES) {
|
||||
for (const lang of LANGS) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
|
||||
const errs = []
|
||||
page.on('pageerror', (e) => errs.push(String(e).slice(0, 120)))
|
||||
let r = null
|
||||
try {
|
||||
const res = await page.goto(`${BASE}/doxa-maps/${leaf}/index.html?lang=${lang}`,
|
||||
{ waitUntil: 'load', timeout: 45000 })
|
||||
if (!res || !res.ok()) { rows.push({ leaf, lang, skipped: `HTTP ${res ? res.status() : '?'}` }); await page.close(); continue }
|
||||
await page.waitForTimeout(13000)
|
||||
r = await page.evaluate(`(() => {
|
||||
const all = (${WALK})(document)
|
||||
const cls = (e, n) => new RegExp('\\\\b' + n + '\\\\b').test(e.className || '')
|
||||
const texts = (n) => [...new Set(all.filter((e) => cls(e, n)).map((e) => e.textContent.trim()).filter(Boolean))]
|
||||
const legend = all.find((e) => e.tagName === 'LEGEND' && e.parentElement && /\\bpfs\\b/.test(e.parentElement.className || ''))
|
||||
const perrow = all.find((e) => cls(e, 'perrow__label'))
|
||||
return {
|
||||
cards: all.filter((e) => cls(e, 'card')).length,
|
||||
chrome: [
|
||||
...texts('pbtn'),
|
||||
...(legend ? [legend.textContent.trim()] : []),
|
||||
...(perrow ? [perrow.textContent.trim()] : []),
|
||||
...texts('pstock'),
|
||||
],
|
||||
showOnMap: texts('tape-h'),
|
||||
}
|
||||
})()`)
|
||||
} catch (e) {
|
||||
rows.push({ leaf, lang, skipped: String(e.message || e).slice(0, 60) })
|
||||
await page.close(); continue
|
||||
}
|
||||
await page.close()
|
||||
|
||||
// ⚠ TWO WAYS THIS GATE CAN PASS WITHOUT CHECKING ANYTHING, and both were REAL on the first
|
||||
// run — a gate that cannot tell "clean" from "saw nothing" is the same failure it exists to
|
||||
// catch. A wall with no cards renders no chrome; and regions-list rendered 7 cards with
|
||||
// chrome 0, because that profile mounts the wall WITHOUT the print row, so there was no
|
||||
// translatable string on the page at all and "all translated" was vacuously true.
|
||||
if (!r.cards) { rows.push({ leaf, lang, skipped: 'no poster cards rendered' }); continue }
|
||||
if (!r.chrome.length && !r.showOnMap.length) {
|
||||
rows.push({ leaf, lang, skipped: `${r.cards} card(s) but ZERO wall chrome — nothing to check` })
|
||||
continue
|
||||
}
|
||||
|
||||
// "Show on map" is a COMPOSED string ("116 UPG·Show on map"), so compare by containment.
|
||||
const leaked = [
|
||||
...r.chrome.filter((t) => FALLBACKS.has(t)),
|
||||
...r.showOnMap.filter((t) => [...FALLBACKS].some((f) => t.includes(f))),
|
||||
]
|
||||
rows.push({ leaf, lang, cards: r.cards, chrome: r.chrome.length, leaked, errs: errs.length })
|
||||
if (leaked.length) {
|
||||
fails.push(`${leaf} @ ${lang}: ${leaked.length} untranslated string(s) — ${[...new Set(leaked)].join(' · ')}`)
|
||||
}
|
||||
if (errs.length) fails.push(`${leaf} @ ${lang}: ${errs.length} page error(s) — ${errs[0]}`)
|
||||
}
|
||||
}
|
||||
await browser.close()
|
||||
|
||||
console.log('POSTER-WALL-LOCALISED')
|
||||
console.log(` base : ${BASE}`)
|
||||
for (const row of rows) {
|
||||
if (row.skipped) { console.log(` ${row.leaf} @ ${row.lang} SKIP — ${row.skipped}`); continue }
|
||||
console.log(` ${row.leaf} @ ${row.lang} cards ${row.cards} · chrome ${row.chrome} string(s) · `
|
||||
+ `${row.leaked.length ? `LEAKED ${row.leaked.length}` : 'all translated'} · ${row.errs} error(s)`)
|
||||
}
|
||||
const measured = rows.filter((x) => !x.skipped).length
|
||||
const skipped = rows.filter((x) => x.skipped)
|
||||
if (!measured) {
|
||||
console.error('\nPOSTER-WALL-LOCALISED: NOT MEASURED — every leaf skipped, so nothing was checked.')
|
||||
process.exit(2)
|
||||
}
|
||||
// ★ NO SILENT CAPS. A leaf that never got measured is not a leaf that passed, and a summary that
|
||||
// counts only the green ones reads as full coverage. Name what was dropped and why, every run —
|
||||
// key-cities ships NO leaf pages (bundle JS only), so it can never be reached by URL, and that
|
||||
// is a coverage fact a reader of this output needs rather than a 404 buried mid-list.
|
||||
if (skipped.length) {
|
||||
const byLeaf = new Map()
|
||||
for (const s of skipped) byLeaf.set(s.leaf, s.skipped)
|
||||
console.log(`\n NOT COVERED (${skipped.length} combination(s)):`)
|
||||
for (const [leaf, why] of byLeaf) console.log(` ${leaf} — ${why}`)
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\nPOSTER-WALL-LOCALISED: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(` - ${f}`)
|
||||
console.error('\n The shared wall humanizes its i18n keys when useI18n() throws, so the fallback')
|
||||
console.error(' reads as fluent English ("Prayer List"). That is why this can only be seen in a')
|
||||
console.error(' non-English locale, and why an en-only check would pass while every other')
|
||||
console.error(' locale leaked.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nPOSTER-WALL-LOCALISED: PASS — ${measured} leaf x locale combination(s), `
|
||||
+ `${LANGS.join('/')}. The shared wall's own chrome is translated everywhere it mounts.`)
|
||||
|
|
@ -202,7 +202,8 @@
|
|||
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
|
||||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
|
||||
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison",
|
||||
"check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs"
|
||||
},
|
||||
"//scripts": {
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue