Export poster opens the print dialog on the poster, and downloads nothing

The Driver, 2026-09-28: an export-poster button must open the PRINT DIALOG with a poster preview,
never a direct download. Simple Research was doing exactly the forbidden thing — render a PNG, hand it
to a download helper — so a reader got doxa-prayer-poster.png in their downloads folder with no
preview, no paper size, and no way to print it without opening it somewhere else first.

The dialog IS the preview. printImageBlob() puts the rendered poster on a sheet of exactly the spec's
size (@page 8.5in x 11in, margin 0 — the renderer already composed the margins in) and raises the
dialog over the map. It goes in library/composables/printInPlace.js, which is THE print mechanism here
and already carries the Driver's earlier ruling that killed the pop-up path: an iframe cannot be
blocked and needs no user gesture, so the same call works for a human click and for an agent.

The ellipsis comes back on the label. It was stripped while the button handed over a file directly,
and an ellipsis promises a dialog — which is now what happens.

★ AND THE GATE ASSERTED THE OLD RULING, which is the part worth naming. check-export-one-click passed
while the button did the thing the Driver has now ruled against: it waited for a download and checked
the PNG's dimensions. A gate that encodes a superseded ruling does not just miss the defect, it defends
it — the next person to "fix" this button would have had to break a green gate to do it. It is replaced
by check-export-poster-prints, same four cases (leaf and gallery, en and ar), asserting the new
contract: one click, some frame calls window.print() within 120s, the printing document is the
1700x2200 poster on an 8.5in x 11in sheet, and NO download arrives. window.print is stubbed in every
frame — the native dialog is modal and would hang the run — so this measures everything up to the
dialog and not the browser's own chrome, which is the right boundary for a headless gate.

Why the download half is asserted separately: a button that opens the dialog AND saves a file still
breaks the rule, and a download that lands late is still a download, so it is judged over the whole run
rather than at a moment.

Verified: gates:pre 12/12, check:doc-scripts and check:no-credentials-omit PASS with the rename.
NOT verified here: the new gate needs a browser, and this agent does not start one — rega runs it.
This commit is contained in:
Doxa Maps 2026-09-28 01:12:07 +00:00
parent ed87323592
commit 2ea9b22daa
5 changed files with 223 additions and 132 deletions

View file

@ -45,3 +45,43 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
return frame.contentWindow
}
/**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
*
* ★ THE DRIVER'S RULE (2026-09-28): an "Export poster" button opens the PRINT DIALOG with a preview.
* It never hands over a file. Simple Research was rendering a PNG and calling a download helper, so
* the reader got doxa-prayer-poster.png in their downloads folder and no preview, no paper size, and
* no way to print it without opening it somewhere else first. The dialog IS the preview: the sheet the
* browser shows is the poster, at the size the spec asked for.
*
* @page carries the spec's own inches so the preview is not letter-shaped by default — a 8.5x11
* poster previewed on A4 is a different document from the one that was rendered. margin:0 because the
* renderer already composed the margins into the image; a second set would scale the poster down.
*
* The object URL is revoked on afterprint AND on the frame's 60s reclaim, since afterprint does not
* fire in every engine when the dialog is dismissed. Revoking early would blank the preview, so the
* image element is kept alive by the frame itself rather than by a timer here.
*
* Returns true when the dialog was opened, false when the host blocked the frame — the caller decides
* what to do about it, because a silent no-op is exactly the failure this replaced.
*/
export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '', alt = '' } = {}) {
if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) return false
const url = URL.createObjectURL(blob)
const esc = (v) => String(v).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
// The image is sized by the PAGE, not by its pixels: object-fit keeps a poster rendered at any DPI
// on its own sheet without cropping. The <img> also gates the print — openPrintFrame's bootstrap
// waits for load, which is what keeps the first page from printing blank.
const html = `<!doctype html><html><head><meta charset="utf-8"><title>${esc(title)}</title>`
+ `<style>@page{size:${widthIn}in ${heightIn}in;margin:0}`
+ 'html,body{margin:0;padding:0;background:#fff}'
+ 'img{display:block;width:100%;height:100%;object-fit:contain}</style></head>'
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
const win = openPrintFrame(html, { autoprint: true })
if (!win) { URL.revokeObjectURL(url); return false }
const revoke = () => { try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
try { win.addEventListener('afterprint', () => setTimeout(revoke, 0)) } catch { /* cross-doc guard */ }
setTimeout(revoke, 60000)
return true
}

