#!/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 agent, 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 agent 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
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('../../../internal/lib/playwright-guarded.mjs')) } 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(`
`) }).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')