doxa-maps/internal/check-host-locale-contract.mjs
doxa-maps-88655c70 44ef31b2c6 Tooling reads the tree's name from the build config, in one place
The output tree's name has always been configurable (vite.doxa-maps-build-config.json "name"),
but 61 tooling files ignored it and hardcoded 'doxa-maps' (181 literals), so a rename would have
moved the build while the gates kept reading the old folder.

- internal/_tree-name.mjs reads it once (validated as one URL-safe path segment; falls back to the
  historical name) and exports TREE_NAME / TREE_NAME_RE; `node internal/_tree-name.mjs` prints it
  for shell scripts.
- A codemod rewrote 171 literals in internal/ to TREE_NAME; the regex literals, messages with
  backticks, atomic-public-swap.sh (fails loudly if the name cannot be read), build-lock.sh and
  vite.config.js (its duplicate readMfeName is gone) were done by hand.
- The name is unchanged: old and new tooling produce all 1408 files under public/ byte for byte.
  Code lines still naming the tree in internal/ + vite.config.js: 2, both intended (the helper's
  fallback, the private-repo-name pattern in public-hygiene-check).

This renames nothing visible; the eventual rename becomes a one-line config change.
2026-09-25 16:25:22 +00:00

153 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* check-host-locale-contract — a HOST that passes profile-config.locale gets that language.
*
* THE CONTRACT. `profile-config.locale` is how a host page (the Nuxt site, a WordPress embed) tells
* an element which language to speak — ProfileLoader.vue reads it and drives the element's i18n. It
* does that through `inject('appI18n', null)`: a bundle whose index.js never `app.provide('appI18n')`
* hands ProfileLoader a null, the watchEffect does nothing, and the host's locale is SILENTLY
* IGNORED — no error, just English. Found in source 2026-09-25 in six bundles; this gate is what keeps
* the durable fix (createAppI18n's install provides appI18n itself) from quietly regressing.
*
* HOW, per leaf of every profile in the manifest (public/doxa-maps/<bundle>/<profile>/index.html):
* HOST the leaf page with ONE change — "locale":"ar" added to its BASE_CONFIG (the object it passes
* as profile-config). No ?lang=, and the page keeps its baked <html lang="en">, so the ONLY
* route to Arabic is the host contract under test.
* REF the same leaf with ?lang=ar (read by detectLocale when the instance is created — a different
* path that does not depend on appI18n being provided). It answers "does this page HAVE
* Arabic chrome to show?", so a page with no translatable text is reported n/a, not failed.
* Arabic-script characters (U+0600–U+06FF) are counted over every text node and the placeholder /
* aria-label / title attributes, through every shadow root, once the count stops changing.
* VERDICT per leaf: HOST ≥ half of REF → ok · otherwise FAIL · REF < MIN → n/a (nothing to judge),
* unless HOST ≥ MIN: then the contract holds and it is ?lang= the page ignores (ok, noted host-only).
* SETTLE: network idle (capped at 20 s), then the count must hold for 3 s. Measured 2026-09-25: a
* shorter settle read the control at 76 in one run and 2054 in the next.
* TODAY (2026-09-25, before upg100's createAppI18n fix): 18 leaves FAIL — every composite among them.
* POSITIVE CONTROL: regions-dashboard/regions-list (provides appI18n) must be ok, or the whole run is
* NOT MEASURED — a harness that cannot see Arabic there cannot say anything about the rest.
*
* USAGE node internal/check-host-locale-contract.mjs [--dir <tree root>] [--only bundle/profile,…]
* Serves THIS checkout's public/ on an OS-assigned port, or --dir (another commit's tree, for the
* before/after). Run through internal/browser-slot.sh; the FULL run (35 leaves x 2 loads) can pass
* the slot's 900 s default hold, so give it BROWSER_MAX_HOLD=1800. --only runs fit easily.
* EXIT 0 every judged leaf enters the host's locale · 1 one does not · 75 not measured.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const ONLY = arg('--only', '') ? new Set(arg('--only', '').split(',')) : null
const CONTROL = 'regions-dashboard/regions-list'
const MIN = 20 // Arabic characters a page must show under ?lang=ar before it can be judged
const CONCURRENCY = 3
const manifestPath = join(DIR, TREE_NAME, 'manifest.json')
if (!existsSync(manifestPath)) { console.log(`HOST-LOCALE: NOT MEASURED — no ${manifestPath}`); process.exit(75) }
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const leaves = []
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
const id = `${b.bundle}/${p.profile}`
if (ONLY && !ONLY.has(id) && id !== CONTROL) continue
if (existsSync(join(DIR, TREE_NAME, id, 'index.html'))) leaves.push(id)
}
if (!leaves.includes(CONTROL)) { console.log(`HOST-LOCALE: NOT MEASURED — the control ${CONTROL} is not in this tree`); process.exit(75) }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* already gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`HOST-LOCALE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
const COUNT = `(() => {
let n = 0; const AR = /[\\u0600-\\u06FF]/g
const add = (s) => { if (s) { const m = String(s).match(AR); if (m) n += m.length } }
const walk = (root) => {
const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT)
for (let node = tw.currentNode; node; node = tw.nextNode()) {
if (node.nodeType === 3) { add(node.nodeValue); continue }
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') continue
for (const a of ['placeholder', 'aria-label', 'title']) add(node.getAttribute && node.getAttribute(a))
if (node.shadowRoot) walk(node.shadowRoot)
}
}
walk(document); return n
})()`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
async function measure(id, mode) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
const page = await ctx.newPage()
const url = `http://127.0.0.1:${port}/${TREE_NAME}/${id}/index.html${mode === 'ref' ? '?lang=ar' : ''}`
let injected = mode === 'ref'
if (mode === 'host') {
await page.route(url, async (route) => {
const res = await route.fetch()
const html = await res.text()
const out = html.replace(/var BASE_CONFIG = \{/, 'var BASE_CONFIG = {"locale":"ar",')
injected = out !== html
await route.fulfill({ response: res, body: out })
})
}
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
// SETTLE. A first version stopped after three equal reads 500 ms apart and read the control at 76
// Arabic characters in one run and 2054 in another: regions-list's feed takes over a second, and
// the page sat still (in English placeholders) until it arrived. So: wait for the network to go
// quiet (capped — a map streams tiles for ever), then require the count to hold for 3 s.
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {})
let last = -1, same = 0, n = 0
for (let i = 0; i < 60 && same < 6; i++) {
await page.waitForTimeout(500)
n = await page.evaluate(COUNT).catch(() => 0)
same = n === last ? same + 1 : 0; last = n
}
return { n, injected }
} catch (e) { return { n: null, injected, error: String(e.message || e).split('\n')[0] } }
finally { await ctx.close() }
}
const rows = []
async function judge(id) {
const host = await measure(id, 'host'), ref = await measure(id, 'ref')
let verdict, why = host.error || ref.error || (!host.injected ? 'no BASE_CONFIG in the page' : '')
if (!host.injected) verdict = 'unmeasured' // the page has no BASE_CONFIG to set
else if (host.n === null || ref.n === null) verdict = 'unmeasured'
// ?lang=ar showed no Arabic but the host path did: the page is translatable and the CONTRACT holds;
// it is ?lang= that page ignores — a different defect, reported, not failed here.
else if (ref.n < MIN && host.n >= MIN) { verdict = 'ok'; why = 'host-only: this page ignores ?lang=' }
else if (ref.n < MIN) verdict = 'n/a'
else verdict = host.n >= ref.n / 2 ? 'ok' : 'FAIL'
rows.push({ id, host: host.n, ref: ref.n, verdict, why })
}
// The control runs ALONE first, so it is never starved by three maps loading beside it.
await judge(CONTROL)
const queue = leaves.filter((l) => l !== CONTROL)
await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (queue.length) await judge(queue.shift()) }))
await browser.close(); stop()
rows.sort((a, b) => (a.id === CONTROL ? -1 : b.id === CONTROL ? 1 : a.id.localeCompare(b.id)))
console.log(`HOST-LOCALE: profile-config.locale="ar" (HOST) vs ?lang=ar (REF), Arabic-script characters drawn, ${DIR}`)
for (const r of rows) console.log(` ${r.verdict.padEnd(10)} ${r.id.padEnd(48)} host ${String(r.host ?? '—').padStart(5)} ref ${String(r.ref ?? '—').padStart(5)}${r.id === CONTROL ? ' ← positive control' : ''}${r.why ? ' ' + r.why : ''}`)
const control = rows.find((r) => r.id === CONTROL)
if (!control || control.verdict !== 'ok') {
console.log(`HOST-LOCALE: NOT MEASURED — the positive control ${CONTROL} did not enter Arabic (host ${control && control.host}, ref ${control && control.ref}); this harness cannot judge the rest.`)
process.exit(75)
}
const fails = rows.filter((r) => r.verdict === 'FAIL'), un = rows.filter((r) => r.verdict === 'unmeasured'), na = rows.filter((r) => r.verdict === 'n/a')
if (fails.length) {
console.log(`HOST-LOCALE: FAIL — ${fails.length} leaf/leaves ignore the host's profile-config.locale although they can speak Arabic: ${fails.map((r) => r.id).join(', ')}`)
console.log(' The usual cause: the bundle never provides appI18n, so ProfileLoader\'s inject(\'appI18n\') is null.')
process.exit(1)
}
if (un.length) { console.log(`HOST-LOCALE: NOT MEASURED — ${un.length} leaf/leaves could not be read: ${un.map((r) => r.id).join(', ')}`); process.exit(75) }
console.log(`HOST-LOCALE: PASS — ${rows.length - na.length} leaves enter the host's locale; ${na.length} have no Arabic chrome to judge (n/a).`)