diff --git a/README.md b/README.md index 853f954a2..adb6f5ad5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ the Research Gallery. ✅ works today · 🧪 proven, not yet in production. Every plugin builds into the same folder, and that folder is what all five methods ship — so a capability never limits where a plugin can go. +### The Experimental shelf + +**New plugins the Research Gallery picks up by itself** — each builds and embeds like any other, +and graduates to a named tab when it's ready. + + + + + + +
Wall of People Groups
Wall of People Groups
all 2,107 Doxa people groups as photo cards on one zoomable prayer wall, with a pray dialog
Prayer Walk Planner
Prayer Walk Planner
plan a prayer walk through a zone, open it full screen from any embed, and print a walking map that stays sharp when zoomed
+ +Also on the shelf: UPG neighbor, all people groups, country posters, the vision card, and the PPLR data maps. +
How one build becomes five ways to ship — the "map cruncher" @@ -149,7 +163,9 @@ Research Gallery capability 5 ├─ UPG neighbor upg-neighbor capability 3 ├─ all people groups all-people-groups capability 1 ├─ country posters country-posters capability 4 - └─ vision card doxa-vision-card capability 2 + ├─ vision card doxa-vision-card capability 2 + ├─ PPLR data maps pplr-data-maps capability 3 + └─ Wall of People Groups wall-of-people-groups capability 3 ```
diff --git a/documentation/readme-images/cap1-single-element.png b/documentation/readme-images/cap1-single-element.png index 725868c89..206890a1b 100644 Binary files a/documentation/readme-images/cap1-single-element.png and b/documentation/readme-images/cap1-single-element.png differ diff --git a/documentation/readme-images/cap2-adoption.png b/documentation/readme-images/cap2-adoption.png index 0c48017eb..87f71d7fc 100644 Binary files a/documentation/readme-images/cap2-adoption.png and b/documentation/readme-images/cap2-adoption.png differ diff --git a/documentation/readme-images/cap2-engagement.png b/documentation/readme-images/cap2-engagement.png index bce831ec0..c710fc31f 100644 Binary files a/documentation/readme-images/cap2-engagement.png and b/documentation/readme-images/cap2-engagement.png differ diff --git a/documentation/readme-images/cap2-prayer.png b/documentation/readme-images/cap2-prayer.png index c33293361..fb80176f4 100644 Binary files a/documentation/readme-images/cap2-prayer.png and b/documentation/readme-images/cap2-prayer.png differ diff --git a/documentation/readme-images/cap3-monolithic-research.png b/documentation/readme-images/cap3-monolithic-research.png index ff5ab7764..59caee758 100644 Binary files a/documentation/readme-images/cap3-monolithic-research.png and b/documentation/readme-images/cap3-monolithic-research.png differ diff --git a/documentation/readme-images/exp-prayer-walk-planner.png b/documentation/readme-images/exp-prayer-walk-planner.png new file mode 100644 index 000000000..39f119d92 Binary files /dev/null and b/documentation/readme-images/exp-prayer-walk-planner.png differ diff --git a/documentation/readme-images/exp-wall-of-people-groups.png b/documentation/readme-images/exp-wall-of-people-groups.png new file mode 100644 index 000000000..16a15dc8b Binary files /dev/null and b/documentation/readme-images/exp-wall-of-people-groups.png differ diff --git a/documentation/readme-images/prod-big-picture.png b/documentation/readme-images/prod-big-picture.png index fc5bdf44f..fb8735cd4 100644 Binary files a/documentation/readme-images/prod-big-picture.png and b/documentation/readme-images/prod-big-picture.png differ diff --git a/documentation/readme-images/prod-cities-internet-phone.png b/documentation/readme-images/prod-cities-internet-phone.png index 5c10e9d10..80eff4570 100644 Binary files a/documentation/readme-images/prod-cities-internet-phone.png and b/documentation/readme-images/prod-cities-internet-phone.png differ diff --git a/documentation/readme-images/prod-cities-internet.png b/documentation/readme-images/prod-cities-internet.png index 120934850..880aa825f 100644 Binary files a/documentation/readme-images/prod-cities-internet.png and b/documentation/readme-images/prod-cities-internet.png differ diff --git a/documentation/readme-images/prod-prayer-phone.png b/documentation/readme-images/prod-prayer-phone.png index 0fb2244ea..1155ccd93 100644 Binary files a/documentation/readme-images/prod-prayer-phone.png and b/documentation/readme-images/prod-prayer-phone.png differ diff --git a/documentation/readme-images/prod-regions-phone.png b/documentation/readme-images/prod-regions-phone.png index 9a7dea09f..dee0ac92f 100644 Binary files a/documentation/readme-images/prod-regions-phone.png and b/documentation/readme-images/prod-regions-phone.png differ diff --git a/documentation/readme-images/prod-regions.png b/documentation/readme-images/prod-regions.png index 875dd4f91..d5ccf955e 100644 Binary files a/documentation/readme-images/prod-regions.png and b/documentation/readme-images/prod-regions.png differ diff --git a/documentation/readme-images/prod-simple-research-phone.png b/documentation/readme-images/prod-simple-research-phone.png index 16653903b..acde70197 100644 Binary files a/documentation/readme-images/prod-simple-research-phone.png and b/documentation/readme-images/prod-simple-research-phone.png differ diff --git a/documentation/readme-images/prod-simple-research.png b/documentation/readme-images/prod-simple-research.png index 89e3045f1..4f29bbba9 100644 Binary files a/documentation/readme-images/prod-simple-research.png and b/documentation/readme-images/prod-simple-research.png differ diff --git a/documentation/readme-images/shot-bundles.json b/documentation/readme-images/shot-bundles.json index bea6d851a..c28074173 100644 --- a/documentation/readme-images/shot-bundles.json +++ b/documentation/readme-images/shot-bundles.json @@ -1,23 +1,23 @@ { "cap1-single-element": { "bundle": "all-people-groups", - "sha256": "0ede35b98a92ea2a56fa5b6302eb3dd827d9de4c636f82f663f9c0f4d8b24890" + "sha256": "c990929277d9aa5fd0d4ef78362f7b43e328a7c073ac6bf97aadfde5cb942de9" }, "cap2-adoption": { "bundle": "doxa-simple-map", - "sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20" + "sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90" }, "cap2-engagement": { "bundle": "doxa-simple-map", - "sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20" + "sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90" }, "cap2-prayer": { "bundle": "doxa-simple-map", - "sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20" + "sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90" }, "cap3-monolithic-research": { "bundle": "doxa-research-map", - "sha256": "3391075b32dd710b7f810e3097074a4d453c7720a6963e101ddec99ad4186d29" + "sha256": "b849fb71c4a96810d840d9f2dfc208c3e13648429e0ae1a1c5784693e6e5774f" }, "cap4-upg100-list": { "bundle": "my-upg-100-list", @@ -43,17 +43,25 @@ "bundle": "my-upg-100-list", "sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a" }, + "exp-prayer-walk-planner": { + "bundle": "prayer-walk-planner", + "sha256": "cee5f2806bd5cc8a7fa4c49db200df643d07cf303fd75ee739e606dd9ee93aee" + }, + "exp-wall-of-people-groups": { + "bundle": "wall-of-people-groups", + "sha256": "ba718e082f77f50d5775ebf43007385b7293f8099e2ca4d79e8ad1753ac4133f" + }, "prod-big-picture": { "bundle": "dashboard-plus-grid", - "sha256": "d3eb3468e6f42c3346885a665102dd10965a98a5e045f50b0d15c14f52a05e56" + "sha256": "e90739f622678db4c421c76855a5c39882aabd4ed8dbdd65cbe1385a45c67edc" }, "prod-cities-internet": { "bundle": "cities-internet", - "sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39" + "sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b" }, "prod-cities-internet-phone": { "bundle": "cities-internet", - "sha256": "1372e30e723497c6164c409b2e41e095f5f18f38caba606edb4ca58293640d39" + "sha256": "ad145b5a63e7d4b7468a54db53f2f71f397216956fec698d357ae5b841fa8e3b" }, "prod-gospel-resources": { "bundle": "gospel-resources", @@ -65,15 +73,15 @@ }, "prod-prayer-phone": { "bundle": "doxa-simple-map", - "sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20" + "sha256": "0e11603ed79a26b16456f23d582549830365a2c5095ba9ad7ad363c7d1ba9a90" }, "prod-regions": { "bundle": "regions-dashboard", - "sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e" + "sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5" }, "prod-regions-phone": { "bundle": "regions-dashboard", - "sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e" + "sha256": "8420509dde0ea1390e0d4ae8d49f80a303d17fa8a38c37755a82b07ab09567f5" }, "prod-research-gallery-phone": { "bundle": "doxa-maps-spa", @@ -81,10 +89,10 @@ }, "prod-simple-research": { "bundle": "research-map-plus-grid", - "sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7" + "sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f" }, "prod-simple-research-phone": { "bundle": "research-map-plus-grid", - "sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7" + "sha256": "9e081907bd697b5ac63531cedbdb34d3f47a09d1d5aee3634722128af1136d0f" } } diff --git a/internal/_serves-this-tree.mjs b/internal/_serves-this-tree.mjs index ebc13b188..34133c4eb 100644 --- a/internal/_serves-this-tree.mjs +++ b/internal/_serves-this-tree.mjs @@ -168,3 +168,42 @@ export function requirePathInThisCheckout(file, repo, label) { console.log(`${label}: note — ${file} is outside any checkout, so which build produced it cannot be checked.`) return { ok: true, why: 'outside any checkout' } } + + +/** + * The same guard for gates aimed at a VITE DEV SERVER, which has no built manifest to fingerprint. + * + * A dev server serves this repo's SOURCE, so identity is the source itself: ask it for a few of this + * checkout's library files with Vite's `?raw` query (the file's exact text, as an ES module string) + * and compare byte for byte. A dev server rooted in another worktree with different code answers + * differently, or 403s the path — either way it is NOT MEASURED (exit 75). One serving identical code + * is, by definition, measuring the same thing. Unreachable is left to the gate's own handling. + * Remote (non-loopback) targets are not checked, as in requireThisTree. + */ +const DEV_FINGERPRINT = ['library/layouts/VerticalTabsLayout.vue', 'library/composables/useMapInstance.js', 'library/composables/useShadowStyles.js'] +export async function requireDevServesThisCheckout(base, repo, label, files = DEV_FINGERPRINT) { + let origin + try { origin = new URL(String(base)).origin } catch { return { ok: true, why: 'not a URL' } } + if (!/^(localhost|127\.|0\.0\.0\.0$|\[?::1\]?$)/.test(new URL(origin).hostname)) return { ok: true, why: 'remote target' } + const refuse = (why) => { + console.log(`${label}: NOT MEASURED — the dev server at ${origin} is not serving THIS checkout's source (${why}).`) + console.log(' Start `bun x vite --port --strictPort` in this worktree and pass that URL.') + process.exit(75) + } + for (const rel of files) { + let local + try { local = readFileSync(resolve(repo, rel), 'utf8') } catch { continue } + const r = await fetch(`${origin}/${rel}?raw`, { signal: AbortSignal.timeout(8000) }).catch(() => null) + if (!r) return { ok: false, why: 'unreachable' } + if (!r.ok) refuse(`${rel} → HTTP ${r.status}`) + // A plain fetch (not a module import) gets the file's TEXT served directly; an import-style + // request gets it wrapped as `export default "…"`. Accept both shapes — the first version only + // parsed the wrapped one and refused this very checkout's own dev server (measured 2026-09-25). + const body = await r.text() + const m = body.match(/^export default ("(?:[^"\\]|\\.)*")/s) + let served = body + if (m) { try { served = JSON.parse(m[1]) } catch { served = null } } + if (served !== local) refuse(`${rel} differs`) + } + return { ok: true, why: 'same source' } +} diff --git a/internal/browser-slot.sh b/internal/browser-slot.sh index fc0b8a2e1..6215304ba 100755 --- a/internal/browser-slot.sh +++ b/internal/browser-slot.sh @@ -52,9 +52,16 @@ done # Fast path: any free slot, in a FIXED order so two processes can never take them in opposite # orders. -n means "do not wait": if this slot is busy we move on rather than queue behind it. +# +# ★ `{ exec 9>…; } 2>/dev/null`, NEVER `exec 9>… 2>/dev/null`. A bare exec applies EVERY redirection +# on its line to this shell for good, so the old form sent stderr to /dev/null for the rest of the +# script AND for the gate exec'd below. Measured 2026-09-25: every gate run through this wrapper lost +# its stderr — verify-map-paint exited 1 on two leaves with its FAIL lines (console.error) gone, and +# this script's own "not measuring" message vanished too. The braces scope the 2>/dev/null to the +# open alone; fd 9 still persists, which is the only thing the exec is for. i=1 while [ "$i" -le "$SLOTS" ]; do - exec 9>"$(slot_path "$i")" 2>/dev/null || true + { exec 9>"$(slot_path "$i")"; } 2>/dev/null || true if flock -n 9; then exec "$@" fi @@ -72,7 +79,7 @@ deadline=$(( $(date +%s) + WAIT_S )) while [ "$(date +%s)" -lt "$deadline" ]; do i=1 while [ "$i" -le "$SLOTS" ]; do - exec 9>"$(slot_path "$i")" 2>/dev/null || true + { exec 9>"$(slot_path "$i")"; } 2>/dev/null || true if flock -n 9; then exec "$@"; fi i=$((i + 1)) done diff --git a/internal/check-basemap-labels-localized.mjs b/internal/check-basemap-labels-localized.mjs new file mode 100644 index 000000000..fc7d887c3 --- /dev/null +++ b/internal/check-basemap-labels-localized.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * check-basemap-labels-localized — the BASEMAP's place names follow the reader's language, on BOTH + * engines (card BUILD-LOCALIZATION-SURVIVES-THE-PROVIDER-SWAP; the code under test is + * library/maps/localize-labels.js, 319064a1 / 4839f879). + * + * USAGE: node internal/check-basemap-labels-localized.mjs [--dir ] [--leaf bundle/profile] + * Serves THIS checkout's public/ on an OS-assigned port (so it cannot measure another tree), + * or --dir (used for the negative control: a tree whose bundle predates the fix). + * Exit 0 = both engines localize · 1 = an engine does not · 2 = could not measure. + * + * WHAT IS READ — the RENDERED label, not our own guess at an expression: + * Mapbox Standard keeps its labels in the IMPORTED basemap, invisible to a plain + * queryRenderedFeatures. The 'place-labels' featureset of the 'basemap' import returns + * each country label with the `name` the engine drew (setLanguage localizes it). + * MapLibre queryRenderedFeatures returns each symbol's EVALUATED text-field, i.e. the text drawn. + * WHAT IS ASSERTED, per engine, between ?lang=en and ?lang=ru on the same view (30°E 45°N, z3): + * Mapbox known countries flip: Germany→Германия, Ukraine→Украина, Turkey→Турция, Egypt→Египет + * (at least two of them seen in both runs). + * MapLibre for the country labels (identified by their name:en property), the ru run draws the + * feature's name:ru, and the en run does not. (English on MapLibre restores the style's + * ORIGINAL label, OSM's Latin name — 'Deutschland', not 'Germany' — so no English word + * is expected there; the flip to Cyrillic is the evidence.) + * + * NEGATIVE-TESTED 2026-09-25: the same leaf built from 319064a1's parent (before localize-labels + * existed), served with --dir, FAILS on both engines. + */ +import { chromium } from 'playwright' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +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 LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map') +const PAIRS = { Germany: 'Германия', Ukraine: 'Украина', Turkey: 'Турция', Egypt: 'Египет', Kazakhstan: 'Казахстан', Poland: 'Польша' } + +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' }) +await new Promise((r) => setTimeout(r, 900)) +const up = await fetch(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html`).then((r) => r.ok).catch(() => false) +if (!up) { srv.kill(); console.log(`BASEMAP-LABELS: NOT MEASURED — could not serve ${DIR}`); process.exit(2) } + +const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] }) +async function labels(provider, lang) { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }) + await page.goto(`http://127.0.0.1:${port}/doxa-maps/${LEAF}/index.html?lang=${lang}${provider === 'maplibre' ? '&provider=maplibre' : ''}`, { waitUntil: 'networkidle' }) + const FIND = `(() => { const f = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = f(e.shadowRoot); if (m) return m } } return null }; return f(document) })()` + let ok = false + for (let i = 0; i < 60 && !ok; i++) { await page.waitForTimeout(500); ok = await page.evaluate(`(() => { const m = ${FIND}; return !!(m && m.isStyleLoaded && m.isStyleLoaded()) })()`).catch(() => false) } + if (!ok) { await page.close(); return null } + await page.evaluate(`(() => { const m = ${FIND}; m.jumpTo({ center: [30, 45], zoom: 3 }) })()`) + await page.waitForTimeout(7000) + const out = await page.evaluate(`(() => { const m = ${FIND} + if (${JSON.stringify(provider)} === 'mapbox') { + let fs = []; try { fs = m.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } }) } catch (e) { return { error: e.message } } + return { names: fs.filter((f) => f.properties && f.properties.class === 'country').map((f) => f.properties.name) } + } + const text = (tf) => (tf && Array.isArray(tf.sections)) ? tf.sections.map((s) => s.text).join('') : (typeof tf === 'string' ? tf : '') + const feats = m.queryRenderedFeatures().filter((f) => f.layer && f.layer.type === 'symbol' && /country/.test(f.layer.id) && f.properties['name:en']) + return { countries: feats.map((f) => ({ en: f.properties['name:en'], ru: f.properties['name:ru'] || '', drawn: text(f.layer.layout && f.layer.layout['text-field']) })) } + })()`) + await page.close() + return out +} + +const results = [] +for (const provider of ['mapbox', 'maplibre']) { + const en = await labels(provider, 'en'), ru = await labels(provider, 'ru') + if (!en || !ru || en.error || ru.error) { results.push({ provider, measured: false, why: (en && en.error) || (ru && ru.error) || 'map never finished its style' }); continue } + if (provider === 'mapbox') { + const seen = Object.keys(PAIRS).filter((k) => en.names.includes(k)) + const flipped = seen.filter((k) => ru.names.includes(PAIRS[k])) + // MEASURED = the engine drew enough country labels to judge. Not "saw two known English names": + // without localization Mapbox Standard draws a different default form, and that absence IS the + // defect — the first version of this line reported the pre-fix build as NOT MEASURED instead of + // FAIL (68 country labels drawn, none English), which would let the gate pass by abstaining. + results.push({ provider, measured: en.names.length >= 5 && ru.names.length >= 5, ok: seen.length >= 2 && flipped.length === seen.length, + detail: `en saw ${seen.join(', ') || '(none)'}; ru drew ${flipped.map((k) => PAIRS[k]).join(', ') || '(none of them)'} (${en.names.length}/${ru.names.length} country labels)` }) + } else { + const byEn = (arr) => new Map(arr.map((c) => [c.en, c])) + const E = byEn(en.countries), R = byEn(ru.countries) + const common = [...R.keys()].filter((k) => E.has(k) && R.get(k).ru) + const localized = common.filter((k) => R.get(k).drawn === R.get(k).ru && E.get(k).drawn !== R.get(k).ru) + results.push({ provider, measured: common.length >= 2, ok: common.length >= 2 && localized.length === common.length, + detail: `${localized.length}/${common.length} countries drawn in Russian under ru and not under en` + (common[0] ? ` — e.g. ${common[0]}: en "${E.get(common[0]).drawn}" → ru "${R.get(common[0]).drawn}"` : '') }) + } +} +await browser.close(); srv.kill() + +for (const r of results) console.log(` ${r.provider.padEnd(9)} ${!r.measured ? 'NOT MEASURED' : r.ok ? 'ok ' : 'FAIL'} ${r.detail || r.why}`) +if (results.some((r) => r.measured && !r.ok)) { console.log('BASEMAP-LABELS: FAIL — a basemap does not follow the reader\'s language on every engine.'); process.exit(1) } +if (results.some((r) => !r.measured)) { console.log('BASEMAP-LABELS: NOT MEASURED — an engine gave too few labels to judge.'); process.exit(2) } +console.log(`BASEMAP-LABELS: PASS — ${LEAF}'s basemap place names follow the reader's language on Mapbox AND MapLibre.`) diff --git a/internal/check-experimental-shelf.mjs b/internal/check-experimental-shelf.mjs new file mode 100644 index 000000000..e7221dd99 --- /dev/null +++ b/internal/check-experimental-shelf.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * check-experimental-shelf — "all experimental plugins working", as a measurement. + * + * THE SHELF is derived, never listed: every bundle in the built manifest that the Research Gallery + * would discover into its Experimental folder, i.e. not a curated tab and not in NOT_EXPERIMENTAL. + * Both sets are read from plugin-designs/doxa-maps-spa/doxa-maps-spa.vue, so a bundle that joins or + * leaves the shelf changes what this gate measures without an edit here. + * + * PER BUNDLE, PER ENGINE (mapbox, maplibre), on TWO surfaces: its own leaf page (A) and its tab + * inside the Research Gallery (D). A surface passes when: + * - the element mounted with content (its shadow tree has real nodes), + * - 0 page errors and 0 failed requests (4xx/5xx; Mapbox telemetry and favicons excluded), + * - no loading text is left on screen (Loading / Adding / Computing), + * - and, for every MAP on the page: its 'load' fired, and it DRAWS its own data, meaning at + * least one rendered feature on a layer fed by a GeoJSON source. That is the lesson from the + * PPLR check: 'load' alone is not loaded, and an empty GeoJSON source still counts as a + * source. + * Maps are found without touching any bundle: an init script wraps the engine's Map class as the + * engine script defines it, so every map the page constructs is recorded. + * + * IDENTITY: the gate serves THIS checkout's public/ itself, on a port the OS hands out, so it + * cannot grade another worktree's tree (the fixed-port trap internal/_serves-this-tree.mjs names). + * + * SELF-TEST (--self-test, and always first unless --no-self-test): the first shelf bundle's script + * is replaced in flight by one that throws. That bundle MUST fail on both surfaces; if it does not, + * the gate is blind and exits 3 before grading anything. + * + * Usage: node internal/check-experimental-shelf.mjs [--engines mapbox,maplibre] [--only a,b] + * [--no-self-test] [--self-test] + * Exit: 0 all pass · 1 a bundle failed · 3 self-test could not see a broken bundle · 2 setup. + * Run browser gates through internal/browser-slot.sh. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const argv = process.argv.slice(2) +const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d } +const ENGINES = arg('--engines', 'mapbox,maplibre').split(',') +const ONLY = arg('--only', '') ? arg('--only', '').split(',') : null +const SELF_TEST = !argv.includes('--no-self-test') +const WAIT_MS = Number(process.env.SHELF_WAIT_MS || 75000) + +const { chromium } = await import(resolve(ROOT, 'node_modules/playwright/index.mjs')) +const manifest = JSON.parse(readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8')) +const spa = readFileSync(resolve(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue'), 'utf8') +const cfg = JSON.parse(readFileSync(resolve(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')) +const TK = process.env.MAPBOX_TOKEN || cfg.token || '' + +// ── the shelf, derived from the Gallery's own rules ─────────────────────────── +const notExp = new Set((spa.match(/const NOT_EXPERIMENTAL = new Set\(\[([\s\S]*?)\]\)/) || [, ''])[1].match(/'([a-z0-9-]+)'/g)?.map(s => s.slice(1, -1)) || []) +const curated = new Set([...spa.matchAll(/bundle:\s*'([a-z0-9-]+)'/g)].map(m => m[1])) +if (!notExp.size || !curated.size) { console.error('check-experimental-shelf: could not read NOT_EXPERIMENTAL / curated tabs from doxa-maps-spa.vue'); process.exit(2) } +const shelf = manifest.bundles + .filter(b => !notExp.has(b.bundle) && !curated.has(b.bundle) && (b.profiles || []).length) + .filter(b => !ONLY || ONLY.includes(b.bundle)) + .map(b => { + // the entry profile the Gallery opens: a map profile, else the one named like the bundle, else the first + const p = b.profiles.find(x => x.isMap) || b.profiles.find(x => x.profile === b.bundle) || b.profiles[0] + return { bundle: b.bundle, profile: p.profile, isMap: !!p.isMap, tag: p.element || (b.elements || [])[0] || b.bundle } + }) +if (!shelf.length) { console.error('check-experimental-shelf: the shelf is empty'); process.exit(2) } + +// ── SEEDS: bundles whose data is the USER'S, so a fresh browser correctly shows none ───────── +// Seeded before load, then required to be DRAWN, which is stronger than exempting them. Printed on +// every run with the reason, so a seed can never quietly hide a regression. +const SEED = { + 'prayer-walk-planner': { + why: 'walk zones are user-drawn and kept in localStorage; a fresh browser has none, so one known zone is seeded and must be drawn', + keyPrefix: 'prayer-walk-planner:v1:', + value: JSON.stringify([{ id: 'shelf-seed', title: 'Shelf check zone', coords: [[0, 10], [20, 10], [20, 25], [0, 25]] }]), + }, +} + +// ── our own server, on a port nobody else can be holding ────────────────────── +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', 'public'], { cwd: ROOT, stdio: 'ignore' }) +const reap = () => { try { srv.kill() } catch (_) {} } +process.on('exit', reap); for (const s of ['SIGINT', 'SIGTERM']) process.on(s, () => { reap(); process.exit(130) }) +const BASE = `http://127.0.0.1:${port}/doxa-maps` +for (let i = 0; i < 40; i++) { try { if ((await fetch(BASE + '/manifest.json')).ok) break } catch (_) {} await new Promise(r => setTimeout(r, 250)) } + +// ── every Map the page builds, recorded without touching the bundle ─────────── +const RECORD_MAPS = () => { + window.__shelfMaps = [] + for (const name of ['mapboxgl', 'maplibregl']) { + let v + Object.defineProperty(window, name, { + configurable: true, + get() { return v }, + set(val) { + if (val && val.Map && !val.Map.__shelfWrapped) { + const Orig = val.Map + class Recorded extends Orig { constructor(...a) { super(...a); window.__shelfMaps.push(this); this.once('load', () => { this.__shelfLoaded = true }) } } + Recorded.__shelfWrapped = true + try { val.Map = Recorded } catch (_) { try { Object.defineProperty(val, 'Map', { value: Recorded }) } catch (_) {} } + } + v = val + }, + }) + } +} +const PROBE = (tag) => `(() => { + const acc = []; const w = (r) => { for (const el of r.querySelectorAll('*')) { acc.push(el); if (el.shadowRoot) w(el.shadowRoot) } }; w(document) + // CONTENT is the plugin's own subtree, not the page: a small card on a bare leaf and a card inside the + // Gallery's chrome must be judged the same way. + const host = acc.find(e => e.tagName === ${JSON.stringify('__TAG__')}.toUpperCase()) + let own = 0, sized = false + if (host) { const inner = []; const w2 = (r) => { for (const el of r.querySelectorAll('*')) { inner.push(el); if (el.shadowRoot) w2(el.shadowRoot) } }; w2(host.shadowRoot || host); own = inner.length + const hr = host.getBoundingClientRect(); sized = hr.width > 100 && hr.height > 100 } + const busy = acc.filter(e => e.children.length === 0 && /Loading|Adding|Computing/i.test(e.textContent || '') && e.getBoundingClientRect().height > 0).length + const maps = (window.__shelfMaps || []).filter(m => { try { return m.getContainer() && m.getContainer().isConnected } catch (_) { return false } }).map(m => { + let drawn = 0, geo = 0 + try { + const st = m.getStyle(); const srcs = st.sources || {} + const geoIds = Object.keys(srcs).filter(k => srcs[k].type === 'geojson'); geo = geoIds.length + const layers = (st.layers || []).filter(l => geoIds.includes(l.source)).map(l => l.id) + if (layers.length) drawn = m.queryRenderedFeatures({ layers }).length + } catch (_) {} + return { loaded: !!(m.__shelfLoaded || (m.isStyleLoaded && m.isStyleLoaded())), geo, drawn } + }) + return { own, sized, host: !!host, busy, maps } +})()`.replace('__TAG__', tag) + +const b = await chromium.launch({ headless: true, args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--enable-webgl', '--no-sandbox'] }) + +async function measure(url, s, poisonBundle) { + const p = await b.newPage({ viewport: { width: 1280, height: 800 } }) + await p.addInitScript(RECORD_MAPS) + const seed = SEED[s.bundle] + if (seed) await p.addInitScript(({ keyPrefix, value }) => { + const get = Storage.prototype.getItem + Storage.prototype.getItem = function (k) { const v = get.call(this, k); return (v == null && String(k).startsWith(keyPrefix)) ? value : v } + }, seed) + if (poisonBundle) await p.route(new RegExp(`/${poisonBundle}/${poisonBundle}\\.js(\\?|$)`), r => r.fulfill({ status: 200, contentType: 'text/javascript', body: 'throw new Error("shelf self-test: deliberately broken bundle")' })) + const errs = [], fails = [] + p.on('pageerror', e => errs.push(e.message.slice(0, 140))) + p.on('response', r => { if (r.status() >= 400 && !/events\.mapbox\.com|favicon/.test(r.url())) fails.push(r.status() + ' ' + r.url().replace(/\?.*/, '').slice(-80)) }) + await p.goto(url) + let st = null + const t0 = Date.now() + while (Date.now() - t0 < WAIT_MS) { + await p.waitForTimeout(2500) + st = await p.evaluate(PROBE(s.tag)) + const mapsReady = st.maps.every(m => m.loaded && m.drawn > 0) + if (st.own > 8 && st.sized && st.busy === 0 && mapsReady && Date.now() - t0 > 6000) break + } + await p.close() + const why = [] + if (!st || !st.host) why.push(`<${s.tag}> never appeared`) + else if (st.own <= 8 || !st.sized) why.push(`<${s.tag}> mounted empty (${st.own} node(s)${st.sized ? '' : ', not sized'})`) + if (st && st.busy) why.push(`${st.busy} loading label(s) left`) + if (st) st.maps.forEach((m, i) => { if (!m.loaded) why.push(`map ${i + 1}: 'load' never fired`); else if (!m.drawn) why.push(`map ${i + 1}: no own data drawn (${m.geo} geojson source(s))`) }) + if (errs.length) why.push(`page error: ${errs[0]}`) + if (fails.length) why.push(`failed request: ${fails[0]}`) + return { pass: !why.length, why, maps: st ? st.maps.length : 0 } +} + +const leaf = (s, eng) => `${BASE}/${s.bundle}/${s.profile}/index.html?provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '') +const gallery = (s, eng) => `${BASE}/doxa-maps-spa/doxa-maps-spa/index.html?tab=${s.bundle}&provider=${eng}` + (eng === 'mapbox' ? `&tk=${TK}` : '') + +let exit = 0 +try { + if (SELF_TEST) { + const s = shelf[0] + const a = await measure(leaf(s, 'maplibre'), s, s.bundle), d = await measure(gallery(s, 'maplibre'), s, s.bundle) + if (a.pass || d.pass) { console.error(`SELF-TEST FAILED: a deliberately broken ${s.bundle} still passed (${a.pass ? 'leaf' : ''} ${d.pass ? 'gallery' : ''}). This gate cannot see a broken bundle.`); exit = 3 } + else console.log(`self-test: a deliberately broken ${s.bundle} fails as it must (leaf: ${a.why[0]}; gallery: ${d.why[0]})`) + } + if (exit !== 3) { + for (const [k, v] of Object.entries(SEED)) if (shelf.some(s => s.bundle === k)) console.log(`SEEDED ${k}: ${v.why}`) + console.log(`EXPERIMENTAL SHELF — ${shelf.length} bundle(s): ${shelf.map(s => s.bundle).join(', ')} · engines: ${ENGINES.join(', ')}`) + for (const s of shelf) for (const eng of ENGINES) for (const [surf, url] of [['A leaf', leaf(s, eng)], ['D gallery', gallery(s, eng)]]) { + const r = await measure(url, s) + if (!r.pass) exit = 1 + console.log(`${r.pass ? 'PASS' : 'FAIL'} ${s.bundle.padEnd(24)} ${eng.padEnd(9)} ${surf.padEnd(10)} ${r.maps ? r.maps + ' map(s)' : 'no map'}${r.pass ? '' : ' — ' + r.why.join('; ')}`) + } + console.log(exit ? 'EXPERIMENTAL-SHELF: FAIL' : `EXPERIMENTAL-SHELF: PASS — every experimental plugin loads on its own page and in the Gallery, under ${ENGINES.join(' and ')}`) + } +} finally { await b.close(); reap() } +process.exit(exit) diff --git a/internal/check-gospel-english-leaks.mjs b/internal/check-gospel-english-leaks.mjs new file mode 100644 index 000000000..f8a4a4e13 --- /dev/null +++ b/internal/check-gospel-english-leaks.mjs @@ -0,0 +1,114 @@ +/** + * check-gospel-english-leaks.mjs — a translated key must not render in English. + * + * THE METHOD, from rega by way of dx-shepherd. A string is a LEAK when the catalogue has a + * DIFFERENT value for the locale and the page still shows the English one. That is a render + * or wiring fault, and it is a different thing from an untranslated string, which is a + * catalogue gap. Only the first is this check's business. + * + * WHAT IT DOES NOT COUNT, so the number means something: + * - third-party strings, e.g. the Mapbox and geoBoundaries attributions, which are licence + * obligations and must never be translated; + * - DATA, the people-group and place names that come from the publishers; + * - PLACEHOLDER-ONLY strings; + * - the explanatory PROSE this bundle deliberately keeps in English, which is declared by + * being absent from the catalogue entirely — if there is no translated value, there is + * nothing to leak. + * + * SCOPE: THE LEAF, AND THAT BOUNDARY IS THE POINT. Six keys were reported leaking in the + * gallery. Measured here, the standalone leaf is clean in ar, zh, ru and hi — and the + * gallery's iframe src is built without a lang parameter, so the embedded leaf never learns + * the locale and correctly falls back to English. That is an embedder defect, not a bundle + * one, and a check that conflated them would have sent someone to translate strings that are + * already translated. + */ +import { chromium } from 'playwright' +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { serveReaped } from './_serve-reaped.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = resolve(HERE, '..') +const PORT = Number(process.env.PORT || 5399) +const BASE = process.env.BASE_URL || ('http://127.0.0.1:' + PORT) +const LOCALES = (process.env.LOCALES || 'ar,zh,ru,hi').split(',') +const LEAVES = ['resources-map', 'resources-list', 'data-tables', 'charts'] + +let srv = null +if (!process.env.BASE_URL) srv = serveReaped(resolve(REPO, 'public'), PORT) + +const cat = (lc) => JSON.parse(readFileSync(resolve(REPO, 'library/i18n/locales/' + lc + '/gospel.json'), 'utf8')) +const flat = (o, p = []) => Object.entries(o).flatMap(([k, v]) => + k.startsWith('_') ? [] : (v && typeof v === 'object' ? flat(v, [...p, k]) : [[[...p, k].join('.'), String(v)]])) + +let browser +try { + browser = await chromium.launch() + const en = Object.fromEntries(flat(cat('en'))) + const leaks = [] + let compared = 0 + + for (const leaf of LEAVES) { + for (const lc of LOCALES) { + const loc = Object.fromEntries(flat(cat(lc))) + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) + await page.goto(BASE + '/doxa-maps/gospel-resources/' + leaf + '/index.html?lang=' + lc, + { waitUntil: 'networkidle', timeout: 90000 }) + await page.waitForTimeout(8000) + /* ═══ READ ONLY THE CHROME, NOT THE DECLARED-ENGLISH PROSE ═══════════════════════ + * The first version read the whole shadow root and reported 113 leaks, nearly all + * false: "JESUS Film" and "Scripture" appear inside the explanatory paragraphs this + * bundle deliberately keeps in English, and three-letter catalogue entries like "for" + * match inside ordinary English sentences. Both are noise, and a gate that cries 113 + * when 0 are real is worse than none — someone would have gone and "fixed" strings + * that were already correct. + * Every English prose block in this bundle declares itself with dir="ltr". So the + * chrome is everything OUTSIDE those subtrees, which is exactly the population a + * translated key is supposed to reach. */ + const text = await page.evaluate(() => { + const sr = document.querySelector('gospel-resources-map')?.shadowRoot + if (!sr) return '' + /* A ShadowRoot is not clonable, so walk it and skip declared-English subtrees. */ + const parts = [] + const walk = (node) => { + for (const child of node.childNodes) { + if (child.nodeType === 3) { parts.push(child.textContent); continue } + if (child.nodeType !== 1) continue + if (child.getAttribute && child.getAttribute('dir') === 'ltr') continue + if (child.tagName === 'STYLE' || child.tagName === 'SCRIPT') continue + walk(child) + } + } + walk(sr) + return parts.join(' ').replace(/\s+/g, ' ') + }) + await page.close() + if (!text) { leaks.push(leaf + ' ' + lc + ': rendered nothing at all'); continue } + for (const [key, e] of Object.entries(en)) { + const t = loc[key] + /* Short entries match inside unrelated English words; six characters is the floor + at which a match is about the key rather than about the alphabet. */ + if (!t || t === e || e.length < 6) continue + compared++ + if (text.includes(e) && !text.includes(t)) leaks.push(leaf + ' ' + lc + ' ' + key + ': shows English "' + e.slice(0, 40) + '"') + } + } + } + + console.log(' compared ' + compared + ' key/locale pair(s) across ' + LEAVES.length + ' leaves and ' + LOCALES.length + ' locales') + if (leaks.length) { + /* exitCode, NOT process.exit(). process.exit() terminates before pending stdout/stderr + writes flush, and this gate proved it on itself: its first real run returned 1 with + every explanatory line missing, so it failed without saying why. A gate that cannot + explain its own failure is barely better than no gate. */ + console.error('GOSPEL-ENGLISH-LEAKS: FAIL — ' + leaks.length + ' translated key(s) rendering in English:') + for (const l of leaks.slice(0, 12)) console.error(' ' + l) + process.exitCode = 1 + } else { + console.log('GOSPEL-ENGLISH-LEAKS: PASS — every key with a translation renders in it.') + } +} finally { + await browser?.close() + srv?.stop() +} diff --git a/internal/check-host-locale-contract.mjs b/internal/check-host-locale-contract.mjs new file mode 100644 index 000000000..92ff881ac --- /dev/null +++ b/internal/check-host-locale-contract.mjs @@ -0,0 +1,151 @@ +#!/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///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 , 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 ] [--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. + * 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' + +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, 'doxa-maps', '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, 'doxa-maps', 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}/doxa-maps/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}/doxa-maps/${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).`) diff --git a/internal/check-poster-wall-localised.mjs b/internal/check-poster-wall-localised.mjs new file mode 100644 index 000000000..700acc49e --- /dev/null +++ b/internal/check-poster-wall-localised.mjs @@ -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.`) diff --git a/internal/check-prayer-walk-planner.mjs b/internal/check-prayer-walk-planner.mjs index 08d4e59ba..bc6a9e8c4 100644 --- a/internal/check-prayer-walk-planner.mjs +++ b/internal/check-prayer-walk-planner.mjs @@ -13,6 +13,8 @@ * each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a * human can READ it: streets, building footprints, the zone outline on top. * 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page. + * 5. FULL SCREEN — from a 520px embed, the whole planner takes the viewport. + * 6. PRINT RESOLUTION — the printed map measures >= 300 DPI at the sheet's printable width. * * Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared * browser locks, like every browser gate. OUT= chooses where the print PNG is written. @@ -69,6 +71,17 @@ try { console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`) await p.close() } + // 1b. FULL SCREEN from inside a small embed: the whole planner (toolbar + map) takes the viewport. + { + const p = await b.newPage({ viewport: { width: 1280, height: 800 } }) + const fsHost = createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`