View file

@ -65,8 +65,8 @@
"check:embedder-keys": "node plugin-designs/regions-dashboard/scripts/check-embedder-keys.mjs",
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs",
"check:export-one-click": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs",
"check:export-one-click:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs --provider maplibre",
"check:export-poster-prints": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs",
"check:export-poster-prints:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-poster-prints.mjs --provider maplibre",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@ -332,8 +332,8 @@
"check:embedder-keys": "regions-mfe forwards page-scoped config to its nested panes by an explicit key list (INHERITED_PAGE_KEYS). Asserts the documented embedder keys travel (dataSource, tokenUrl among them) AND that profile and instanceId do NOT: the host's would overwrite each pane's own and collapse both tabs into one view.",
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
"check:experimental-shelf": "Every Experimental-folder bundle, leaf + Gallery tab, mapbox + maplibre: mounts with content, 0 errors / failed requests, maps load and DRAW their own data. Self-tests on a deliberately broken bundle first. Run through internal/browser-slot.sh.",
"check:export-one-click": "the Simple Research 'Export poster' button gives a FILE on ONE click (no menu, no dialog, no second step): leaf AND Research Gallery, en AND ar; the file is a real print PNG (US Letter @200 DPI); 0 page errors. Run inside the shared browser slot. Exit 2 = SKIP, 75 = NOT MEASURED.",
"check:export-one-click:maplibre": "the same four one-click exports on the keyless MapLibre engine (OpenFreeMap tiles): the hidden print map must be built with the engine that drew the screen, and the footer must credit the tiles actually printed.",
"check:export-poster-prints": "the Simple Research 'Export poster' button opens the PRINT DIALOG on the poster in ONE click and downloads NOTHING (the Driver's rule, 2026-09-28): leaf AND Research Gallery, en AND ar; the printing document is the 1700x2200 poster on an 8.5in x 11in sheet; no download arrives; 0 page errors. window.print is stubbed, so this measures everything up to the dialog. Replaces check:export-one-click, which asserted the download this rule forbids. Run inside the shared browser slot. Exit 2 = SKIP, 75 = NOT MEASURED.",
"check:export-poster-prints:maplibre": "the same four one-click prints on the keyless MapLibre engine (OpenFreeMap tiles): the hidden print map must be built with the engine that drew the screen, and the footer must credit the tiles actually printed.",
"check:facet-cascade": "a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-changes-map": "a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",

View file

