Files
functional-programming-snake/scripts/smoke.mjs
T

93 lines
4.1 KiB
JavaScript

// 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 <url> 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 <url>'); 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', detached: true })
// Chrome forks helper processes; kill the whole process group, then remove the profile dir.
const cleanup = () => {
try { process.kill(-chrome.pid, 'SIGKILL') } catch {}
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
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}`)
}