#!/usr/bin/env node /* * Headless render check — proves the 3D canvas actually renders. * * No dependencies: uses Chrome's DevTools protocol over Node's built-in fetch/WebSocket. * Works without a GPU via SwiftShader (CPU rasterisation). * * node scripts/headless-check.mjs * CHROME=/path/to/chrome node scripts/headless-check.mjs * * Exits non-zero if the canvas renders black or the shaders fail to compile. */ import { spawn, execSync } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { rmSync, mkdtempSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, extname, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const dist = join(root, 'dist'); const PORT = Number(process.env.CHECK_PORT || 4176); const CDP_PORT = Number(process.env.CDP_PORT || 9333); function findChrome() { if (process.env.CHROME) return process.env.CHROME; const candidates = [ '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', ]; for (const c of candidates) { if (existsSync(c)) return c; } // last resort: ask the shell try { return execSync('command -v google-chrome chromium chromium-browser 2>/dev/null | head -1').toString().trim() || null; } catch { return null; } } const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ico': 'image/x-icon' }; function serveStatic() { return new Promise((resolve) => { const server = createServer(async (req, res) => { const path = req.url === '/' ? '/index.html' : req.url.split('?')[0]; try { const body = await readFile(join(dist, '.' + path)); res.writeHead(200, { 'content-type': MIME[extname(path)] || 'application/octet-stream' }); res.end(body); } catch { res.writeHead(404).end('not found'); } }); server.listen(PORT, '127.0.0.1', () => resolve(server)); }); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitForCDP(port, attempts = 60) { for (let i = 0; i < attempts; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) return r.json(); } catch {} await sleep(500); } throw new Error('Chrome CDP endpoint never became available'); } class CDP { constructor(wsUrl) { this.ws = new WebSocket(wsUrl); this.id = 0; this.pending = new Map(); this.listeners = new Map(); } open() { return new Promise((resolve, reject) => { this.ws.addEventListener('open', resolve); this.ws.addEventListener('error', reject); this.ws.addEventListener('message', (ev) => { let parsed; try { parsed = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString()); } catch { return; } this.handle(parsed); }); }); } send(method, params = {}) { const id = ++this.id; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP call '${method}' timed out after 20s`)); }, 20000); this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); }, }); this.ws.send(JSON.stringify({ id, method, params })); }); } on(method, fn) { if (!this.listeners.has(method)) this.listeners.set(method, []); this.listeners.get(method).push(fn); } handle(event) { if (event.id && this.pending.has(event.id)) { const { resolve, reject } = this.pending.get(event.id); this.pending.delete(event.id); event.error ? reject(new Error(event.error.message)) : resolve(event.result); return; } if (event.method && this.listeners.has(event.method)) { for (const fn of this.listeners.get(event.method)) fn(event.params); } } async evaluate(expression) { const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }); if (r.exceptionDetails) { throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); } return r.result.value; } close() { try { this.ws.close(); } catch {} } } const chrome = findChrome(); if (!chrome) { console.error('No Chrome/Chromium found. Set CHROME=/path/to/chrome'); process.exit(2); } // Watchdog: a render check must never hang a CI runner. const watchdog = setTimeout(() => { console.error('HEADLESS CHECK FAILED: watchdog expired after 120s'); process.exit(1); }, 120000); watchdog.unref(); const server = await serveStatic(); const profile = mkdtempSync(join(tmpdir(), 'headless-check-')); const args = [ '--headless=new', '--no-sandbox', '--disable-gpu-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', `--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank', ]; const proc = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; proc.stderr.on('data', (d) => { stderr += d.toString(); }); let exitCode = 1; try { await waitForCDP(CDP_PORT); const targets = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`)).json(); const page = targets.find((t) => t.type === 'page'); if (!page) throw new Error('no page target'); const cdp = new CDP(page.webSocketDebuggerUrl); await cdp.open(); await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); // Hook getContext before the page's own scripts run, so we can read back the // game's real WebGL context. await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: ` (function () { const orig = HTMLCanvasElement.prototype.getContext; window.__gl = null; HTMLCanvasElement.prototype.getContext = function (type, ...rest) { const c = orig.call(this, type, ...rest); if (c && (type === 'webgl2' || type === 'webgl') && this.id === 'game-canvas') window.__gl = c; return c; }; window.__shaderErrors = []; window.addEventListener('error', (e) => window.__shaderErrors.push(String(e.message))); })(); `, }); const consoleErrors = []; cdp.on('Runtime.consoleAPICalled', (p) => { if (p.type === 'error') consoleErrors.push(p.args.map((a) => a.value ?? a.description ?? '').join(' ')); }); cdp.on('Runtime.exceptionThrown', (p) => consoleErrors.push(p.exceptionDetails.exception?.description || p.exceptionDetails.text)); await cdp.send('Page.navigate', { url: `http://127.0.0.1:${PORT}/` }); await sleep(9000); const sample = () => cdp.evaluate(`(async () => { await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); const gl = window.__gl; if (!gl) return { err: 'no webgl context captured' }; const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight; const px = new Uint8Array(w * h * 4); gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); const colors = new Map(); let nonBlack = 0; for (let i = 0; i < px.length; i += 4) { if (px[i] || px[i+1] || px[i+2]) nonBlack++; const k = (px[i] >> 4) + ',' + (px[i+1] >> 4) + ',' + (px[i+2] >> 4); colors.set(k, (colors.get(k) || 0) + 1); } let hash = 0; for (let i = 0; i < px.length; i += 16) hash = (hash * 31 + px[i] * 3 + px[i+1] * 7 + px[i+2] * 11) >>> 0; return { buffer: w + 'x' + h, distinctColors: colors.size, nonBlack, total: w * h, hash, topColors: [...colors.entries()].sort((a,b) => b[1]-a[1]).slice(0,5).map(([k,v]) => 'rgb(' + k + ') x' + v) }; })()`); const menu = await cdp.evaluate(`document.body.innerText.replace(/\\s+/g,' ').slice(0,80)`); const idle = await sample(); // drive the aircraft: full throttle, then rotate const VK = { Enter: 13, ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39, KeyW: 87, KeyS: 83, KeyC: 67, Escape: 27 }; const key = async (type, code) => { await cdp.send('Input.dispatchKeyEvent', { type, key: code === 'Enter' ? 'Enter' : code.replace(/^Key/, '').toLowerCase().replace(/^Arrow/, 'Arrow'), code, windowsVirtualKeyCode: VK[code], nativeVirtualKeyCode: VK[code], }); }; const press = async (code) => { await key('keyDown', code); await key('keyUp', code); }; await press('Enter'); await sleep(2500); await press('Enter'); await sleep(5000); for (let i = 0; i < 15; i++) { await press('KeyW'); await sleep(120); } await key('keyDown', 'ArrowUp'); await sleep(3000); await key('keyUp', 'ArrowUp'); await sleep(1500); const flown = await sample(); const shaderErrors = consoleErrors.filter((e) => /Shader Error|not compiled|WebGLProgram/i.test(e)); const report = { chrome: chrome.replace(/^.*\/chrome-linux64\//, ''), menuText: menu, idle, flown, shaderErrors, consoleErrors: consoleErrors.filter((e) => !/Shader Error|not compiled|WebGLProgram/i.test(e)).slice(0, 5), }; console.log(JSON.stringify(report, null, 2)); const failures = []; if (shaderErrors.length) failures.push(`shader compile errors: ${shaderErrors.length}`); if (idle.err) failures.push(idle.err); if (!idle.err && idle.distinctColors < 8) failures.push(`canvas looks blank: only ${idle.distinctColors} distinct colours`); if (!idle.err && idle.nonBlack / idle.total < 0.5) failures.push(`canvas mostly black: ${idle.nonBlack}/${idle.total} lit pixels`); if (!flown.err && flown.hash === idle.hash) failures.push('scene did not change after throttle + pitch input'); if (failures.length) { console.error('\nHEADLESS CHECK FAILED:\n ' + failures.join('\n ')); exitCode = 1; } else { console.log('\nHEADLESS CHECK PASSED'); exitCode = 0; } cdp.close(); } catch (e) { console.error('headless check errored:', e.message); if (stderr) console.error('chrome stderr:', stderr.slice(-800)); exitCode = 1; } finally { proc.kill(); server.close(); try { rmSync(profile, { recursive: true, force: true }); } catch {} } process.exit(exitCode);