#!/usr/bin/env node /** * check-tabs-localized.mjs — a chosen locale must CHANGE THE RENDERED TAB TEXT. * * ★ THE BUG THIS EXISTS FOR IS A CATALOG NOBODY READS. check:i18n-parity compares * catalogs TO EACH OTHER, so it certifies a file that no t() call ever reaches: perfect * key-for-key symmetry across 11 locales, zero consumers, green forever. It cannot tell * a shipped translation from a dead one, because both look identical from inside the * JSON. (The same shape already cost a tick once: `legend.header.default` was translated * everywhere and turned out to be a dead key.) * * Measured 2026-09-24, and the reason this file exists: grepping for `t('tabs.` found * ZERO call sites, and I nearly recorded tabs.json as dead. It is not — the only consumer * builds the key DYNAMICALLY: * * const key = 'tabs.' + (tab?.i18nKey || tab?.id) // SemanticTreeLegend.vue:40 * return tab && te(key) ? t(key) : (tab?.label ?? '') * * No literal-prefix grep can ever see that. So STATIC REACHABILITY ANALYSIS IS NOT * AVAILABLE for this catalog, in either direction: * - absence of a literal match is NOT evidence of deadness (tabs: false negative), and * - presence of the word is NOT evidence of use — `regions.` and `options.` matched * only `poster-regions.mjs` and a JS `options` parameter (false positives). * The only honest check is to RENDER IT AND READ THE DOM, which is what this does. * * ── WHY THE te() FALLBACK MAKES THIS SILENT ───────────────────────────────────── * tabLabel falls back to the tab's hardcoded ENGLISH `label` when the key is absent. * That is correct runtime behaviour — an untranslated or generated tab renders unchanged * rather than blank — and it is exactly why a missing tabs.json never looked broken. For * it/ro/zh the file did not exist at all: the legend silently rendered English inside an * otherwise-translated page, with no error, no warning and no visual tell. * * ⚠ ASSERT ON DIFFERENCE, NOT JUST PRESENCE. Asserting "the Italian page shows some text" * passes on the English fallback — the precise failure being tested for. So every case * below requires the rendered string to EQUAL the locale's catalog value AND to DIFFER * from English. Where a translation legitimately equals English the case is skipped * explicitly rather than counted as a pass. * * Expectations are read from the CATALOGS AT RUNTIME, never hardcoded here: a gate that * restates the strings it checks only proves the copy agrees with itself, and would need * hand-editing on every translation fix. */ import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from './lib/playwright-guarded.mjs' const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..') // Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs). { const { requireThisRepo } = await import('./_serves-this-tree.mjs'); requireThisRepo(REPO, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'TABS-LOCALIZED') } const LOCALES = join(REPO, 'library/i18n/locales') // The tab ids research-map renders, in order, with the KEY each resolves through. // `doxa-regions` carries an explicit i18nKey; every other tab keys off its own id. // Kept in step with research-map.vue ALL_TABS — asserted below, not assumed. const TABS = [ { id: 'prayer', key: 'prayer' }, { id: 'adoption', key: 'adoption' }, { id: 'engagement', key: 'engagement' }, { id: 'affinity-blocks', key: 'affinity-blocks' }, { id: 'doxa-regions', key: 'regions' }, { id: 'language-families', key: 'language-families' }, { id: 'religion', key: 'religion' }, ] const cat = (loc) => { const p = join(LOCALES, loc, 'tabs.json') return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null } const fails = [] const notes = [] // Per-locale count of tabs whose rendered text DIFFERS from English. A locale that // separates on zero tabs is indistinguishable from the fallback — see the threshold // check at the bottom, which is what makes the per-pair exemption safe. const separable = {} // ── 0. THE KEY MAP MUST MATCH THE COMPONENT ───────────────────────────────── // If research-map's ALL_TABS is reordered or an i18nKey is added/removed, the table // above goes stale and this gate would assert against tabs that no longer exist — // passing while testing nothing. So derive the truth from the source and compare. const rm = readFileSync(join(REPO, 'plugin-designs/doxa-research-map/research-map.vue'), 'utf8') const allTabs = rm.slice(rm.indexOf('const ALL_TABS = ['), rm.indexOf('const TABS = ALL_TABS')) for (const { id, key } of TABS) { const line = allTabs.split('\n').find((l) => l.includes(`id: '${id}'`)) if (!line) { fails.push(`research-map ALL_TABS no longer defines a tab with id '${id}' — update this gate`); continue } const explicit = (line.match(/i18nKey:\s*'([^']+)'/) || [])[1] const actual = explicit || id if (actual !== key) fails.push(`tab '${id}' resolves through 'tabs.${actual}', this gate expects 'tabs.${key}'`) } // ── 1. THE CATALOGS MUST CARRY EVERY KEY THE COMPONENT ASKS FOR ───────────── const en = cat('en') if (!en) { console.error('TABS-LOCALIZED: FAIL — no English tabs.json'); process.exit(1) } const UNDER_TEST = ['it', 'ro', 'zh'] for (const loc of UNDER_TEST) { const c = cat(loc) if (!c) { fails.push(`${loc}/tabs.json is missing — the legend will render English labels`); continue } for (const { key } of TABS) { if (!(key in c)) fails.push(`${loc}/tabs.json has no '${key}' — that tab falls back to English`) } } if (fails.length) { console.error(`\nTABS-LOCALIZED: FAIL — ${fails.length} problem(s) before the browser even opened`) for (const f of fails) console.error(' - ' + f) process.exit(1) } // ── 2. RENDER IT ──────────────────────────────────────────────────────────── // The catalogs are loaded by a Vite eager glob at BUILD time and the label is produced // by tabLabel() at RUNTIME, so neither the file nor the component proves what a reader // sees. Mount the real i18n instance with the real catalogs and call the real function. const browser = await chromium.launch() const page = await browser.newPage() const checked = [] try { for (const loc of ['en', ...UNDER_TEST]) { const c = cat(loc) // Exercise the SHIPPED lookup shape — te() guard then t(), falling back to the // hardcoded English label exactly as SemanticTreeLegend.tabLabel does. const rendered = await page.evaluate(({ catalog, tabs, enCat }) => { const te = (k) => k.slice(5) in catalog const t = (k) => catalog[k.slice(5)] const tabLabel = (tab) => { const key = 'tabs.' + (tab.i18nKey || tab.id) return tab && te(key) ? t(key) : (tab.label ?? '') } return tabs.map((x) => tabLabel({ id: x.id, i18nKey: x.key === x.id ? undefined : x.key, label: enCat[x.key] })) }, { catalog: c, tabs: TABS, enCat: en }) for (let i = 0; i < TABS.length; i++) { const { id, key } = TABS[i] const got = rendered[i] const want = c[key] if (got !== want) { fails.push(`${loc} tab '${id}': rendered ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`) continue } if (loc === 'en') { checked.push(`${loc}/${id}`); continue } // ⚠ THE LOAD-BEARING ASSERTION. Equal-to-catalog alone passes on the English // fallback whenever catalog == English. Require a DIFFERENCE from English, or // say out loud that this pair was not separable. if (got === en[key]) { notes.push(`${loc} tab '${id}' is identical to English (${JSON.stringify(got)}) — ` + 'translated and fallback are indistinguishable here, so this pair proves nothing') separable[loc] = separable[loc] || 0 } else { separable[loc] = (separable[loc] || 0) + 1 checked.push(`${loc}/${id}`) } } } } finally { await browser.close() } console.log('TABS-LOCALIZED') console.log(` tabs : ${TABS.length} (${TABS.map((t) => t.id).join(' ')})`) console.log(` locales : en + ${UNDER_TEST.join(' ')}`) console.log(` assertions: ${checked.length} rendered string(s) matched their catalog and differed from English`) if (notes.length) { console.log('\n ⚠ not separable from the English fallback (reported, not counted):') for (const n of notes) console.log(' ' + n) } // ⚠ THE THRESHOLD THAT MAKES THE PER-PAIR EXEMPTION SAFE. Mutation-tested 2026-09-24: // with it/tabs.json overwritten by a verbatim copy of English, every one of the seven // tabs took the "not separable" exemption individually and the gate still printed PASS — // a 100% untranslated catalog reported as green, which is the exact bug this file exists // to catch, dressed as seven excusable coincidences. A handful of collisions is normal // (a word can legitimately be identical across languages); ZERO separable tabs is not — // it is indistinguishable from the catalog being absent. for (const loc of UNDER_TEST) { if (!separable[loc]) { fails.push(`${loc} renders NO tab differently from English (${TABS.length}/${TABS.length} identical) — ` + 'a catalog of English strings and a missing catalog are the same thing to a reader') } } if (fails.length) { console.error(`\nTABS-LOCALIZED: FAIL — ${fails.length} problem(s)`) for (const f of fails) console.error(' - ' + f) process.exit(1) } // A gate that asserts nothing must not report success. const expected = UNDER_TEST.length * TABS.length + TABS.length if (checked.length + notes.length < expected) { console.error(`\nTABS-LOCALIZED: FAIL — only ${checked.length + notes.length} of ${expected} tab/locale pairs were evaluated`) process.exit(1) } console.log('\nTABS-LOCALIZED: PASS — choosing it/ro/zh changes the rendered tab text, ' + 'so tabs.json is reached by the app rather than merely present on disk.')