test(packet): gate the packet the READER gets, not the one Node writes
Some checks are pending
build-gate / build (push) Waiting to run

check:packet-render verifies a packet FILE from the build-time generator. This
verifies the BROWSER path — the legend's Export > Prayer packet action, which
imports library/packet/packet-render.js, fetches live records and prints
in-place.

Both callers now share that one renderer, and this gate is what proves the
shared module actually runs in a tab. A module can be perfectly correct under
Node and still fail in a browser: a bare process.env reference, a Node-only
import, a JSON import assertion. Node passing says nothing about that.

It drives the real thing: the renderer over HTTP, the app's own normalizer
(the API returns { posts, total } — hand-rolling that extraction is how a probe
ends up testing something the product never does), live records, then
page.pdf() and a PDF page count.

Measured on the browser path: 8 sheets -> 8 pages at 612x792pt, 95 people
cards, 12 wheel segments, Mark 5:1-20 in full, exactly 2 metacamp.org
attributions, 0 page errors.

I18N is asserted here because it is a product requirement, not a nicety: pass a
language and the gate reports which headings came back translated. Verified with
a partial Spanish block — "12 Puntos de Oración" and "Rueda de Oración" rendered
while Mark 5 and both attributions correctly fell back to English. Fallback is
PER KEY, so a half-finished translation renders its finished keys and never
blanks. The stub block was removed after verifying; shipping it would imply a
Spanish translation that does not exist.

Parameterised rather than pinned: PACKET_ORIGIN / PACKET_API / PACKET_ISO3, and
the language as argv. Needs a static server over the repo root.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 02:43:15 +00:00
parent 301cc38fba
commit 097ab02458
2 changed files with 83 additions and 0 deletions

View file

@ -0,0 +1,82 @@
// 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'
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)))
const ORIGIN = process.env.PACKET_ORIGIN || 'http://127.0.0.1:4898'
const API = process.env.PACKET_API || 'https://pray.doxa.life'
const ISO3 = process.env.PACKET_ISO3 || 'LAO'
const RENDER = `${ORIGIN}/library/packet/packet-render.js`
const STRINGS = `${ORIGIN}/library/scripts/posters/packet-strings.json`
const LANG = process.argv[2] || 'en'
await pg.goto(ORIGIN + '/')
const html = await pg.evaluate(async ({RENDER, STRINGS, LANG, ORIGIN, API, ISO3}) => {
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]))
return R.buildPacket({id:ISO3.toLowerCase(),label:ISO3}, groups, photos, '')
}, {RENDER, STRINGS, LANG, ORIGIN, API, ISO3})
await pg.setContent(html, { waitUntil:'load' })
await pg.waitForTimeout(3000)
const r = await pg.evaluate(()=>({
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
wheel: document.querySelectorAll('svg path[fill^="hsl"]').length,
mark5: document.body.innerText.includes('Gadarenes'),
metacamp: (document.body.innerText.match(/metacamp\.org/g)||[]).length,
firstCard: (document.querySelector('.card')||{}).innerText||'',
wheelTitle: (document.body.innerText.match(/^.*Wheel.*$/m)||[''])[0],
pointsTitle: (document.body.innerText.match(/^.*Prayer Points.*$|^.*Puntos.*$/m)||[''])[0],
}))
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]])
await b.close()
console.log(` lang : ${LANG}`)
console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`)
console.log(` people cards : ${r.cards}`)
console.log(` wheel segments : ${r.wheel}`)
console.log(` Mark 5 in full : ${r.mark5}`)
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]||''}`)
const bad = !r.sheets || pages!==r.sheets || !r.cards || r.wheel!==12 || !r.mark5 || r.metacamp!==2 || errs.length
console.log(bad?'\nFAIL':'\nPASS — browser-rendered packet is correct')
process.exit(bad?1:0)

View file

@ -55,6 +55,7 @@
"check:heartbeat": "node internal/check-heartbeat-threshold.mjs # the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).",
"check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test # THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-render": "node internal/check-packet-render.mjs",
"check:template-literal": "node internal/template-literal-check.mjs",
"verify:map-paint": "node internal/verify-map-paint.mjs",