host

`) }) + const FP = await new Promise(ok => fsHost.listen(0, 'localhost', () => ok(fsHost.address().port))) + await p.goto(`http://localhost:${FP}/`); await p.waitForTimeout(9000) + const fs = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const btn = el.shadowRoot.querySelector('.pwp-map-controls button'); if (!btn) return null; const before = Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height); btn.click(); await new Promise(r => setTimeout(r, 2500)); const r = el.getBoundingClientRect(); return { before, after: Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height), host: [Math.round(r.width), Math.round(r.height)] } })()`) + const fok = !!fs && fs.host[0] >= 1270 && fs.host[1] >= 790 && fs.after > fs.before + console.log(`${fok ? 'PASS' : 'FAIL'} full screen from a 520px embed: ${JSON.stringify(fs)}`); results.push({ check: 'full screen', ok: fok }) + await p.close(); fsHost.close() + } // 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es) const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = [] p.on('pageerror', e => errs.push(String(e).slice(0, 140))) @@ -86,6 +99,24 @@ try { console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st }) } else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) } console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length }) + + // 5. PRINT RESOLUTION, MEASURED: the real printed sheet laid out as PRINT at the printable width + // (8.5in - 2 x 12mm @page margins = 725 CSS px; emulateMedia alone does NOT apply @page margins, + // which is how a first measurement read the frame as 8.48in wide). DPI = natural px / printed in. + // The Driver: "a print that stays sharp when zoomed" — at >= 300 DPI a zoomed PDF shows real + // street names and house outlines, not an upscaled screen grab. + await p.evaluate(() => { new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes) if (n.tagName === 'IFRAME') { try { n.contentWindow.print = () => {} } catch (_) {} window.__pwpFrame = n } }).observe(document.body, { childList: true }) }) + await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const z = el.listPolygons().find(x => x.title === 'Balat'); el.selectPolygon(z.id, true); await el.print() })()`) + await p.waitForTimeout(3000) + const sheet = await p.evaluate(() => window.__pwpFrame ? window.__pwpFrame.contentDocument.documentElement.outerHTML : '') + if (sheet) { + const q = await b.newPage(); await q.emulateMedia({ media: 'print' }); await q.setViewportSize({ width: 725, height: 965 }) + await q.setContent('' + sheet.replace(/