Files
retro-flight-sim/scripts/headless-check.mjs
T
Hermes Cron Job ee24276fd0 test: real shader/terrain tests + headless render check
npm test was a brace counter: it checked each file was >10 chars and that {
and } counts matched. It passed on a build whose shader would not compile.

- tests/shaders.test.js compiles the game's real GLSL with glslangValidator
  (Khronos' reference compiler) under a three.js-style attribute prefix, and
  includes a canary that asserts a known-bad vec4 construction is REJECTED, so
  the suite cannot pass vacuously.
- tests/terrain.test.js covers heightfield determinism (the "persistent world"
  claim), airport flattening inside the 200 m radius with no leakage outside it,
  out-of-bounds behaviour, plausible height range, and vertex colour counts.
- scripts/headless-check.mjs (npm run test:render) serves dist/, drives the
  game over raw CDP with SwiftShader software WebGL, and fails if the canvas is
  black, the shaders fail to compile, or the scene does not change after input.
  No new dependencies: Node 22 built-in fetch/WebSocket. 120 s watchdog so it
  cannot hang a runner.
- npm test -> node --test "tests/*.test.js"; old checker kept as npm run lint:syntax.
- CI installs glslang-tools and runs the real tests; render job is
  continue-on-error because not every runner has a Chrome binary.

Verified non-vacuous: re-breaking the shader makes the unit tests fail 2/4 with
glslang's own "constructor: too many arguments" and makes the render check fail
four ways (1 distinct colour, 0/64000 lit pixels, shader error, frozen scene).
2026-10-07 13:31:32 +00:00

292 lines
10 KiB
JavaScript

#!/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);