doxa-maps/internal/check-host-locale-contract.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
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.
2026-09-28 08:20:21 +00:00

155 lines
9.9 KiB
JavaScript
Raw Permalink 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. A FULL run loads every leaf twice and
* passed the slot's 900 s default three times, so this gate declares its own limit, which the
* wrapper reads from this header:
* @browser-slot-max-hold 2700
* EXIT 0 every judged leaf enters the host's locale · 1 one does not · 75 not measured.
*/
import { chromium } from './lib/playwright-guarded.mjs'
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).`)