// check-poster-prints — the SHARED print actions must produce real documents. // // Context: the poster wall was generalised into library/components/poster/ // PosterWall.vue and two bundles migrated onto it, but the ORIGINAL 903-line // my-upg-100-list wall — the one carrying the actual print actions — was left // behind. So the shared component's packet path was a "coming soon" stub, and // every print fix in the 100-list was invisible to the three bundles using the // shared wall. usePosterPrints.js closes that: the ACTIONS are shared, the RECORD // FETCHING stays per-bundle behind a `records` provider. // // ★ THE SEAM IS THE THING TO ASSERT. A wall WITH a provider must produce a real // document; a wall WITHOUT one must degrade honestly rather than failing at click // time. Both directions are checked, because "the button is there" and "the button // works" are different claims — this flight has repeatedly found the first true // and the second false. // // Runs the composable in a browser page (it calls document/DOM), driving it with a // fake records provider so the assertion is about the DOCUMENTS, not about any // bundle's API being reachable. import { chromium } from 'playwright' import { readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const REPO = process.env.DOXA_REPO // Third instance of one pattern this flight (after check-zone-census and // check-share-policy): a gate authored in one worktree hardcodes THAT worktree as its // fallback, and check:no-abs-paths — which is inside build:unlocked — turns red for every // other builder. DOXA_REPO still wins; only the default is made portable, using the idiom // check-no-absolute-paths.mjs prints in its own failure text. || resolve(dirname(fileURLToPath(import.meta.url)), '..') // Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs). { const { requireThisRepo } = await import('./_serves-this-tree.mjs'); requireThisRepo(REPO, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'POSTER-PRINTS') } const src = readFileSync(resolve(REPO, 'library/composables/usePosterPrints.js'), 'utf8') const frame = readFileSync(resolve(REPO, 'library/composables/printInPlace.js'), 'utf8') // The composable dynamically imports its renderers by RELATIVE path. Under a blob // URL there is no directory to resolve '../packet/...' against, so those imports // fail with "Failed to resolve module specifier" — a harness limitation, not a // product bug (Vite resolves them, and the built bundle inlines both renderers). // Read them here and substitute blob URLs so the gate exercises the REAL code. const packetRender = readFileSync(resolve(REPO, 'library/packet/packet-render.js'), 'utf8') const cardRender = readFileSync(resolve(REPO, 'library/packet/card-render.js'), 'utf8') const qr = readFileSync(resolve(REPO, 'library/packet/qr.js'), 'utf8') const strings = readFileSync(resolve(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8') const b = await chromium.launch() const pg = await b.newPage({ viewport: { width: 1000, height: 800 } }) const errs = [] pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140))) await pg.setContent('') const r = await pg.evaluate(async ({ src, frame, packetRender, cardRender, qr, strings }) => { // Shim the two module deps so the composable can be evaluated standalone: a tiny // `ref`, and printInPlace's real source so the frame mechanism is the real one. const frameUrl = URL.createObjectURL(new Blob([frame], { type: 'text/javascript' })) const vueShim = URL.createObjectURL(new Blob([ 'export const ref = (v) => ({ value: v })', ], { type: 'text/javascript' })) const blob = (code) => URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) const qrUrl = blob(qr) const packetUrl = blob(packetRender.replace("from './qr.js'", `from '${qrUrl}'`)) const cardUrl = blob(cardRender.replace("from './qr.js'", `from '${qrUrl}'`)) const stringsUrl = URL.createObjectURL(new Blob([`export default ${strings}`], { type: 'text/javascript' })) const patched = src .replace("from 'vue'", `from '${vueShim}'`) .replace("from './printInPlace.js'", `from '${frameUrl}'`) .replace("'../packet/packet-render.js'", `'${packetUrl}'`) .replace("'../packet/card-render.js'", `'${cardUrl}'`) .replace(/'\.\.\/scripts\/posters\/packet-strings\.json'/g, `'${stringsUrl}'`) const modUrl = URL.createObjectURL(new Blob([patched], { type: 'text/javascript' })) const { usePosterPrints } = await import(modUrl) const ROWS = [ { slug: 'a', name: 'Aheu', country_code_label: 'Laos', population: 2700, religion_label: 'Animism' }, { slug: 'b', name: 'Akha', country_code_label: 'Laos', population: 113000, religion_label: 'Animism' }, ] const out = {} // ── with a provider ────────────────────────────────────────────────────── // ⚠ CAPTURE THE HTML, do not read the frame back. openPrintFrame calls // window.print() under autoprint, which BLOCKS in headless Chromium, and the // frame self-removes on afterprint — so reading it back measured a frame that had // already gone and reported 0 rows for a correct document. Intercept the document // the composable hands to the frame instead: that IS what gets printed. let printed = '' const realCreate = document.createElement.bind(document) document.createElement = (tag) => { const el = realCreate(tag) if (tag === 'iframe') { Object.defineProperty(el, 'contentDocument', { get: () => ({ open() {}, close() {}, write(h) { printed += h } }), }) Object.defineProperty(el, 'contentWindow', { get: () => ({ addEventListener() {} }) }) } return el } const withProv = usePosterPrints({ records: async () => ROWS, label: (i) => i.label }) await withProv.printList({ id: 'laos', label: 'Laos' }) await new Promise((r2) => setTimeout(r2, 400)) document.createElement = realCreate const doc = new DOMParser().parseFromString(printed, 'text/html') out.listRows = doc ? doc.querySelectorAll('tbody tr').length : 0 out.listHasCheckbox = doc ? /□/.test(doc.body.innerText) : false out.listPhase = withProv.exportState.value?.phase ?? 'cleared' // ── the PACKET action: same provider, the full booklet ─────────────────── // Migrated from upg-100-list.vue, where it was a second hand-maintained copy of // the same renderer call. Asserted here so the shared version cannot regress // silently — the packet is the product's core artefact. const PKT = [ { slug: 'a', name: 'Aheu', country_code_label: 'Laos', population: 2700, image_url: 'x.jpg' }, { slug: 'b', name: 'Akha', country_code_label: 'Laos', population: 113000, image_url: 'y.jpg' }, ] printed = '' document.createElement = (tag) => { const el = realCreate(tag) if (tag === 'iframe') { Object.defineProperty(el, 'contentDocument', { get: () => ({ open() {}, close() {}, write(h) { printed += h } }), }) Object.defineProperty(el, 'contentWindow', { get: () => ({ addEventListener() {} }) }) } return el } const pk = usePosterPrints({ records: async () => PKT, label: (i) => i.label }) await pk.printPacket({ id: 'laos', label: 'Laos' }) await new Promise((r2) => setTimeout(r2, 600)) document.createElement = realCreate const pdoc = new DOMParser().parseFromString(printed, 'text/html') out.packetSheets = pdoc.querySelectorAll('.sheet').length out.packetCards = pdoc.querySelectorAll('.card').length out.packetWheel = pdoc.querySelectorAll('svg path[fill^="hsl"]').length // ⚠ NOT /Gadarenes/. That is an ENGLISH proper noun from the World English Bible // text, so this assertion would go RED on a CORRECTLY TRANSLATED packet and passes // today only because point4_passage is still English in all ten non-English blocks // (DECIDE-MARK5-SCRIPTURE-IS-ENGLISH-IN-EVERY-TRANSLATED-PACKET). The check and the // bug were locked together — the same fix already applied in check-packet-render // and check-packet-browser. Assert the passage BLOCK and its length, which holds in // any language. out.packetMark5Len = (pdoc.querySelector('.p4-passage') || { textContent: '' }).textContent.trim().length // ── without a provider: must FAIL HONESTLY, not throw ──────────────────── const noProv = usePosterPrints({ label: (i) => i.label }) await noProv.printList({ id: 'x', label: 'X' }) out.noProvPhase = noProv.exportState.value?.phase out.noProvMsg = noProv.exportState.value?.message || '' return out }, { src, frame, packetRender, cardRender, qr, strings }) await b.close() const fails = [] if (r.listRows !== 2) fails.push(`the list print rendered ${r.listRows} row(s), expected 2`) if (!r.listHasCheckbox) fails.push('the list print has no prayer checkbox column') if (r.noProvPhase !== 'error') { fails.push(`without a records provider the phase was "${r.noProvPhase}", expected "error" — ` + 'it must degrade honestly rather than silently doing nothing') } if (!/no records provider/i.test(r.noProvMsg)) { fails.push(`the no-provider message was "${r.noProvMsg}" — it should name the missing provider`) } // The packet is the product's core artefact: map page + people pages + points + // wheel. Assert its SHAPE, not merely that bytes were produced. With 2 groups // that is 1 map + 1 people page + points + wheel = 4 sheets. if (r.packetSheets !== 4) fails.push(`the packet printed ${r.packetSheets} sheets, expected 4`) if (r.packetCards !== 2) fails.push(`the packet printed ${r.packetCards} people cards, expected 2`) if (r.packetWheel !== 12) fails.push(`the prayer wheel has ${r.packetWheel} segments, expected 12`) // The English passage is 2,141 chars; 1,200 is a floor no truncation or placeholder // survives, with room for languages that render the same text more compactly. if (r.packetMark5Len < 1200) { fails.push(r.packetMark5Len === 0 ? 'the packet is missing Mark 5:1-20 in full (no .p4-passage block)' : `Mark 5:1-20 looks truncated: ${r.packetMark5Len} chars`) } if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) console.log(` list print : ${r.listRows} rows, checkbox=${r.listHasCheckbox}`) console.log(` without provider : phase=${r.noProvPhase} "${r.noProvMsg.slice(0, 60)}"`) console.log(` packet : ${r.packetSheets} sheets, ${r.packetCards} cards, ` + `${r.packetWheel} wheel segments, Mark5=${r.packetMark5Len} chars`) console.log(` page errors : ${errs.length}`) if (fails.length) { console.error(`\nPOSTER-PRINTS: FAIL — ${fails.length} problem(s)`) for (const f of fails) console.error(' - ' + f) process.exit(1) } console.log('\nPOSTER-PRINTS: PASS — shared print actions produce real documents, and degrade honestly without a provider.')