118 lines
8.4 KiB
JavaScript
118 lines
8.4 KiB
JavaScript
#!/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')
|