@ -1299,16 +1299,17 @@ watch(() => mapStore.selectedRegion, (key) => {
// required getMap, so it mounted invisible; and the old loader called useMapPoster({ sourceMap, … })
// with an OBJECT where the shared composable takes a FUNCTION, so a render would have thrown anyway.
// Now the button does the export itself, through the SHARED poster path (useMapPoster → render →
// download), with a sensible print default: the current view on US Letter portrait at 200 DPI, the
// legend title and rows, and a source/date footer. No dialog, no second step.
// print), with a sensible print default: the current view on US Letter portrait at 200 DPI, the
// legend title and rows, and a source/date footer. One click, one dialog, the poster in the preview.
const posterBusy = ref(false)
async function openPoster() {
if (posterBusy.value) return
posterBusy.value = true
try {
const [{ useMapPoster }, { defaultPosterSpec }] = await Promise.all([
const [{ useMapPoster }, { defaultPosterSpec }, { printImageBlob }] = await Promise.all([
import('@map/composables/poster/useMapPoster.js'),
import('@map/constants/posterDefaults.js'),
import('@map/composables/printInPlace.js'),
])
const exporter = useMapPoster(() => map.value)
const spec = defaultPosterSpec({ widthIn: 8.5, heightIn: 11, dpi: 200, orientation: 'portrait' })
@ -1333,19 +1334,31 @@ async function openPoster() {
spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10),
attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' }
const blob = await exporter.render(spec, 'png')
// File name from the view's id, not its title: an Arabic or Chinese title has no ASCII letters
// to keep, and every reader's file would otherwise be the same "doxa-map-poster.png".
const slug = String(activeTab.value?.id || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()
_downloadBlob(blob, 'doxa-' + (slug || 'map') + '-poster.png')
// ★ THE DIALOG, NOT A DOWNLOAD (the Driver, 2026-09-28: an export-poster button must open the
// print dialog with a poster preview, never hand over a file). This used to call _downloadBlob,
// so the reader got a PNG in their downloads folder with no preview, no paper size and no way to
// print it without opening it somewhere else first. printImageBlob puts the rendered poster on a
// sheet of exactly the spec's size and raises the dialog over the map — the preview IS the poster.
// The same in-place frame the Driver's earlier ruling settled on: no pop-up, no blocker, and it
// works when an agent triggers it without a user gesture.
const printed = printImageBlob(blob, {
widthIn: spec.widthIn ?? 8.5,
heightIn: spec.heightIn ?? 11,
title,
alt: title,
})
// An unopenable frame (sandboxed or detached host) is reported, not silently swallowed: a button
// that looks like it worked and did nothing is the defect this whole path has already had once.
if (!printed) console.error('[research-map] poster print: the host blocked the print frame')
} catch (err) {
console.error('[research-map] poster export failed:', err)
} finally {
posterBusy.value = false
}
}
// The toolbar's export label: the drawer's translated 'Export poster…' WITHOUT the ellipsis — an
// ellipsis promises a dialog, and this button hands over the file directly.
const exportPosterLabel = computed(() => String(t('controls.export_poster') || 'Export poster').replace(/\s*…\s*$/, ''))
// The toolbar's export label, WITH its ellipsis. The ellipsis was stripped while this button handed
// over a file directly; it now opens the print dialog, which is exactly what an ellipsis promises.
const exportPosterLabel = computed(() => String(t('controls.export_poster') || 'Export poster…'))
// ─── Export menu router (SemanticTreeLegend @export) ─────────────────────────
// EXPERIMENTAL — dormant behind the shared kill-switch (SHOW_EXPORT_BUTTON).

View file

@ -1,118 +0,0 @@
#!/usr/bin/env node
/**
* check-export-one-click.mjs — the Simple Research "Export poster" button gives you a FILE on ONE
* click, with no menu, dialog or second step, in its own leaf AND inside the Research Gallery, in
* en AND ar.
*
* WHY (card BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK; the Driver: "there's an export button on the
* simple dashboard ... get it working"). Measured 2026-09-25 before the fix: one click produced
* NOTHING — no dialog, no file within 60s, no error — in both hosts. A silent dead button reads as
* "the export is slow" to a reader, never as "it is broken", so no other gate could see it.
*
* WHAT IT ASSERTS, per host × language:
* 1. exactly ONE click on the ALWAYS-VISIBLE export button in the map toolbar (button.export-poster-btn).
* The drawer's "Export poster…" does not count: the drawer is CLOSED by default (translated off
* screen), so it costs an extra click to reach — measured 2026-09-25, and in Arabic the button
* could not be clicked at all. The toolbar button must be on screen with no preparation;
* 2. a download arrives within 120s, WITHOUT any further click;
* 3. the file is a real PNG (signature) of the print size (US Letter at 200 DPI = 1700x2200);
* 4. 0 page errors.
*
* Runs through internal/browser-slot.sh like every browser gate. Starts its own servers on
* OS-assigned ports and proves the server is THIS checkout's tree before measuring.
* --provider maplibre runs the same four on the keyless engine.
* EXPORT_SAVE_DIR=<dir> keeps each downloaded poster (<host>-<lang>-<file>) so a person can look at it.
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no playwright) · 75 NOT MEASURED (server identity)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
// The tree's name comes from the build config, never a literal: the doxa-maps -> dx-maps rename
// turned the literal into a silent SKIP (exit 2) that measured nothing.
const { TREE_NAME } = await import(resolve(ROOT, 'internal/_tree-name.mjs'))
if (!existsSync(resolve(ROOT, `public/${TREE_NAME}/research-map-plus-grid/research-map-plus-grid.js`))) {
console.log('SKIP check-export-one-click: no built research-map-plus-grid'); process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-export-one-click: playwright not installed'); process.exit(2) }
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const PROVIDER = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : '' // maplibre = the keyless engine
const TP = await freePort(), HP = await freePort()
const TREE = `http://127.0.0.1:${TP}/${TREE_NAME}`
let lang = 'en'
const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${lang}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="research-map-plus-grid"${PROVIDER ? ` provider="${PROVIDER}"` : ''} style="height:900px"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
const cleanup = () => { try { srv.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
// Server identity by CONTENT: the served manifest must be this checkout's.
{
const disk = readFileSync(resolve(ROOT, `public/${TREE_NAME}/manifest.json`), 'utf8')
let served = ''
for (let i = 0; i < 20 && !served; i++) {
try { const r = await fetch(`${TREE}/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
if (!served) await new Promise(r => setTimeout(r, 300))
}
if (served !== disk) { console.log(`NOT MEASURED: :${TP} is not serving this checkout's tree. Re-run.`); cleanup(); process.exit(75) }
}
const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const fails = []
try {
for (lang of ['en', 'ar']) for (const where of ['leaf', 'gallery']) {
const url = where === 'leaf' ? `${TREE}/research-map-plus-grid/research-map-plus-grid/index.html?lang=${lang}${PROVIDER ? `&provider=${PROVIDER}` : ''}` : `http://localhost:${HP}/`
const ctx = await b.newContext({ acceptDownloads: true, viewport: { width: 1400, height: 900 } })
const p = await ctx.newPage(); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 160)))
// A failed export is CAUGHT and logged (console.error), never thrown: read the console, or a dead
// button reports "no file" with no reason.
const consoleErrs = []; p.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrs.push(m.text().slice(0, 200)) })
const downloads = []; p.on('download', d => downloads.push(d))
await p.goto(url); await p.waitForTimeout(12000)
const btn = p.locator('button.export-poster-btn').first()
const onScreen = await btn.evaluate(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight }).catch(() => false)
if (!onScreen) errs.push('the export button is not on screen without a prior click')
const label = ((await btn.getAttribute('title', { timeout: 20000 }).catch(() => '')) || '').trim()
let clicks = 0
// ONE PHYSICAL CLICK at the button's centre, as a reader makes it. Not locator.click(): that
// re-checks its hit target and RETRIES, and by the retry the button is already disabled (the
// export is running), so it reported "timeout" on exports whose file then arrived — measured
// 2026-09-25 in gallery-ar and leaf-en. What the click achieved is judged by the file below.
try {
const box = await btn.boundingBox({ timeout: 20000 })
if (!box) throw new Error('the export button has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2); clicks = 1
} catch (e) { errs.push('could not click: ' + String(e.message || e).split('\n')[0]) }
for (let i = 0; i < 120 && !downloads.length; i++) await p.waitForTimeout(1000)
let file = null, info = ''
if (downloads.length) {
file = downloads[0].suggestedFilename()
const path = await downloads[0].path()
const buf = readFileSync(path)
if (process.env.EXPORT_SAVE_DIR) writeFileSync(resolve(process.env.EXPORT_SAVE_DIR, `${where}-${lang}${PROVIDER ? '-' + PROVIDER : ''}-${file}`), buf) // to LOOK at the poster
const png = buf.length > 24 && buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
const w = png ? buf.readUInt32BE(16) : 0, h = png ? buf.readUInt32BE(20) : 0
info = `${png ? 'PNG' : 'NOT-PNG'} ${w}x${h} ${Math.round(buf.length / 1024)}KB`
if (!png || w < 1600 || h < 2100) errs.push(`file is not the expected print PNG (${info})`)
}
if (!file) {
for (const c of consoleErrs.filter(c => /poster|useMapPoster/i.test(c)).slice(-2)) errs.push(c)
// SLOW or DEAD? A button still busy at the deadline is a render still running; an idle one is an
// export that ended without a file. The verdict is FAIL either way; the reason says which.
const busy = await btn.evaluate(el => el.disabled || el.getAttribute('aria-busy') === 'true').catch(() => null)
errs.push(busy ? 'still rendering at the deadline (button busy)' : 'the export ended with no file (button idle)')
}
const ok = clicks === 1 && !!file && !errs.length
console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}${PROVIDER ? ' ' + PROVIDER : ''}] "${label}" · 1 click → ${file ? `${file} (${info})` : 'NO FILE within 120s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors'}`)
if (!ok) fails.push(`${where} ${lang}`)
await ctx.close()
}
} finally { await b.close(); cleanup() }
if (fails.length) { console.log(`\ncheck-export-one-click: FAIL (${fails.join(', ')})`); process.exit(1) }
console.log('\ncheck-export-one-click: PASS — one click on Export poster gives a print PNG, leaf and gallery, en and ar')

View file

@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* check-export-poster-prints.mjs — the Simple Research "Export poster" button opens the PRINT DIALOG
* with the poster in it, on ONE click, in its own leaf AND inside the Research Gallery, in en AND ar.
* It must NEVER hand over a file.
*
* ★ THE RULE CHANGED, AND THIS GATE ASSERTED THE OLD ONE (the Driver, 2026-09-28: "an export-poster
* button must open the print dialog with a poster preview, never a direct download"). It was
* check-export-one-click, and it passed while the button did exactly what the Driver has now ruled
* against: render a PNG and drop it in the downloads folder. A gate that encodes a superseded ruling
* is worse than no gate — it defends the defect. The earlier history is still why the click path is
* measured so carefully: before 2026-09-25 one click produced NOTHING, no dialog, no file, no error.
*
* WHAT IT ASSERTS, per host × language:
* 1. exactly ONE click on the ALWAYS-VISIBLE export button in the map toolbar
* (button.export-poster-btn). The drawer's "Export poster…" does not count: the drawer is CLOSED
* by default (translated off screen), so it costs an extra click to reach — measured 2026-09-25,
* and in Arabic the button could not be clicked at all;
* 2. within 120s and with no further click, some frame calls window.print();
* 3. the printing document IS the poster: an <img> at the print size (US Letter at 200 DPI =
* 1700x2200) on an @page sized 8.5in x 11in. A dialog over a blank or letter-defaulted sheet is
* not a preview of what was rendered;
* 4. NO download arrives. This is the half that encodes the new rule, and it is asserted for the
* whole run rather than at a moment, because a download that lands late is still a download;
* 5. 0 page errors.
*
* ★ window.print IS STUBBED, not called through. The native dialog is modal and would hang the run
* forever, so the init script records the call and returns. That makes this a test of everything up to
* the dialog — the frame, the sheet size and the image — and not of the browser's own chrome, which is
* the correct boundary for a headless gate.
*
* Runs through internal/browser-slot.sh like every browser gate. Starts its own servers on
* OS-assigned ports and proves the server is THIS checkout's tree before measuring.
* --provider maplibre runs the same four on the keyless engine.
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no playwright) · 75 NOT MEASURED (server identity)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
// The tree's name comes from the build config, never a literal: the doxa-maps -> dx-maps rename
// turned the literal into a silent SKIP (exit 2) that measured nothing.
const { TREE_NAME } = await import(resolve(ROOT, 'internal/_tree-name.mjs'))
if (!existsSync(resolve(ROOT, `public/${TREE_NAME}/research-map-plus-grid/research-map-plus-grid.js`))) {
console.log('SKIP check-export-poster-prints: no built research-map-plus-grid'); process.exit(2)
}
let chromium
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-export-poster-prints: playwright not installed'); process.exit(2) }
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const PROVIDER = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : '' // maplibre = the keyless engine
const TP = await freePort(), HP = await freePort()
const TREE = `http://127.0.0.1:${TP}/${TREE_NAME}`
let lang = 'en'
const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${lang}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="research-map-plus-grid"${PROVIDER ? ` provider="${PROVIDER}"` : ''} style="height:900px"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
const cleanup = () => { try { srv.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
// Server identity by CONTENT: the served manifest must be this checkout's.
{
const disk = readFileSync(resolve(ROOT, `public/${TREE_NAME}/manifest.json`), 'utf8')
let served = ''
for (let i = 0; i < 20 && !served; i++) {
try { const r = await fetch(`${TREE}/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ }
if (!served) await new Promise(r => setTimeout(r, 300))
}
if (served !== disk) { console.log(`NOT MEASURED: :${TP} is not serving this checkout's tree. Re-run.`); cleanup(); process.exit(75) }
}
const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
const fails = []
try {
for (lang of ['en', 'ar']) for (const where of ['leaf', 'gallery']) {
const url = where === 'leaf' ? `${TREE}/research-map-plus-grid/research-map-plus-grid/index.html?lang=${lang}${PROVIDER ? `&provider=${PROVIDER}` : ''}` : `http://localhost:${HP}/`
const ctx = await b.newContext({ acceptDownloads: true, viewport: { width: 1400, height: 900 } })
// Every frame, because the poster prints from an iframe the app creates (printInPlace.js). The
// override lives on the WINDOW, which survives the document.open()/write() that fills that frame.
// acceptDownloads stays TRUE on purpose: a download must be observable to be failed on.
await ctx.addInitScript(() => {
window.__printed = []
window.print = function () {
const img = document.querySelector('img')
const style = document.querySelector('style')
window.__printed.push({
imgW: img ? img.naturalWidth : 0,
imgH: img ? img.naturalHeight : 0,
page: style ? (String(style.textContent).match(/@page\{size:([^;}]+)/) || [])[1] || '' : '',
})
}
})
const p = await ctx.newPage(); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 160)))
// A failed export is CAUGHT and logged (console.error), never thrown: read the console, or a dead
// button reports "no file" with no reason.
const consoleErrs = []; p.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrs.push(m.text().slice(0, 200)) })
const downloads = []; p.on('download', d => downloads.push(d))
await p.goto(url); await p.waitForTimeout(12000)
const btn = p.locator('button.export-poster-btn').first()
const onScreen = await btn.evaluate(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight }).catch(() => false)
if (!onScreen) errs.push('the export button is not on screen without a prior click')
const label = ((await btn.getAttribute('title', { timeout: 20000 }).catch(() => '')) || '').trim()
let clicks = 0
// ONE PHYSICAL CLICK at the button's centre, as a reader makes it. Not locator.click(): that
// re-checks its hit target and RETRIES, and by the retry the button is already disabled (the
// export is running), so it reported "timeout" on exports whose file then arrived — measured
// 2026-09-25 in gallery-ar and leaf-en. What the click achieved is judged by the file below.
try {
const box = await btn.boundingBox({ timeout: 20000 })
if (!box) throw new Error('the export button has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2); clicks = 1
} catch (e) { errs.push('could not click: ' + String(e.message || e).split('\n')[0]) }
// Poll every frame: the print call happens in the iframe printInPlace creates, which is not
// attached when the click is made. A frame that has gone away is skipped rather than failing the
// poll — the app reclaims its print frame, and a reclaimed frame is a completed print, not an error.
const readPrints = async () => {
const out = []
for (const f of p.frames()) {
const got = await f.evaluate(() => window.__printed || []).catch(() => [])
out.push(...got)
}
return out
}
let prints = []
for (let i = 0; i < 120 && !prints.length; i++) { await p.waitForTimeout(1000); prints = await readPrints() }
let info = ''
if (prints.length) {
const pr = prints[0]
info = `${pr.imgW}x${pr.imgH} on @page ${pr.page || '(none)'}`
// The poster, at the size it was rendered, on the sheet it was rendered for.
if (pr.imgW < 1600 || pr.imgH < 2100) errs.push(`the printed document is not the poster (${info})`)
if (!/8\.5in\s+11in/.test(pr.page || '')) errs.push(`the sheet is not the poster's page size (@page ${pr.page || 'missing'})`)
} else {
for (const c of consoleErrs.filter(c => /poster|print|useMapPoster/i.test(c)).slice(-2)) errs.push(c)
// SLOW or DEAD? A button still busy at the deadline is a render still running; an idle one is an
// export that ended without opening anything. The verdict is FAIL either way; the reason says which.
const busy = await btn.evaluate(el => el.disabled || el.getAttribute('aria-busy') === 'true').catch(() => null)
errs.push(busy ? 'still rendering at the deadline (button busy)' : 'the export ended with no print dialog (button idle)')
}
// ★ THE NEW RULE'S OTHER HALF. A button that opens the dialog AND saves a file still breaks it.
if (downloads.length) errs.push(`the button downloaded a file (${downloads[0].suggestedFilename()}) instead of only printing`)
const ok = clicks === 1 && prints.length === 1 && !downloads.length && !errs.length
// Exactly one dialog: two print frames would mean the click was handled twice, which a reader sees
// as a second dialog appearing behind the first.
if (clicks === 1 && prints.length > 1) errs.push(`${prints.length} print dialogs opened on one click`)
console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}${PROVIDER ? ' ' + PROVIDER : ''}] "${label}" · 1 click → ${prints.length ? `print dialog (${info})` : 'NO PRINT DIALOG within 120s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors, 0 downloads'}`)
if (!ok) fails.push(`${where} ${lang}`)
await ctx.close()
}
} finally { await b.close(); cleanup() }
if (fails.length) { console.log(`\ncheck-export-poster-prints: FAIL (${fails.join(', ')})`); process.exit(1) }
console.log('\ncheck-export-poster-prints: PASS — one click opens the print dialog on the poster and downloads nothing, leaf and gallery, en and ar')