// Headless smoke test: the page loads, throws no errors, and the canvas has painted something. // Zero dependencies: drives google-chrome over the DevTools protocol with Node's global WebSocket. // Usage: node scripts/smoke.mjs exit 0 = painted and clean, 1 = problem (reason printed) import { spawn } from 'node:child_process' import { mkdtempSync, rmSync } from 'node:fs' import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' const url = process.argv[2] if (!url) { console.error('usage: smoke.mjs '); process.exit(2) } const chromePath = process.env.CHROME ?? '/usr/bin/google-chrome' const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const freePort = () => new Promise((resolve, reject) => { const s = createServer() s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) }) s.on('error', reject) }) const dir = mkdtempSync(join(tmpdir(), 'smoke-')) const port = await freePort() const chrome = spawn(chromePath, [ '--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${dir}`, '--no-sandbox', '--disable-gpu', '--no-first-run', 'about:blank', ], { stdio: 'ignore' }) const cleanup = () => { chrome.kill('SIGKILL'); rmSync(dir, { recursive: true, force: true }) } const finish = (code, msg) => { if (msg) console.log(msg); cleanup(); process.exit(code) } process.on('SIGINT', () => finish(1, 'interrupted')) const timer = setTimeout(() => finish(1, 'FAIL: timed out'), 30000) try { // Wait for the debugging endpoint, then find the page target. let target for (let i = 0; i < 100 && !target; i++) { try { const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json() target = list.find((t) => t.type === 'page') } catch { await sleep(100) } } if (!target) finish(1, 'FAIL: could not connect to Chrome') const ws = new WebSocket(target.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject }) const errors = [] const pending = new Map() let id = 0 const send = (method, params = {}) => new Promise((resolve) => { pending.set(++id, resolve) ws.send(JSON.stringify({ id, method, params })) }) ws.onmessage = ({ data }) => { const m = JSON.parse(data) if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id) } else if (m.method === 'Runtime.exceptionThrown') { const d = m.params.exceptionDetails errors.push(`exception: ${d.exception?.description ?? d.text}`) } else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') { errors.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description).join(' ')}`) } else if (m.method === 'Log.entryAdded' && m.params.entry.level === 'error') { errors.push(`log: ${m.params.entry.text} ${m.params.entry.url ?? ''}`) } } await send('Runtime.enable') await send('Log.enable') await send('Page.enable') await send('Page.navigate', { url }) await sleep(1000) // Does the canvas hold at least one pixel that is not the #111111 background (and not transparent)? const check = `(() => { const c = document.querySelector('canvas') if (!c) return 'no canvas' const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data for (let i = 0; i < d.length; i += 4) if (d[i + 3] !== 0 && (d[i] !== 0x11 || d[i + 1] !== 0x11 || d[i + 2] !== 0x11)) return 'painted' return 'blank' })()` const res = await send('Runtime.evaluate', { expression: check, returnByValue: true }) const state = res.result?.value ?? `evaluation failed: ${JSON.stringify(res)}` const problems = [...(state === 'painted' ? [] : [`canvas: ${state}`]), ...errors] clearTimeout(timer) if (problems.length) finish(1, `FAIL ${url}\n ${problems.join('\n ')}`) finish(0, `OK ${url}: canvas painted, no errors`) } catch (e) { finish(1, `FAIL: ${e.message}`) }