AtlatestRepositorysigil-graphics
sigil-graphics / tree / test / test-shader-limitsverify-wl.mjs
1
// verify-wl.mjs - verify.mjs's native arm for a Wayland compositor: the same2
// scene, the same assertions, the window captured through the compositor3
// instead of an X server.4
//5
// node verify-wl.mjs --native [--bin ...] [--grid 64] [--steps 8] [--expect-steps N] [--shot PATH]6
//7
// Runs the binary with WAYLAND_DISPLAY pointing at a compositor the caller8
// owns (a headless sway: WLR_BACKENDS=headless sway -c <config>, see the9
// sigil-desktop notes), waits for "shader-limits: ready", finds the window10
// in `swaymsg -t get_tree` by name AND by the child's pid (a leftover window11
// from an earlier run must not be captured), and reads its rect with12
// `grim -g`. swaymsg, jq, grim, convert and identify must be on PATH; the13
// window must float without a border (a `for_window ... floating enable,14
// border none` rule) so the rect is the framebuffer. The assertions scale15
// with the capture, so an output at scale 2 (a 1280x960 capture of the16
// 640x480 window) passes the same checks.17
//18
// Everything below this header is verify.mjs; keep the two in step.20
import http from "node:http";21
import fs from "node:fs";22
import path from "node:path";23
import { spawn, execFileSync } from "node:child_process";25
const args = process.argv.slice(2);26
const flag = (name) => args.includes(name);27
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };28
const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold", "--rule", "--substeps"];29
const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));31
const NATIVE = flag("--native");32
const WEB = flag("--web");33
const BENCH = flag("--bench");34
if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }35
const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);36
const STEPS = parseInt(opt("--steps", "8"), 10);37
const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);38
const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);39
const REPORTS = parseInt(opt("--reports", "3"), 10);40
const HOLD = parseInt(opt("--hold", "60"), 10);41
const RULE = opt("--rule", "life");42
const SUBSTEPS = parseInt(opt("--substeps", "1"), 10);43
const REACTION = RULE === "reaction";45
// ---- the scene's geometry, mirrored from scene.sgl ---------------------------46
const VW = 640, VH = 480;47
const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;48
const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;49
const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;50
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]];51
const LEVELS = [0, 64, 128, 255];52
const PATTERN_B = [53
[0, 255, 0], [255, 255, 0], [0, 255, 0], [255, 255, 0],54
[255, 255, 0], [255, 255, 255], [255, 255, 0], [0, 255, 0],55
[0, 255, 0], [255, 255, 0], [0, 0, 0], [255, 255, 0],56
[255, 255, 0], [0, 255, 0], [255, 255, 0], [0, 255, 0],57
];58
const PROBES = ["make-texture-size", "update-texture-size", "update-same-frame", "update-immutable",59
"shader-compile-error", "uniform-block-too-large", "uniform-too-many",60
"uniform-count", "uniform-scalar-for-array", "filter-name", "uniform-nested", "uniform-flat-vector"];62
// The seed, as scene.sgl draws it, and a CPU Life on a torus.63
function seedGrid(n) {64
const g = new Uint8Array(n * n);65
const set = (x, y) => { g[((y + n) % n) * n + ((x + n) % n)] = 1; };66
const s = n - 3;67
set(s + 1, s); set(s + 2, s + 1); set(s, s + 2); set(s + 1, s + 2); set(s + 2, s + 2);68
set(9, 10); set(10, 10); set(11, 10);69
set(20, 20); set(21, 20); set(20, 21); set(21, 21);70
return g;71
}72
function lifeStep(g, n) {73
const out = new Uint8Array(n * n);74
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {75
let c = 0;76
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {77
if (dx || dy) c += g[((y + dy + n) % n) * n + ((x + dx + n) % n)];78
}79
const alive = g[y * n + x];80
out[y * n + x] = (c === 3 || (c === 2 && alive)) ? 1 : 0;81
}82
return out;83
}84
function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }86
// ---- verdicts -------------------------------------------------------------------87
const results = [];88
const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);89
function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }90
function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }91
function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }92
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));94
// A captured frame: RGBA bytes, buffer size, and the virtual-to-buffer scale.95
// px(vx, vy) samples the buffer pixel under the centre of virtual pixel (vx, vy).96
function makeFrame(data, w, h) {97
const sx = w / VW, sy = h / VH;98
return {99
w, h, sx, sy,100
px(vx, vy) {101
const bx = Math.min(w - 1, Math.floor((vx + 0.5) * sx)), by = Math.min(h - 1, Math.floor((vy + 0.5) * sy));102
const i = (by * w + bx) * 4;103
return [data[i], data[i + 1], data[i + 2]];104
},105
};106
}107
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;109
function checkLines(lines) {110
// probes111
const seen = new Map();112
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]); }113
const missing = PROBES.filter((p) => !seen.has(p));114
const failed = [...seen].filter(([, v]) => v.startsWith("FAIL")).map(([k, v]) => `${k}${v.slice(4)}`);115
if (!missing.length && !failed.length) pass("probes", `${PROBES.length} probes ok`);116
else fail("probes", `${missing.length ? "missing " + missing.join(",") + "; " : ""}${failed.length ? "failed: " + failed.join(" | ") : ""}`);117
// update118
const upd = lines.find((l) => /^shader-limits: update /.test(l));119
if (upd === "shader-limits: update ok") pass("update", "update-texture landed on frame 2");120
else fail("update", upd || "no update line");121
// ready122
const rdy = lines.map((l) => l.match(/^shader-limits: ready grid=(\d+) steps=(\d+)$/)).find(Boolean);123
if (rdy && parseInt(rdy[1], 10) === GRID && parseInt(rdy[2], 10) === EXPECT_STEPS) pass("ready", rdy[0]);124
else fail("ready", rdy ? `${rdy[0]} (expected grid=${GRID} steps=${EXPECT_STEPS})` : "no ready line");125
}127
function checkFrame(frame) {128
if (REACTION) { checkReaction(frame); checkStripsAndPixels(frame); return; }129
// field130
const exp = expectedField(GRID, EXPECT_STEPS);131
const cell = FIELD_PX / GRID;132
let mismatches = 0, live = 0, seenLive = 0; const firstBad = [];133
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {134
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);135
const lit = c[0] > 128 ? 1 : 0;136
live += exp[y * GRID + x]; seenLive += lit;137
if (lit !== exp[y * GRID + x]) { mismatches++; if (firstBad.length < 6) firstBad.push(`(${x},${y}) got ${lit} want ${exp[y * GRID + x]}`); }138
}139
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)`);140
else fail("field", `${mismatches} of ${GRID * GRID} cells differ (expected ${live} live, saw ${seenLive}); first: ${firstBad.join("; ")}`);141
checkStripsAndPixels(frame);142
}144
function checkStripsAndPixels(frame) {145
// palette146
const bad = [];147
for (let i = 0; i < 8; i++) {148
const c = frame.px(PAL_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);149
if (!near(c, PALETTE[i], 12)) bad.push(`palette[${i}] got ${c} want ${PALETTE[i]}`);150
}151
for (let i = 0; i < 4; i++) {152
const c = frame.px(LEV_X + STRIP / 2, 8 + i * STRIP_GAP + STRIP / 2);153
if (!near(c, [LEVELS[i], LEVELS[i], LEVELS[i]], 12)) bad.push(`levels[${i}] got ${c} want ${LEVELS[i]}`);154
}155
if (!bad.length) pass("palette", "8 vec4 palette entries and 4 float levels read back through the shader");156
else fail("palette", bad.join("; "));157
// pixels158
const badPx = [];159
for (let t = 0; t < 16; t++) {160
const tx = t % 4, ty = Math.floor(t / 4);161
const c = frame.px(PIX_X + tx * PIX_SCALE + PIX_SCALE / 2, PIX_Y + ty * PIX_SCALE + PIX_SCALE / 2);162
if (!near(c, PATTERN_B[t], 12)) badPx.push(`texel ${tx},${ty} got ${c} want ${PATTERN_B[t]}`);163
}164
if (!badPx.length) pass("pixels", "16 texels show the update-texture pattern");165
else fail("pixels", badPx.join("; "));166
}168
// Gray-Scott has no closed form to compare against, so the reaction field is169
// judged by shape: v (the G channel) must have grown from three seed squares170
// (together 4.7% of the field) into a pattern covering between 6% and171
// 70% of the cells, while u (R) is still high somewhere (the field has not172
// saturated), and the pattern must not be one flat value.173
function checkReaction(frame) {174
const cell = FIELD_PX / GRID;175
let vHigh = 0, uHigh = 0; const vBins = new Set();176
for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) {177
const c = frame.px(FIELD_X + x * cell + (cell - 1) / 2, FIELD_Y + y * cell + (cell - 1) / 2);178
if (c[1] > 64) vHigh++;179
if (c[0] > 192) uHigh++;180
vBins.add(c[1] >> 4);181
}182
const total = GRID * GRID, vf = vHigh / total, uf = uHigh / total;183
const detail = `v>0.25 on ${(vf * 100).toFixed(1)}% of cells, u>0.75 on ${(uf * 100).toFixed(1)}%, ${vBins.size} v levels`;184
// the three seed squares cover 3/64 = 4.7% of the field; growth past 6% is the pattern185
if (vf >= 0.06 && vf <= 0.7 && uf >= 0.2 && vBins.size >= 6) pass("field", `reaction pattern grew: ${detail}`);186
else fail("field", `no reaction pattern: ${detail}`);187
}189
// sokol logs through slog_func: natively sigil-app's format `[sg][error][id:N] ...`190
// on stderr, on the web sigil-graphics' own `sokol[level=L] file:line [tag] msg`191
// on the console (level 0 panic, 1 error, 2 warning, 3 info; the web build192
// has no SOKOL_DEBUG (zig cc defines NDEBUG at -Oz, measured 2026-09-19 with193
// `zig cc -Oz -target wasm32-wasi -E` on an #ifdef NDEBUG probe) so the194
// message text is empty and the line number is195
// the pointer). A render target whose pass sokol refuses draws nothing and196
// says so only here, so any error-level line is a failure.197
// The shader-compile-error probe fails one GLSL compile on purpose, which198
// sokol reports as GL_SHADER_COMPILATION_FAILED (item id 7; sokol_gfx.h:10828199
// in the vendored header): exactly one such line is expected and exempt.200
const COMPILE_FAIL = (l) => /\[sg\]\[error\]\[id:7\]/.test(l) || /^sokol\[level=1\] \S+:10828 \[sg\]/.test(l);201
function checkGlLog(lines) {202
const all = lines.filter((l) => /\[(sg|sgp|sapp)\]\[(error|panic)\]/.test(l) || /^sokol\[level=[01]\]/.test(l));203
const expected = all.filter(COMPILE_FAIL);204
const bad = all.filter((l) => !COMPILE_FAIL(l)).concat(expected.slice(1));205
if (expected.length !== 1) bad.push(`expected exactly one deliberate compile failure line, saw ${expected.length}`);206
const warn = lines.filter((l) => /\[(sg|sgp)\]\[warning\]/.test(l) || /^sokol\[level=2\]/.test(l));207
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(" | ") : ""}`);208
else fail("gl-log", `${bad.length} sokol error line(s): ${bad.slice(0, 4).join(" | ")}`);209
}211
function finish() {212
const failed = results.filter((r) => r[1] === "FAIL").length;213
const nr = notRun();214
console.log(`${failed ? "RED" : "GREEN"}: ${results.length - failed} pass, ${failed} fail${nr.length ? "; did not run: " + nr.join(" ") : ""}`);215
return failed || nr.length ? 1 : 0;216
}218
// =============================================================================219
// NATIVE ARM220
// =============================================================================221
if (NATIVE) {222
const bin = path.resolve(opt("--bin", "build/dev/bin/test-shader-limits"));223
if (!fs.existsSync(bin)) { console.log(`SETUP-FAILED: ${bin} missing; build first`); process.exit(2); }224
if (!process.env.WAYLAND_DISPLAY) { console.log("SETUP-FAILED: WAYLAND_DISPLAY is not set; point it at a worker-owned headless sway"); process.exit(2); }225
for (const tool of ["swaymsg", "jq", "grim", "convert", "identify"]) {226
try { execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }); }227
catch { console.log(`SETUP-FAILED: ${tool} is not on PATH (run inside guix shell -m ../../manifest.scm imagemagick xdotool)`); process.exit(2); }228
}229
const appArgs = ["--grid", String(GRID), "--steps", String(STEPS), "--hold", String(HOLD), "--rule", RULE, "--substeps", String(SUBSTEPS)].concat(BENCH ? ["--bench"] : []);230
const child = spawn(bin, appArgs, { stdio: ["ignore", "pipe", "pipe"], detached: true });231
const lines = [];232
let buf = "";233
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); } });234
const stderrLines = [];235
child.stderr.on("data", (d) => { for (const l of d.toString().split("\n")) if (l.trim()) stderrLines.push(l); });236
let exited = null;237
child.on("exit", (code, sig) => { exited = { code, sig }; });238
const stop = () => { try { process.kill(-child.pid, "SIGTERM"); } catch { /* gone */ } };239
process.on("exit", stop);241
const waitLine = async (re, ms) => {242
const t0 = Date.now();243
while (Date.now() - t0 < ms) {244
const hit = lines.find((l) => re.test(l));245
if (hit) return hit;246
if (exited) return null;247
await sleep(100);248
}249
return null;250
};252
if (BENCH) {253
const t0 = Date.now();254
while (Date.now() - t0 < HOLD * 1000 + 5000) {255
const reports = lines.filter((l) => /^shader-limits: frame-ms /.test(l));256
if (reports.length >= REPORTS || exited) break;257
await sleep(200);258
}259
console.log(`bench native grid=${GRID} (renderer: ${glRenderer()})`);260
for (const l of lines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);261
stop();262
process.exit(0);263
}265
const ready = await waitLine(/^shader-limits: ready /, 30000);266
if (!ready) {267
console.log(`TIMED-OUT ready: no ready line in 30 s${exited ? ` (exited ${JSON.stringify(exited)})` : ""}; did not run: ${notRun().join(" ")}`);268
console.log("stdout so far:\n " + lines.join("\n ")); console.log("stderr so far:\n " + stderrLines.join("\n "));269
stop(); process.exit(2);270
}271
checkLines(lines);272
// The frame that shows the settled state was presented before the line273
// was printed, and the app keeps presenting it; one more frame's worth of274
// time makes sure the X server has it before the capture.275
await sleep(300);276
// WAYLAND ARM (scratch copy for the sigil-desktop-glfw spike): capture the277
// window through the compositor. swaymsg gives the window's rect on the278
// headless output; grim reads that region in output pixels.279
let rect = "";280
for (let i = 0; i < 50 && !rect; i++) {281
try {282
rect = execFileSync("sh", ["-c", `swaymsg -t get_tree | jq -r '.. | objects | select(.name? == "test-shader-limits" and .pid? == ${child.pid}) | "\\(.rect.x),\\(.rect.y) \\(.rect.width)x\\(.rect.height)"' | head -1`]).toString().trim();283
} catch { /* not yet */ }284
if (!rect) await sleep(100);285
}286
if (!rect) { console.log("SETUP-FAILED: no sway window named test-shader-limits; did not run: " + notRun().join(" ")); stop(); process.exit(2); }287
console.log(`note: sway window rect ${rect}`);288
execFileSync("grim", ["-g", rect, SHOT]);289
const [w, h] = execFileSync("identify", ["-format", "%w %h", SHOT]).toString().trim().split(" ").map(Number);290
const raw = execFileSync("convert", [SHOT, "-depth", "8", "rgba:-"], { maxBuffer: 64 * 1024 * 1024 });291
if (raw.length !== w * h * 4) { console.log(`SETUP-FAILED: raw dump is ${raw.length} bytes for ${w}x${h}`); stop(); process.exit(2); }292
console.log(`note: captured ${w}x${h} window to ${SHOT}`);293
checkFrame(makeFrame(raw, w, h));294
checkGlLog(stderrLines.concat(lines));295
stop();296
process.exit(finish());297
}299
function glRenderer() {300
try { return execFileSync("sh", ["-c", "glxinfo -B 2>/dev/null | grep -i 'renderer string' | head -1"]).toString().trim() || "glxinfo unavailable"; }301
catch { return "glxinfo unavailable"; }302
}304
// =============================================================================305
// WEB ARM306
// =============================================================================307
const ROOT = path.resolve(positional[0] || "build/web");308
const PORT = parseInt(opt("--port", "8097"), 10);309
const CDP = parseInt(opt("--cdp", "9237"), 10);310
const TYPES = { ".html": "text/html;charset=utf-8", ".js": "text/javascript;charset=utf-8", ".wasm": "application/wasm",311
".json": "application/json;charset=utf-8", ".css": "text/css;charset=utf-8", ".png": "image/png" };313
// ---- imports, from the file on disk ------------------------------------------314
{315
const wasmPath = path.join(ROOT, "test-shader-limits.wasm");316
if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing; build --config web first`); process.exit(2); }317
const mod = new WebAssembly.Module(fs.readFileSync(wasmPath));318
const byModule = {};319
for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name);320
const modules = Object.keys(byModule).sort();321
const expected = ["gl", "sigil_wasm_gles3", "wasi_snapshot_preview1"];322
const listing = modules.map((m) => `${m}(${byModule[m].length})`).join(" ");323
if (JSON.stringify(modules) === JSON.stringify(expected)) pass("imports", listing);324
else fail("imports", `expected modules ${expected.join(",")} got ${listing}`);325
}327
const server = http.createServer((req, res) => {328
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);329
const fp = path.join(ROOT, urlPath === "/" ? "/index.html" : urlPath);330
if (fp !== ROOT && !fp.startsWith(ROOT + path.sep)) { res.writeHead(403).end(); return; }331
fs.readFile(fp, (err, buf) => {332
if (err) { res.writeHead(404).end("not found: " + urlPath); return; }333
res.writeHead(200, { "Content-Type": TYPES[path.extname(fp)] || "application/octet-stream", "Cache-Control": "no-store" });334
res.end(buf);335
});336
});337
await new Promise((r) => server.listen(PORT, "127.0.0.1", r));339
const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");340
const chrome = spawn("google-chrome", [341
"--headless=new", "--no-sandbox", "--disable-dev-shm-usage",342
"--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",343
"--enable-webgl", "--ignore-gpu-blocklist",344
`--remote-debugging-port=${CDP}`, `--user-data-dir=${udd}`,345
"--window-size=800,600", "about:blank",346
], { stdio: "ignore", detached: true });347
function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }348
let exiting = false;349
function shutdown(code) {350
if (exiting) return; exiting = true;351
try { server.close(); } catch { /* not listening */ }352
killChromeGroup("SIGTERM");353
setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref();354
const done = () => { try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); };355
if (chrome.exitCode !== null) done(); else chrome.once("exit", done);356
}357
process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });358
process.on("SIGINT", () => shutdown(130));359
process.on("SIGTERM", () => shutdown(143));360
process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });361
process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });362
setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();364
let pageWs = null;365
for (let i = 0; i < 80 && !pageWs; i++) {366
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 */ }367
await sleep(250);368
}369
if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }371
const ws = new WebSocket(pageWs);372
let msgId = 0; const pending = new Map();373
const consoleLines = []; const consoleErrors = [];374
function send(method, params = {}) {375
return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });376
}377
ws.addEventListener("message", (ev) => {378
const msg = JSON.parse(ev.data);379
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; }380
if (msg.method === "Runtime.consoleAPICalled") {381
const text = (msg.params.args || []).map((a) => a.value ?? a.description ?? "").join(" ");382
consoleLines.push(text);383
if (msg.params.type === "error") consoleErrors.push("console.error: " + text);384
}385
if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") consoleErrors.push("log: " + msg.params.entry.text);386
if (msg.method === "Runtime.exceptionThrown") consoleErrors.push("exception: " + (msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text));387
});388
await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });389
await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");390
// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual391
// size: the sampler's scale mapping is then exercised, not the identity.392
await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });394
async function evalJS(expr) {395
const r = await send("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true });396
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));397
return r.result.value;398
}399
async function waitLine(re, ms) {400
const t0 = Date.now();401
while (Date.now() - t0 < ms) { const hit = consoleLines.find((l) => re.test(l)); if (hit) return hit; await sleep(100); }402
return null;403
}405
const query = `?grid=${GRID}&steps=${STEPS}&rule=${RULE}&substeps=${SUBSTEPS}${BENCH ? "&bench" : ""}`;406
await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });408
if (BENCH) {409
const t0 = Date.now();410
while (Date.now() - t0 < 90000) {411
if (consoleLines.filter((l) => /^shader-limits: frame-ms /.test(l)).length >= REPORTS) break;412
await sleep(200);413
}414
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"; })()`);415
console.log(`bench web grid=${GRID} (renderer: ${renderer})`);416
for (const l of consoleLines.filter((l) => /^shader-limits: (init|frame-ms) /.test(l))) console.log(" " + l);417
shutdown(0);418
} else {419
const ready = await waitLine(/^shader-limits: ready /, 30000);420
if (!ready) {421
console.log(`TIMED-OUT ready: no ready line in 30 s; did not run: ${notRun().join(" ")}`);422
console.log("console so far:\n " + consoleLines.join("\n ") + "\nerrors:\n " + consoleErrors.join("\n "));423
shutdown(2);424
} else {425
checkLines(consoleLines);426
if (flag("--verbose")) console.log("console:\n " + consoleLines.join("\n "));427
// Read the canvas inside an animation frame: the app's tick registered428
// its next frame first, so a callback registered now runs after the draw429
// and before the (non-preserved) buffer is discarded.430
const px = await evalJS(`new Promise((resolve) => requestAnimationFrame(() => {431
const c = document.getElementById("stage");432
const off = document.createElement("canvas"); off.width = c.width; off.height = c.height;433
const g = off.getContext("2d"); g.drawImage(c, 0, 0);434
const d = g.getImageData(0, 0, c.width, c.height).data;435
resolve({ w: c.width, h: c.height, data: Array.from(d) });436
}))`);437
const shot = await send("Page.captureScreenshot", { format: "png" });438
fs.writeFileSync(SHOT, Buffer.from(shot.data, "base64"));439
console.log(`note: canvas buffer ${px.w}x${px.h}; page screenshot at ${SHOT}`);440
checkFrame(makeFrame(Uint8Array.from(px.data), px.w, px.h));441
checkGlLog(consoleLines);442
if (!consoleErrors.length) pass("console", "zero error entries, zero exceptions");443
else fail("console", consoleErrors.slice(0, 5).join(" | "));444
shutdown(finish());445
}446
}