AtlatestRepositorysigil-graphics
sigil-graphics / tree / test / test-shader-limitsverify.mjs
1
// verify.mjs - reads the presented frame of test-shader-limits on either2
// target and asserts every exhibit against what the scene must show.3
//4
// node verify.mjs --native [--bin build/dev/bin/test-shader-limits]5
// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]6
// node verify.mjs --web [build/web] [--port 8097] [--cdp 9237]7
// [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]8
// node verify.mjs --native|--web --bench [--grid 512] [--reports 3]9
// node verify.mjs --native|--web --rule reaction --steps 4000 --substeps 8 [--grid 128]10
//11
// Native: runs the binary on $DISPLAY (an Xvfb the caller owns), waits for12
// its "shader-limits: ready" line, captures the window with ImageMagick's13
// `import -window` (so `import`, `identify`, `convert` and `xdotool` must be14
// on PATH: run inside `guix shell -m ../../manifest.scm imagemagick xdotool`,15
// with LD_LIBRARY_PATH set for libGL, see README.md).16
// Web: serves the build dir on loopback, launches google-chrome headless with17
// software WebGL (SwiftShader), drives it over the DevTools Protocol (Node18
// 22's built-in WebSocket), and reads the canvas inside an animation frame.19
//20
// Sub-arms, each PASS/FAIL <name>: <detail>; any FAIL exits 1; a wait that21
// runs out prints TIMED-OUT and exits 2:22
// imports (web) the wasm imports only gl, sigil_wasm_gles3 and wasi23
// probes every refusal probe the scene runs said ok, none FAIL24
// update update-texture landed on frame 2 ("shader-limits: update ok")25
// ready the ready line names the expected grid and step count26
// field every cell of the GRIDxGRID Life field matches a CPU Life run27
// of the same seed for --expect-steps generations on a torus28
// palette the eight vec4 palette[8] strips and the four float levels[4]29
// strips show the array entries30
// pixels the 4x4 uploaded texture shows the post-update pattern31
// console (web) zero error-level console entries and zero exceptions32
//33
// --expect-steps defaults to --steps; passing a different value is the34
// assertion-layer sabotage: the field must then FAIL.35
// --bench runs the app in bench mode and prints its frame-ms lines; no36
// verdict, it is the measurement for the Session Log.38
import http from "node:http";39
import fs from "node:fs";40
import path from "node:path";41
import { spawn, execFileSync } from "node:child_process";43
const args = process.argv.slice(2);44
const flag = (name) => args.includes(name);45
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };46
const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold", "--rule", "--substeps"];47
const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));49
const NATIVE = flag("--native");50
const WEB = flag("--web");51
const BENCH = flag("--bench");52
if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }53
const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);54
const STEPS = parseInt(opt("--steps", "8"), 10);55
const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);56
const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);57
const REPORTS = parseInt(opt("--reports", "3"), 10);58
const HOLD = parseInt(opt("--hold", "60"), 10);59
const RULE = opt("--rule", "life");60
const SUBSTEPS = parseInt(opt("--substeps", "1"), 10);61
const REACTION = RULE === "reaction";63
// ---- the scene's geometry, mirrored from scene.sgl ---------------------------64
const VW = 640, VH = 480;65
const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;66
const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;67
const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;68
const PALETTE = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255], [255, 0, 255], [255, 128, 0], [128, 0, 255]];69
const LEVELS = [0, 64, 128, 255];70
const PATTERN_B = [71
[0, 255, 0], [255, 255, 0], [0, 255, 0], [255, 255, 0],72
[255, 255, 0], [255, 255, 255], [255, 255, 0], [0, 255, 0],73
[0, 255, 0], [255, 255, 0], [0, 0, 0], [255, 255, 0],74
[255, 255, 0], [0, 255, 0], [255, 255, 0], [0, 255, 0],75
];76
const PROBES = ["make-texture-size", "update-texture-size", "update-same-frame", "update-immutable",77
"shader-compile-error", "uniform-block-too-large", "uniform-too-many",78
"uniform-count", "uniform-scalar-for-array", "filter-name", "uniform-nested", "uniform-flat-vector"];80
// The seed, as scene.sgl draws it, and a CPU Life on a torus.81
function seedGrid(n) {82
const g = new Uint8Array(n * n);83
const set = (x, y) => { g[((y + n) % n) * n + ((x + n) % n)] = 1; };84
const s = n - 3;85
set(s + 1, s); set(s + 2, s + 1); set(s, s + 2); set(s + 1, s + 2); set(s + 2, s + 2);86
set(9, 10); set(10, 10); set(11, 10);87
set(20, 20); set(21, 20); set(20, 21); set(21, 21);88
return g;89
}90
function lifeStep(g, n) {91
const out = new Uint8Array(n * n);92
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {93
let c = 0;94
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {95
if (dx || dy) c += g[((y + dy + n) % n) * n + ((x + dx + n) % n)];96
}97
const alive = g[y * n + x];98
out[y * n + x] = (c === 3 || (c === 2 && alive)) ? 1 : 0;99
}100
return out;101
}102
function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }104
// ---- verdicts -------------------------------------------------------------------105
const results = [];106
const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);107
function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }108
function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }109
function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }110
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));112
// A captured frame: RGBA bytes, buffer size, and the virtual-to-buffer scale.113
// px(vx, vy) samples the buffer pixel under the centre of virtual pixel (vx, vy).114
function makeFrame(data, w, h) {115
const sx = w / VW, sy = h / VH;116
return {117
w, h, sx, sy,118
px(vx, vy) {119
const bx = Math.min(w - 1, Math.floor((vx + 0.5) * sx)), by = Math.min(h - 1, Math.floor((vy + 0.5) * sy));120
const i = (by * w + bx) * 4;121
return [data[i], data[i + 1], data[i + 2]];122
},123
};124
}125
const near = (a, b, tol) => Math.abs(a[0] - b[0]) <= tol && Math.abs(a[1] - b[1]) <= tol && Math.abs(a[2] - b[2]) <= tol;127
function checkLines(lines) {128
// probes129
const seen = new Map();130
for (const l of lines) { const m = l.match(/^shader-limits: probe (\S+) (ok|FAIL)(.*)$/); if (m) seen.set(m[1], m[2] + m[3]); }131
const missing = PROBES.filter((p) => !seen.has(p));132
const failed = [...seen].filter(([, v]) => v.startsWith("FAIL")).map(([k, v]) => `${k}${v.slice(4)}`);133
if (!missing.length && !failed.length) pass("probes", `${PROBES.length} probes ok`);134
else fail("probes", `${missing.length ? "missing " + missing.join(",") + "; " : ""}${failed.length ? "failed: " + failed.join(" | ") : ""}`);135
// update136
const upd = lines.find((l) => /^shader-limits: update /.test(l));137
if (upd === "shader-limits: update ok") pass("update", "update-texture landed on frame 2");138
else fail("update", upd || "no update line");139
// ready140
const rdy = lines.map((l) => l.match(/^shader-limits: ready grid=(\d+) steps=(\d+)$/)).find(Boolean);141
if (rdy && parseInt(rdy[1], 10) === GRID && parseInt(rdy[2], 10) === EXPECT_STEPS) pass("ready", rdy[0]);142
else fail("ready", rdy ? `${rdy[0]} (expected grid=${GRID} steps=${EXPECT_STEPS})` : "no ready line");143
}145
function checkFrame(frame) {146
if (REACTION) { checkReaction(frame); checkStripsAndPixels(frame); return; }147
// field148
const exp = expectedField(GRID, EXPECT_STEPS);149
const cell = FIELD_PX / GRID;150
let mismatches = 0, live = 0, seenLive = 0; const firstBad = [];151
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {152
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);153
const lit = c[0] > 128 ? 1 : 0;154
live += exp[y * GRID + x]; seenLive += lit;155
if (lit !== exp[y * GRID + x]) { mismatches++; if (firstBad.length < 6) firstBad.push(`(${x},${y}) got ${lit} want ${exp[y * GRID + x]}`); }156
}157
if (mismatches === 0 && live >= 12) pass("field", `${GRID}x${GRID} cells match a CPU Life after ${EXPECT_STEPS} steps; ${live} live cells (glider, blinker, block)`);158
else fail("field", `${mismatches} of ${GRID * GRID} cells differ (expected ${live} live, saw ${seenLive}); first: ${firstBad.join("; ")}`);159
checkStripsAndPixels(frame);160
}162
function checkStripsAndPixels(frame) {163
// palette164
const bad = [];165
for (let i = 0; i < 8; i++) {166
const c = frame.px(PAL_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);167
if (!near(c, PALETTE[i], 12)) bad.push(`palette[${i}] got ${c} want ${PALETTE[i]}`);168
}169
for (let i = 0; i < 4; i++) {170
const c = frame.px(LEV_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);171
if (!near(c, [LEVELS[i], LEVELS[i], LEVELS[i]], 12)) bad.push(`levels[${i}] got ${c} want ${LEVELS[i]}`);172
}173
if (!bad.length) pass("palette", "8 vec4 palette entries and 4 float levels read back through the shader");174
else fail("palette", bad.join("; "));175
// pixels176
const badPx = [];177
for (let t = 0; t < 16; t++) {178
const tx = t % 4, ty = Math.floor(t / 4);179
const c = frame.px(PIX_X + tx * PIX_SCALE + PIX_SCALE / 2, PIX_Y + ty * PIX_SCALE + PIX_SCALE / 2);180
if (!near(c, PATTERN_B[t], 12)) badPx.push(`texel ${tx},${ty} got ${c} want ${PATTERN_B[t]}`);181
}182
if (!badPx.length) pass("pixels", "16 texels show the update-texture pattern");183
else fail("pixels", badPx.join("; "));184
}186
// Gray-Scott has no closed form to compare against, so the reaction field is187
// judged by shape: v (the G channel) must have grown from three seed squares188
// (together 4.7% of the field) into a pattern covering between 6% and189
// 70% of the cells, while u (R) is still high somewhere (the field has not190
// saturated), and the pattern must not be one flat value.191
function checkReaction(frame) {192
const cell = FIELD_PX / GRID;193
let vHigh = 0, uHigh = 0; const vBins = new Set();194
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {195
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);196
if (c[1] > 64) vHigh++;197
if (c[0] > 192) uHigh++;198
vBins.add(c[1] >> 4);199
}200
const total = GRID * GRID, vf = vHigh / total, uf = uHigh / total;201
const detail = `v>0.25 on ${(vf * 100).toFixed(1)}% of cells, u>0.75 on ${(uf * 100).toFixed(1)}%, ${vBins.size} v levels`;202
// the three seed squares cover 3/64 = 4.7% of the field; growth past 6% is the pattern203
if (vf >= 0.06 && vf <= 0.7 && uf >= 0.2 && vBins.size >= 6) pass("field", `reaction pattern grew: ${detail}`);204
else fail("field", `no reaction pattern: ${detail}`);205
}207
// sokol logs through slog_func: natively sigil-app's format `[sg][error][id:N] ...`208
// on stderr, on the web sigil-graphics' own `sokol[level=L] file:line [tag] msg`209
// on the console (level 0 panic, 1 error, 2 warning, 3 info; the web build210
// has no SOKOL_DEBUG (zig cc defines NDEBUG at -Oz, measured 2026-09-19 with211
// `zig cc -Oz -target wasm32-wasi -E` on an #ifdef NDEBUG probe) so the212
// message text is empty and the line number is213
// the pointer). A render target whose pass sokol refuses draws nothing and214
// says so only here, so any error-level line is a failure.215
// The shader-compile-error probe fails one GLSL compile on purpose, which216
// sokol reports as GL_SHADER_COMPILATION_FAILED (item id 7; sokol_gfx.h:10828217
// in the vendored header): exactly one such line is expected and exempt.218
const COMPILE_FAIL = (l) => /\[sg\]\[error\]\[id:7\]/.test(l) || /^sokol\[level=1\] \S+:10828 \[sg\]/.test(l);219
function checkGlLog(lines) {220
const all = lines.filter((l) => /\[(sg|sgp|sapp)\]\[(error|panic)\]/.test(l) || /^sokol\[level=[01]\]/.test(l));221
const expected = all.filter(COMPILE_FAIL);222
const bad = all.filter((l) => !COMPILE_FAIL(l)).concat(expected.slice(1));223
if (expected.length !== 1) bad.push(`expected exactly one deliberate compile failure line, saw ${expected.length}`);224
const warn = lines.filter((l) => /\[(sg|sgp)\]\[warning\]/.test(l) || /^sokol\[level=2\]/.test(l));225
if (!bad.length) pass("gl-log", `no sokol error lines beyond the one deliberate compile failure${warn.length ? "; " + warn.length + " warning(s): " + warn.slice(0, 3).join(" | ") : ""}`);226
else fail("gl-log", `${bad.length} sokol error line(s): ${bad.slice(0, 4).join(" | ")}`);227
}229
function finish() {230
const failed = results.filter((r) => r[1] === "FAIL").length;231
const nr = notRun();232
console.log(`${failed ? "RED" : "GREEN"}: ${results.length - failed} pass, ${failed} fail${nr.length ? "; did not run: " + nr.join(" ") : ""}`);233
return failed || nr.length ? 1 : 0;234
}236
// =============================================================================237
// NATIVE ARM238
// =============================================================================239
if (NATIVE) {240
const bin = path.resolve(opt("--bin", "build/dev/bin/test-shader-limits"));241
if (!fs.existsSync(bin)) { console.log(`SETUP-FAILED: ${bin} missing; build first`); process.exit(2); }242
if (!process.env.DISPLAY) { console.log("SETUP-FAILED: DISPLAY is not set; point it at a worker-owned Xvfb"); process.exit(2); }243
for (const tool of ["xdotool", "import", "convert", "identify"]) {244
try { execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }); }245
catch { console.log(`SETUP-FAILED: ${tool} is not on PATH (run inside guix shell -m ../../manifest.scm imagemagick xdotool)`); process.exit(2); }246
}247
const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD), "--rule", RULE, "--substeps", String(SUBSTEPS)].concat(BENCH ? ["--bench"] : []);248
const child = spawn(bin, appArgs, { stdio: ["ignore", "pipe", "pipe"], detached: true });249
const lines = [];250
let buf = "";251
child.stdout.on("data", (d) => { buf += d.toString(); let i; while ((i = buf.indexOf("\n")) >= 0) { lines.push(buf.slice(0, i)); buf = buf.slice(i + 1); } });252
const stderrLines = [];253
child.stderr.on("data", (d) => { for (const l of d.toString().split("\n")) if (l.trim()) stderrLines.push(l); });254
let exited = null;255
child.on("exit", (code, sig) => { exited = { code, sig }; });256
const stop = () => { try { process.kill(-child.pid, "SIGTERM"); } catch { /* gone */ } };257
process.on("exit", stop);259
const waitLine = async (re, ms) => {260
const t0 = Date.now();261
while (Date.now() - t0 < ms) {262
const hit = lines.find((l) => re.test(l));263
if (hit) return hit;264
if (exited) return null;265
await sleep(100);266
}267
return null;268
};270
if (BENCH) {271
const t0 = Date.now();272
while (Date.now() - t0 < HOLD * 1000 + 5000) {273
const reports = lines.filter((l) => /^shader-limits: frame-ms /.test(l));274
if (reports.length >= REPORTS || exited) break;275
await sleep(200);276
}277
console.log(`bench native grid=${GRID} (renderer: ${glRenderer()})`);278
for (const l of lines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);279
stop();280
process.exit(0);281
}283
const ready = await waitLine(/^shader-limits: ready /, 30000);284
if (!ready) {285
console.log(`TIMED-OUT ready: no ready line in 30 s${exited ? ` (exited ${JSON.stringify(exited)})` : ""}; did not run: ${notRun().join(" ")}`);286
console.log("stdout so far:\n " + lines.join("\n ")); console.log("stderr so far:\n " + stderrLines.join("\n "));287
stop(); process.exit(2);288
}289
checkLines(lines);290
// The frame that shows the settled state was presented before the line291
// was printed, and the app keeps presenting it; one more frame's worth of292
// time makes sure the X server has it before the capture.293
await sleep(300);294
let xid = "";295
for (let i = 0; i < 50 && !xid; i++) {296
try { xid = execFileSync("xdotool", ["search", "--name", "test-shader-limits"]).toString().trim().split("\n")[0]; } catch { /* not yet */ }297
if (!xid) await sleep(100);298
}299
if (!xid) { console.log("SETUP-FAILED: no X window named test-shader-limits; did not run: " + notRun().join(" ")); stop(); process.exit(2); }300
execFileSync("import", ["-window", xid, SHOT]);301
const [w, h] = execFileSync("identify", ["-format", "%w %h", SHOT]).toString().trim().split(" ").map(Number);302
const raw = execFileSync("convert", [SHOT, "-depth", "8", "rgba:-"], { maxBuffer: 64 * 1024 * 1024 });303
if (raw.length !== w * h * 4) { console.log(`SETUP-FAILED: raw dump is ${raw.length} bytes for ${w}x${h}`); stop(); process.exit(2); }304
console.log(`note: captured ${w}x${h} window to ${SHOT}`);305
checkFrame(makeFrame(raw, w, h));306
checkGlLog(stderrLines.concat(lines));307
stop();308
process.exit(finish());309
}311
function glRenderer() {312
try { return execFileSync("sh", ["-c", "glxinfo -B 2>/dev/null | grep -i 'renderer string' | head -1"]).toString().trim() || "glxinfo unavailable"; }313
catch { return "glxinfo unavailable"; }314
}316
// =============================================================================317
// WEB ARM318
// =============================================================================319
const ROOT = path.resolve(positional[0] || "build/web");320
const PORT = parseInt(opt("--port", "8097"), 10);321
const CDP = parseInt(opt("--cdp", "9237"), 10);322
const TYPES = { ".html": "text/html;charset=utf-8", ".js": "text/javascript;charset=utf-8", ".wasm": "application/wasm",323
".json": "application/json;charset=utf-8", ".css": "text/css;charset=utf-8", ".png": "image/png" };325
// ---- imports, from the file on disk ------------------------------------------326
{327
const wasmPath = path.join(ROOT, "test-shader-limits.wasm");328
if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing; build --config web first`); process.exit(2); }329
const mod = new WebAssembly.Module(fs.readFileSync(wasmPath));330
const byModule = {};331
for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name);332
const modules = Object.keys(byModule).sort();333
const expected = ["gl", "sigil_wasm_gles3", "wasi_snapshot_preview1"];334
const listing = modules.map((m) => `${m}(${byModule[m].length})`).join(" ");335
if (JSON.stringify(modules) === JSON.stringify(expected)) pass("imports", listing);336
else fail("imports", `expected modules ${expected.join(",")} got ${listing}`);337
}339
const server = http.createServer((req, res) => {340
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);341
const fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath);342
if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; }343
fs.readFile(fp, (err, buf) => {344
if (err) { res.writeHead(404).end("not found: " + urlPath); return; }345
res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream", "Cache-Control": "no-store" });346
res.end(buf);347
});348
});349
await new Promise((r) => server.listen(PORT, "127.0.0.1", r));351
const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");352
const chrome = spawn("google-chrome", [353
"--headless=new", "--no-sandbox", "--disable-dev-shm-usage",354
"--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",355
"--enable-webgl", "--ignore-gpu-blocklist",356
`--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`,357
"--window-size=800,600", "about:blank",358
], { stdio: "ignore", detached: true });359
function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }360
let exiting = false;361
function shutdown(code) {362
if (exiting) return; exiting = true;363
try { server.close(); } catch { /* not listening */ }364
killChromeGroup("SIGTERM");365
setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref();366
const done = () => { try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); };367
if (chrome.exitCode !== null) done(); else chrome.once("exit", done);368
}369
process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });370
process.on("SIGINT", () => shutdown(130));371
process.on("SIGTERM", () => shutdown(143));372
process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });373
process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });374
setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();376
let pageWs = null;377
for (let i = 0; i < 80 && !pageWs; i++) {378
try { const ts = await (await fetch(`http://127.0.0.1:${CDP}/json`)).json(); const p = ts.find((t) => t.type === "page"); if (p && p.webSocketDebuggerUrl) pageWs = p.webSocketDebuggerUrl; } catch { /* not up yet */ }379
await sleep(250);380
}381
if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }383
const ws = new WebSocket(pageWs);384
let msgId = 0; const pending = new Map();385
const consoleLines = []; const consoleErrors = [];386
function send(method, params = {}) {387
return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });388
}389
ws.addEventListener("message", (ev) => {390
const msg = JSON.parse(ev.data);391
if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); return; }392
if (msg.method === "Runtime.consoleAPICalled") {393
const text = (msg.params.args || []).map((a) => a.value ?? a.description ?? "").join(" ");394
consoleLines.push(text);395
if (msg.params.type === "error") consoleErrors.push("console.error: " + text);396
}397
if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") consoleErrors.push("log: " + msg.params.entry.text);398
if (msg.method === "Runtime.exceptionThrown") consoleErrors.push("exception: " + (msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text));399
});400
await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });401
await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");402
// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual403
// size: the sampler's scale mapping is then exercised, not the identity.404
await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });406
async function evalJS(expr) {407
const r = await send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true });408
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));409
return r.result.value;410
}411
async function waitLine(re, ms) {412
const t0 = Date.now();413
while (Date.now() - t0 < ms) { const hit = consoleLines.find((l) => re.test(l)); if (hit) return hit; await sleep(100); }414
return null;415
}417
const query = `?grid=${GRID}&steps=${STEPS}&rule=${RULE}&substeps=${SUBSTEPS}${BENCH ? "&bench" : ""}`;418
await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });420
if (BENCH) {421
const t0 = Date.now();422
while (Date.now() - t0 < 90000) {423
if (consoleLines.filter((l) => /^shader-limits: frame-ms /.test(l)).length >= REPORTS) break;424
await sleep(200);425
}426
const renderer = await evalJS(`(() => { const c = document.createElement("canvas"); const gl = c.getContext("webgl2"); const d = gl && gl.getExtension("WEBGL_debug_renderer_info"); return d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : "unknown"; })()`);427
console.log(`bench web grid=${GRID} (renderer: ${renderer})`);428
for (const l of consoleLines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);429
shutdown(0);430
} else {431
const ready = await waitLine(/^shader-limits: ready /, 30000);432
if (!ready) {433
console.log(`TIMED-OUT ready: no ready line in 30 s; did not run: ${notRun().join(" ")}`);434
console.log("console so far:\n " + consoleLines.join("\n ") + "\nerrors:\n " + consoleErrors.join("\n "));435
shutdown(2);436
} else {437
checkLines(consoleLines);438
if (flag("--verbose")) console.log("console:\n " + consoleLines.join("\n "));439
// Read the canvas inside an animation frame: the app's tick registered440
// its next frame first, so a callback registered now runs after the draw441
// and before the (non-preserved) buffer is discarded.442
const px = await evalJS(`new Promise((resolve) => requestAnimationFrame(() => {443
const c = document.getElementById("stage");444
const off = document.createElement("canvas"); off.width = c.width; off.height = c.height;445
const g = off.getContext("2d"); g.drawImage(c, 0, 0);446
const d = g.getImageData(0, 0, c.width, c.height).data;447
resolve({ w: c.width, h: c.height, data: Array.from(d) });448
}))`);449
const shot = await send("Page.captureScreenshot", { format: "png" });450
fs.writeFileSync(SHOT, Buffer.from(shot.data, "base64"));451
console.log(`note: canvas buffer ${px.w}x${px.h}; page screenshot at ${SHOT}`);452
checkFrame(makeFrame(Uint8Array.from(px.data), px.w, px.h));453
checkGlLog(consoleLines);454
if (!consoleErrors.length) pass("console", "zero error entries, zero exceptions");455
else fail("console", consoleErrors.slice(0, 5).join(" | "));456
shutdown(finish());457
}458
}