From 097ab02458e7937a14496a9169de4f2f0c037505 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:43:15 +0000 Subject: [PATCH] test(packet): gate the packet the READER gets, not the one Node writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-packet-browser.mjs | 82 +++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 83 insertions(+) create mode 100644 internal/check-packet-browser.mjs diff --git a/internal/check-packet-browser.mjs b/internal/check-packet-browser.mjs new file mode 100644 index 000000000..739446992 --- /dev/null +++ b/internal/check-packet-browser.mjs @@ -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) diff --git a/package.json b/package.json index 2d134e1b5..0f8bf1ce5 100644 --- a/package.json +++ b/package.json @@ -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",