2026-10-07 13:31:32 +00:00
|
|
|
#!/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);
|
|
|
|
|
|
2026-10-07 13:33:16 +00:00
|
|
|
if (!existsSync(join(dist, 'index.html'))) {
|
|
|
|
|
console.error('HEADLESS CHECK FAILED: dist/index.html not found. Run `npm run build` first.');
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 13:31:32 +00:00
|
|
|
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);
|