289 lines
17 KiB
JavaScript
289 lines
17 KiB
JavaScript
// check-packet-browser — the packet the READER gets, not the one Node writes.
|
||
//
|
||
// WHY A SECOND PACKET GATE. check-packet-render verifies a packet FILE produced by
|
||
// the build-time generator. This one verifies the BROWSER path: the legend's
|
||
// Export > Prayer packet action imports library/packet/packet-render.js, fetches
|
||
// live records, and prints in-place. Both callers share that one renderer, and this
|
||
// gate is what proves the shared module actually runs in a browser — a module can
|
||
// be perfectly correct under Node and still fail in a tab (a bare `process.env`
|
||
// reference, a Node-only import, a JSON import assertion).
|
||
//
|
||
// It also covers I18N, which is a product requirement rather than a nicety: pass a
|
||
// language and the gate reports which headings came back translated. Fallback is
|
||
// PER KEY, so a half-finished translation must render its finished keys and leave
|
||
// the rest in English — never blank.
|
||
//
|
||
// Usage: node internal/check-packet-browser.mjs [lang]
|
||
// PACKET_ORIGIN http origin serving the repo root (default http://127.0.0.1:4898)
|
||
// PACKET_API records API (default https://pray.doxa.life)
|
||
// PACKET_ISO3 country to build a test packet for (default LAO)
|
||
// Needs a static server over the repo root, e.g.: python3 -m http.server 4898
|
||
|
||
import { chromium } from 'playwright'
|
||
import { readFileSync } from 'node:fs'
|
||
import { resolve, dirname } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
const b = await chromium.launch()
|
||
const pg = await b.newPage({ viewport:{width:1500,height:1941} })
|
||
const errs=[]; pg.on('pageerror',e=>errs.push(String(e).slice(0,140)))
|
||
|
||
let ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
|
||
let OWN_SERVER = null
|
||
const API = process.env.PACKET_API || 'https://pray.doxa.life'
|
||
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
|
||
let RENDER = `${ORIGIN}/library/packet/packet-render.js`
|
||
let STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
|
||
// The SAME catalog, read from disk for the assertions below. STRINGS is the URL the
|
||
// browser fetches; this is the local path, so the gate compares against the file the
|
||
// page actually loaded rather than a second copy.
|
||
const STRINGS_PATH = resolve(dirname(fileURLToPath(import.meta.url)),
|
||
'..', 'library/scripts/posters/packet-strings.json')
|
||
// The POSTER zone whose map page 1 shows. ISO3 selects the people groups; the poster is
|
||
// keyed by ZONE id, and they are not the same string (ISO3 'LAO' vs zone 'laos').
|
||
const ZONE = process.env.PACKET_ZONE || 'laos'
|
||
const LANG = process.argv[2] || 'en'
|
||
|
||
// ⚠ SKIP CLEANLY WHEN THE PRECONDITION IS ABSENT. This needs a static server over the
|
||
// REPO ROOT and a reachable records API; without either it threw an unhandled navigation
|
||
// error and dumped a stack trace, which is why it could never join a wave — a gate that
|
||
// goes red with a stack on a machine that simply has no server running only teaches
|
||
// people to disable it. Absence of a server is not a product failure; say so and exit 0.
|
||
try {
|
||
await pg.goto(ORIGIN + '/', { timeout: 8000 })
|
||
} catch {
|
||
await b.close()
|
||
console.log(`PACKET-BROWSER: SKIP — nothing served at ${ORIGIN}.`)
|
||
console.log(' This gate exercises the BROWSER export path, so it needs the repo root served:')
|
||
console.log(' python3 -m http.server 4898 (or set PACKET_ORIGIN)')
|
||
process.exit(0)
|
||
}
|
||
|
||
// ⚠ AND IT MUST BE THIS CHECKOUT'S ROOT. Several worktrees of this repo run on one machine,
|
||
// so a port that answers may belong to another one — measured 2026-09-24 on :4173, where a
|
||
// browser gate reported on a build that was not on disk here. This gate imports the shared
|
||
// renderer FROM THE SERVER, so the wrong origin means it verifies someone else's renderer
|
||
// and says nothing about this change. Precondition, not a product failure: SKIP and name it.
|
||
{
|
||
const { servesThisTree, serveThisTree } = await import('./_serves-this-tree.mjs')
|
||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const tree = await servesThisTree(ORIGIN + '/public', repo)
|
||
if (/DIFFERENT build/.test(tree.why)) {
|
||
// Serve this checkout rather than skip: the default port belongs to whichever worktree
|
||
// started first on this box, and a gate that skips every wave stops being a gate.
|
||
OWN_SERVER = await serveThisTree(repo)
|
||
if (!OWN_SERVER) {
|
||
await b.close()
|
||
console.log('PACKET-BROWSER: SKIP — not this checkout, and I could not start my own server.')
|
||
console.log(` ${tree.why}`)
|
||
console.log(' Serve THIS repo root and re-run (PACKET_ORIGIN=http://127.0.0.1:PORT).')
|
||
process.exit(0)
|
||
}
|
||
console.log(` · ${tree.why}`)
|
||
console.log(` · serving this checkout on ${OWN_SERVER.origin} instead`)
|
||
ORIGIN = OWN_SERVER.origin
|
||
RENDER = `${ORIGIN}/library/packet/packet-render.js`
|
||
STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
|
||
// The page is already sitting on the OTHER origin, and a dynamic import across origins is
|
||
// refused (no CORS on a plain static server) — measured: "Failed to fetch dynamically
|
||
// imported module". Re-navigate so the renderer is same-origin with the page importing it.
|
||
await pg.goto(ORIGIN + '/', { timeout: 8000 })
|
||
}
|
||
}
|
||
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE}) => {
|
||
const [{ createPacketRenderer }, strings, norm] = await Promise.all([
|
||
import(RENDER),
|
||
fetch(STRINGS).then(r=>r.json()),
|
||
import(`${ORIGIN}/plugin-designs/my-upg-100-list/data/normalize.js`),
|
||
])
|
||
const R = createPacketRenderer({ strings, lang: LANG })
|
||
const resp = await fetch(`${API}/api/people-groups/list?fields=slug,name,country_code,population,religion,image_url&lang=en`, {cache:'no-store'})
|
||
// Use the SAME normalizer the app uses — the API returns { posts, total }, and
|
||
// hand-rolling the extraction is how a probe ends up testing something the
|
||
// product never does.
|
||
const recs = norm.normalizeRecords(norm.extractRecords(await resp.json()))
|
||
const groups = recs.filter(r=>String(r.country_code)===ISO3)
|
||
.sort((a,b)=>String(a.name).localeCompare(String(b.name), LANG))
|
||
const photos = new Map(groups.filter(g=>g.image_url).map(g=>[g.slug,g.image_url]))
|
||
// ⚠ FETCH THE MAP THE WAY THE PRODUCT DOES. This passed '' for mapSvg, so the
|
||
// browser packet had NO page-1 map and any geography assertion would have been
|
||
// measuring the HARNESS's shortcut rather than the product. The real export path
|
||
// (upg-100-list.vue:packetMapSvg) fetches posters/<zone>.html and lifts its <svg>,
|
||
// returning '' on failure — a legitimate degraded state, the browser twin of the
|
||
// build-time `nomap`. Exercise that code shape here instead of skipping it.
|
||
let mapSvg = ''
|
||
try {
|
||
const html = await (await fetch(`${ORIGIN}/library/scripts/posters/out/posters/${ZONE}.html`)).text()
|
||
const m = html.match(/<svg[\s\S]*?<\/svg>/)
|
||
if (m) {
|
||
mapSvg = `<style>${R.mapCss(html)}</style>`
|
||
+ `<div style="position:absolute;left:0;top:250px;width:${R.W}px;`
|
||
+ `transform:scale(0.62);transform-origin:top center">${m[0]}</div>`
|
||
}
|
||
} catch { /* no poster generated — page 1 renders without its map, as in the product */ }
|
||
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, mapSvg)
|
||
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3, ZONE})
|
||
|
||
await pg.setContent(html, { waitUntil:'load' })
|
||
await pg.waitForTimeout(3000)
|
||
// ⚠ READ THE HEADINGS BY STRUCTURE, NOT BY ENGLISH WORDS. These were greps for
|
||
// /Wheel/ and /Prayer Points|Puntos/, so they returned EMPTY for every language
|
||
// whose heading contains neither — Spanish reported a blank wheel heading and
|
||
// Hindi blanked BOTH, while the catalog holds 'Rueda de Oración' and
|
||
// 'प्रार्थना चक्र'. Nothing asserted them, so the gate PASSED on what looked
|
||
// exactly like a product i18n failure. It was the harness. The renderer marks
|
||
// these two blocks, so select them and compare to the catalog below.
|
||
const r = await pg.evaluate(()=>({
|
||
sheets: document.querySelectorAll('.sheet').length,
|
||
cards: document.querySelectorAll('.card').length,
|
||
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
|
||
// The passage block, not the English proper noun 'Gadarenes': that word would
|
||
// vanish the moment point4_passage is translated, so the assertion would go RED
|
||
// on a CORRECTLY translated packet (the same trap already fixed in
|
||
// check-packet-render).
|
||
mark5Len: (document.querySelector('.p4-passage')||{textContent:''}).textContent.trim().length,
|
||
// ★ THE SAME PACKET, EXPORTED THROUGH A DIFFERENT PATH, WAS HELD TO A WEAKER STANDARD.
|
||
// check-packet-render gained these over several ticks — the 12 points, the wheel's 12
|
||
// LABELS (not just its 12 segments), page 1's geography — each found by mutation, each
|
||
// catching a real "looks fine, is empty" shape. None of them existed here, and THIS is
|
||
// the packet a reader actually gets: the legend's Export > Prayer packet action. The
|
||
// build-time file is the one nobody clicks.
|
||
// Counted the same way as the build-time gate, deliberately, so the two cannot disagree.
|
||
points: (() => {
|
||
const t = document.querySelector('.points-title')
|
||
const host = t && t.parentElement && t.parentElement.parentElement
|
||
if (!host) return -1
|
||
return (host.innerText || '').split('\n').filter((l) => /^\s*\d{1,2}\.\s/.test(l)).length
|
||
})(),
|
||
// A segment without its word is a colour. `order` renders 12 from 12 keys, but `praise`
|
||
// and `praise_end` collapse to one WORD in English — so count ELEMENTS, never distinct
|
||
// words, or the expected number changes per language.
|
||
wheelLabels: (() => {
|
||
const t = document.querySelector('.wheel-title')
|
||
const sheet = t && t.closest('.sheet')
|
||
const svg = sheet && sheet.querySelector('svg')
|
||
if (!svg) return -1
|
||
return [...svg.querySelectorAll('text')]
|
||
.map((e) => (e.textContent || '').trim())
|
||
.filter((s) => s && !/^\d+$/.test(s) && !/^\d[\s\S]*×\s*12$/.test(s)).length
|
||
})(),
|
||
// Page 1 is the map one-pager. Palette-resolves and not-black are BOTH satisfied by a
|
||
// map with zero country shapes, so count the coastlines: paths whose `d` is long enough
|
||
// to be geography rather than an icon.
|
||
mapGeoPaths: (() => {
|
||
const sheet0 = document.querySelector('.sheet')
|
||
const svg = sheet0 && sheet0.querySelector('svg')
|
||
if (!svg) return -1
|
||
return [...svg.querySelectorAll('path')].filter((e) => (e.getAttribute('d') || '').length > 200).length
|
||
})(),
|
||
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
|
||
firstCard: (document.querySelector('.card')||{}).innerText||'',
|
||
wheelTitle: (document.querySelector('.wheel-title')||{textContent:''}).textContent.trim(),
|
||
pointsTitle: (document.querySelector('.points-title')||{textContent:''}).textContent.trim(),
|
||
}))
|
||
const pdf = await pg.pdf({ preferCSSPageSize:true })
|
||
const t = pdf.toString('latin1')
|
||
const pages=(t.match(/\/Type\s*\/Page[^s]/g)||[]).length
|
||
const boxes=[...t.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map(m=>[+m[1],+m[2]])
|
||
// THE PRINT LAYER. This gate already made a PDF and read only its STRUCTURE — page count
|
||
// and MediaBox — which eight blank sheets of the right size satisfy perfectly. The packet
|
||
// exists to be PRINTED, and a print-only CSS rule can strip content every on-screen check
|
||
// still sees. FontFile2 = a face actually embedded, so the text travels with the file.
|
||
const pdfFonts=(t.match(/\/FontFile2/g)||[]).length
|
||
const pdfBytes=pdf.length
|
||
await b.close()
|
||
console.log(` lang : ${LANG}`)
|
||
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
|
||
console.log(` people cards : ${r.cards}`)
|
||
console.log(` prayer points : ${r.points} / 12`)
|
||
console.log(` wheel segments : ${r.wheel} (${r.wheelLabels}/12 labelled)`)
|
||
console.log(` map (page 1) : ${r.mapGeoPaths} geography paths`)
|
||
console.log(` printed artefact : ${(pdfBytes/1024/1024).toFixed(1)}MB, ${pdfFonts} embedded font(s)`)
|
||
console.log(` Mark 5 in full : ${r.mark5Len} chars`) // ⚠ mark5Len, not mark5 — the latter is not a key on r and printed "undefined"
|
||
// beside a PASS for as long as this gate has existed. The ASSERTION below always read
|
||
// the right key, so the requirement was protected; only the display was blind.
|
||
console.log(` metacamp.org : ${r.metacamp}`)
|
||
console.log(` page size : ${boxes[0]?boxes[0].join(' x '):'?'} pt`)
|
||
console.log(` points heading : ${r.pointsTitle.trim()}`)
|
||
console.log(` wheel heading : ${r.wheelTitle.trim()}`)
|
||
console.log(` page errors : ${errs.length} ${errs[0]||''}`)
|
||
// ── NAMED ASSERTIONS, NOT ONE COLLAPSED BOOLEAN ─────────────────────────────
|
||
// This was `const bad = a || b || c || ...`, so a failure printed "FAIL" without
|
||
// saying WHICH of six conditions tripped. Three further metrics were printed and
|
||
// never checked at all: page size, and the two headings — which are the very
|
||
// strings this gate's own header says it covers ("the gate reports which headings
|
||
// came back translated"). Reports, not asserts.
|
||
const fails = []
|
||
if (!r.sheets) fails.push('no .sheet elements — the browser packet did not render')
|
||
if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be one page`)
|
||
if (!r.cards) fails.push('no people-group cards')
|
||
if (r.wheel !== 12) fails.push(`prayer wheel has ${r.wheel} segments, expected 12`)
|
||
// THE PRODUCT IS NAMED FOR THIS NUMBER: the page header reads "12 Prayer Points" in every
|
||
// locale, so 12 is a contract. The failure it guards is silent — a partial translation once
|
||
// rendered THREE under that heading, with nothing blank and no error.
|
||
if (r.points !== 12) {
|
||
fails.push(r.points === -1
|
||
? 'no .points-title block — the 12 prayer points page did not render at all'
|
||
: `${r.points} prayer point(s) rendered, expected 12`)
|
||
}
|
||
// A wheel with 12 coloured segments and no words on it is a pie chart. The document-wide
|
||
// rhythm vocabulary cannot see this: the SAME words are rendered twice, once per segment
|
||
// and once per prayer-point heading, so a whole-document search passes on the points page.
|
||
if (r.wheelLabels !== 12) {
|
||
fails.push(r.wheelLabels === -1
|
||
? 'no prayer-wheel svg found — the wheel page did not render'
|
||
: `the prayer wheel paints ${r.wheelLabels} segment label(s), expected 12`)
|
||
}
|
||
// Page 1 is the map one-pager — the first line of the packet spec.
|
||
if (r.mapGeoPaths !== -1 && r.mapGeoPaths < 5) {
|
||
fails.push(`only ${r.mapGeoPaths} geography path(s) on page 1 — the map one-pager is blank `
|
||
+ 'or drew nothing but furniture')
|
||
}
|
||
// A missing map is a DECLARED degradation here, exactly as PACKET_EXPECT_MAP=0 is for the
|
||
// build-time gate: out/posters/ is gitignored, so a checkout that never ran `bun run posters`
|
||
// legitimately has none, and the product itself returns '' in that case.
|
||
if (r.mapGeoPaths === -1) {
|
||
console.log(` map (page 1) : ABSENT — no out/posters/${ZONE}.html (run \`bun run posters\`)`)
|
||
}
|
||
// The packet is a standalone file meant to print offline, so its text must travel with it.
|
||
if (pdfFonts === 0) {
|
||
fails.push('the PDF embeds NO fonts (0 /FontFile2) — the text depends on the printer\'s font set')
|
||
}
|
||
if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`)
|
||
// Mark 5:1-20 in full, by LENGTH rather than an English proper noun.
|
||
if (r.mark5Len < 1200) {
|
||
fails.push(r.mark5Len === 0
|
||
? 'Mark 5:1-20 is NOT printed (no .p4-passage block)'
|
||
: `Mark 5:1-20 looks truncated: ${r.mark5Len} chars`)
|
||
}
|
||
// PAGE SIZE was printed and never compared. `@page{size:letter portrait}` is
|
||
// 612x792pt; a wrong PORTRAIT size (A4 is 595x842) is taller-than-wide too, so
|
||
// nothing else here would notice it.
|
||
if (!boxes.length) fails.push('the PDF declares no MediaBox — page geometry unreadable')
|
||
else {
|
||
const wrong = boxes.filter(([w,h]) => Math.abs(w-612)>1 || Math.abs(h-792)>1)
|
||
if (wrong.length) fails.push(`${wrong.length}/${boxes.length} page(s) are ${wrong[0].join(' x ')}pt, expected 612 x 792pt`)
|
||
}
|
||
// ── I18N, ASSERTED AGAINST THE CATALOG ──────────────────────────────────────
|
||
// The heading a reader sees must equal the catalog entry for THIS packet's
|
||
// language — per-key fallback to English when the key is absent, exactly as the
|
||
// renderer resolves it. Never a hardcoded expectation, which is what made these
|
||
// two unassertable in the first place.
|
||
const cat = JSON.parse(readFileSync(STRINGS_PATH, 'utf8'))
|
||
const expect = (key) => ((cat[LANG]||{})[key] ?? cat.en[key])
|
||
for (const [sel, key, got] of [['points-title','points_title',r.pointsTitle],
|
||
['wheel-title','wheel_title',r.wheelTitle]]) {
|
||
const want = expect(key)
|
||
if (!got) fails.push(`.${sel} is EMPTY — a blank heading is worse than an English one`)
|
||
else if (got !== want) fails.push(`.${sel} reads ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`)
|
||
}
|
||
if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`)
|
||
|
||
OWN_SERVER?.stop()
|
||
|
||
if (fails.length) {
|
||
console.error(`\nPACKET-BROWSER: FAIL — ${fails.length} problem(s)`)
|
||
for (const f of fails) console.error(' - ' + f)
|
||
process.exit(1)
|
||
}
|
||
console.log('\nPACKET-BROWSER: PASS — the browser-rendered packet is correct, and its headings '
|
||
+ `match the ${LANG} catalog.`)
|