doxa-maps/internal/check-poster-prints.mjs
Doxa Maps fbc517c94a A browser gate refuses to launch outside the browser slot; check:browser-gates-guarded keeps it that way
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
2026-09-28 08:20:21 +00:00

187 lines
11 KiB
JavaScript

// 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 './lib/playwright-guarded.mjs'
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('<!DOCTYPE html><html><body></body></html>')
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.')