AtlatestRepositorysigil-graphics
1// verify-wl.mjs - verify.mjs's native arm for a Wayland compositor: the same
2// scene, the same assertions, the window captured through the compositor
3// 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 caller
8// owns (a headless sway: WLR_BACKENDS=headless sway -c <config>, see the
9// sigil-desktop notes), waits for "shader-limits: ready", finds the window
10// in `swaymsg -t get_tree` by name AND by the child's pid (a leftover window
11// from an earlier run must not be captured), and reads its rect with
12// `grim -g`. swaymsg, jq, grim, convert and identify must be on PATH; the
13// window must float without a border (a `for_window ... floating enable,
14// border none` rule) so the rect is the framebuffer. The assertions scale
15// with the capture, so an output at scale 2 (a 1280x960 capture of the
16// 640x480 window) passes the same checks.
17//
18// Everything below this header is verify.mjs; keep the two in step.
20import http from "node:http";
21import fs from "node:fs";
22import path from "node:path";
23import { spawn, execFileSync } from "node:child_process";
25const args = process.argv.slice(2);
26const flag = (name) => args.includes(name);
27const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };
28const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold", "--rule", "--substeps"];
29const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));
31const NATIVE = flag("--native");
32const WEB = flag("--web");
33const BENCH = flag("--bench");
34if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }
35const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);
36const STEPS = parseInt(opt("--steps", "8"), 10);
37const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);
38const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);
39const REPORTS = parseInt(opt("--reports", "3"), 10);
40const HOLD = parseInt(opt("--hold", "60"), 10);
41const RULE = opt("--rule", "life");
42const SUBSTEPS = parseInt(opt("--substeps", "1"), 10);
43const REACTION = RULE === "reaction";
45// ---- the scene's geometry, mirrored from scene.sgl ---------------------------
46const VW = 640, VH = 480;
47const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;
48const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;
49const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;
50const 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]];
51const LEVELS = [0, 64, 128, 255];
52const 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];
58const 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.
63function 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;
72function 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;
84function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }
86// ---- verdicts -------------------------------------------------------------------
87const results = [];
88const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);
89function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }
90function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }
91function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }
92const 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).
96function 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 };
107const 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;
109function checkLines(lines) {
110 // probes
111 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 // update
118 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 // ready
122 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");
127function checkFrame(frame) {
128 if (REACTION) { checkReaction(frame); checkStripsAndPixels(frame); return; }
129 // field
130 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);
144function checkStripsAndPixels(frame) {
145 // palette
146 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 // pixels
158 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("; "));
168// Gray-Scott has no closed form to compare against, so the reaction field is
169// judged by shape: v (the G channel) must have grown from three seed squares
170// (together 4.7% of the field) into a pattern covering between 6% and
171// 70% of the cells, while u (R) is still high somewhere (the field has not
172// saturated), and the pattern must not be one flat value.
173function 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 pattern
185 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}`);
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 build
192// has no SOKOL_DEBUG (zig cc defines NDEBUG at -Oz, measured 2026-09-19 with
193// `zig cc -Oz -target wasm32-wasi -E` on an #ifdef NDEBUG probe) so the
194// message text is empty and the line number is
195// the pointer). A render target whose pass sokol refuses draws nothing and
196// says so only here, so any error-level line is a failure.
197// The shader-compile-error probe fails one GLSL compile on purpose, which
198// sokol reports as GL_SHADER_COMPILATION_FAILED (item id 7; sokol_gfx.h:10828
199// in the vendored header): exactly one such line is expected and exempt.
200const COMPILE_FAIL = (l) => /\[sg\]\[error\]\[id:7\]/.test(l) || /^sokol\[level=1\] \S+:10828 \[sg\]/.test(l);
201function 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(" | ")}`);
211function 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;
218// =============================================================================
219// NATIVE ARM
220// =============================================================================
221if (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 line
273 // was printed, and the app keeps presenting it; one more frame's worth of
274 // 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 the
277 // window through the compositor. swaymsg gives the window's rect on the
278 // 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());
299function 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"; }
304// =============================================================================
305// WEB ARM
306// =============================================================================
307const ROOT = path.resolve(positional[0] || "build/web");
308const PORT = parseInt(opt("--port", "8097"), 10);
309const CDP = parseInt(opt("--cdp", "9237"), 10);
310const 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 ------------------------------------------
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}`);
327const 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});
337await new Promise((r) => server.listen(PORT, "127.0.0.1", r));
339const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");
340const 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 });
347function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }
348let exiting = false;
349function 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);
357process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });
358process.on("SIGINT", () => shutdown(130));
359process.on("SIGTERM", () => shutdown(143));
360process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
361process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
362setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();
364let pageWs = null;
365for (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);
369if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }
371const ws = new WebSocket(pageWs);
372let msgId = 0; const pending = new Map();
373const consoleLines = []; const consoleErrors = [];
374function send(method, params = {}) {
375 return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });
377ws.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});
388await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });
389await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");
390// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual
391// size: the sampler's scale mapping is then exercised, not the identity.
392await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });
394async 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;
399async 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;
405const query = `?grid=${GRID}&steps=${STEPS}&rule=${RULE}&substeps=${SUBSTEPS}${BENCH ? "&bench" : ""}`;
406await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });
408if (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 registered
428 // its next frame first, so a callback registered now runs after the draw
429 // 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 }