Without dist/ it reported 'no webgl context captured', which points at WebGL when the actual problem was that there was no page to load.
297 lines
10 KiB
JavaScript
297 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);
|
|
|
|
if (!existsSync(join(dist, 'index.html'))) {
|
|
console.error('HEADLESS CHECK FAILED: dist/index.html not found. Run `npm run build` first.');
|
|
process.exit(1);
|
|
}
|
|
|
|
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);
|