156 lines
11 KiB
JavaScript
156 lines
11 KiB
JavaScript
#!/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 <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('../../../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(`<!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')
|