doxa-maps/plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs
Doxa Maps d3967fe37a
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 2ff0ecba
2026-09-25 23:12:08 +00:00

118 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 agent: "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')