AtlatestRepositorysigil-graphics
1// verify.mjs - reads the presented frame of test-shader-limits on either
2// 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 for
12// its "shader-limits: ready" line, captures the window with ImageMagick's
13// `import -window` (so `import`, `identify`, `convert` and `xdotool` must be
14// 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 with
17// software WebGL (SwiftShader), drives it over the DevTools Protocol (Node
18// 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 that
21// runs out prints TIMED-OUT and exits 2:
22// imports (web) the wasm imports only gl, sigil_wasm_gles3 and wasi
23// probes every refusal probe the scene runs said ok, none FAIL
24// update update-texture landed on frame 2 ("shader-limits: update ok")
25// ready the ready line names the expected grid and step count
26// field every cell of the GRIDxGRID Life field matches a CPU Life run
27// of the same seed for --expect-steps generations on a torus
28// palette the eight vec4 palette[8] strips and the four float levels[4]
29// strips show the array entries
30// pixels the 4x4 uploaded texture shows the post-update pattern
31// console (web) zero error-level console entries and zero exceptions
32//
33// --expect-steps defaults to --steps; passing a different value is the
34// assertion-layer sabotage: the field must then FAIL.
35// --bench runs the app in bench mode and prints its frame-ms lines; no
36// verdict, it is the measurement for the Session Log.
38import http from "node:http";
39import fs from "node:fs";
40import path from "node:path";
41import { spawn, execFileSync } from "node:child_process";
43const args = process.argv.slice(2);
44const flag = (name) => args.includes(name);
45const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : dflt; };
46const VALUED = ["--bin", "--grid", "--steps", "--expect-steps", "--shot", "--port", "--cdp", "--reports", "--hold", "--rule", "--substeps"];
47const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && VALUED.includes(args[i - 1])));
49const NATIVE = flag("--native");
50const WEB = flag("--web");
51const BENCH = flag("--bench");
52if (NATIVE === WEB) { console.log("usage: node verify.mjs --native | --web [options]"); process.exit(2); }
53const GRID = parseInt(opt("--grid", BENCH ? "512" : "64"), 10);
54const STEPS = parseInt(opt("--steps", "8"), 10);
55const EXPECT_STEPS = parseInt(opt("--expect-steps", String(STEPS)), 10);
56const SHOT = opt("--shot", `/tmp/test-shader-limits-${NATIVE ? "native" : "web"}.png`);
57const REPORTS = parseInt(opt("--reports", "3"), 10);
58const HOLD = parseInt(opt("--hold", "60"), 10);
59const RULE = opt("--rule", "life");
60const SUBSTEPS = parseInt(opt("--substeps", "1"), 10);
61const REACTION = RULE === "reaction";
63// ---- the scene's geometry, mirrored from scene.sgl ---------------------------
64const VW = 640, VH = 480;
65const FIELD_X = 8, FIELD_Y = 8, FIELD_PX = 384;
66const PAL_X = 408, LEV_X = 440, STRIP = 24, STRIP_GAP = 28;
67const PIX_X = 408, PIX_Y = 260, PIX_SCALE = 16;
68const 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]];
69const LEVELS = [0, 64, 128, 255];
70const 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];
76const 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.
81function 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;
90function 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;
102function expectedField(n, steps) { let g = seedGrid(n); for (let i = 0; i < steps; i++) g = lifeStep(g, n); return g; }
104// ---- verdicts -------------------------------------------------------------------
105const results = [];
106const planned = (WEB ? ["imports"] : []).concat(["probes", "update", "ready", "field", "palette", "pixels", "gl-log"]).concat(WEB ? ["console"] : []);
107function pass(name, detail) { results.push([name, "PASS"]); console.log(`PASS ${name}${detail ? ": " + detail : ""}`); }
108function fail(name, detail) { results.push([name, "FAIL"]); console.log(`FAIL ${name}: ${detail}`); }
109function notRun() { const done = new Set(results.map((r) => r[0])); return planned.filter((p) => !done.has(p)); }
110const 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).
114function 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 };
125const 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;
127function checkLines(lines) {
128 // probes
129 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 // update
136 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 // ready
140 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");
145function checkFrame(frame) {
146 if (REACTION) { checkReaction(frame); checkStripsAndPixels(frame); return; }
147 // field
148 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);
162function checkStripsAndPixels(frame) {
163 // palette
164 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 // pixels
176 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("; "));
186// Gray-Scott has no closed form to compare against, so the reaction field is
187// judged by shape: v (the G channel) must have grown from three seed squares
188// (together 4.7% of the field) into a pattern covering between 6% and
189// 70% of the cells, while u (R) is still high somewhere (the field has not
190// saturated), and the pattern must not be one flat value.
191function 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 pattern
203 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}`);
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 build
210// has no SOKOL_DEBUG (zig cc defines NDEBUG at -Oz, measured 2026-09-19 with
211// `zig cc -Oz -target wasm32-wasi -E` on an #ifdef NDEBUG probe) so the
212// message text is empty and the line number is
213// the pointer). A render target whose pass sokol refuses draws nothing and
214// says so only here, so any error-level line is a failure.
215// The shader-compile-error probe fails one GLSL compile on purpose, which
216// sokol reports as GL_SHADER_COMPILATION_FAILED (item id 7; sokol_gfx.h:10828
217// in the vendored header): exactly one such line is expected and exempt.
218const COMPILE_FAIL = (l) => /\[sg\]\[error\]\[id:7\]/.test(l) || /^sokol\[level=1\] \S+:10828 \[sg\]/.test(l);
219function 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(" | ")}`);
229function 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;
236// =============================================================================
237// NATIVE ARM
238// =============================================================================
239if (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 line
291 // was printed, and the app keeps presenting it; one more frame's worth of
292 // 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());
311function 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"; }
316// =============================================================================
317// WEB ARM
318// =============================================================================
319const ROOT = path.resolve(positional[0] || "build/web");
320const PORT = parseInt(opt("--port", "8097"), 10);
321const CDP = parseInt(opt("--cdp", "9237"), 10);
322const 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 ------------------------------------------
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}`);
339const 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});
349await new Promise((r) => server.listen(PORT, "127.0.0.1", r));
351const udd = fs.mkdtempSync("/tmp/test-shader-limits-chrome-");
352const 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 });
359function killChromeGroup(sig) { try { process.kill(-chrome.pid, sig); } catch { /* gone */ } }
360let exiting = false;
361function 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);
369process.on("exit", () => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } });
370process.on("SIGINT", () => shutdown(130));
371process.on("SIGTERM", () => shutdown(143));
372process.on("unhandledRejection", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
373process.on("uncaughtException", (err) => { console.log("EXCEPTION: " + (err && err.stack || err)); shutdown(2); });
374setTimeout(() => { console.log(`TIMED-OUT whole run after 120 s; did not run: ${notRun().join(" ")}`); shutdown(2); }, 120000).unref();
376let pageWs = null;
377for (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);
381if (!pageWs) { console.log("SETUP-FAILED: no chrome page target after 20 s; did not run: " + notRun().join(" ")); shutdown(2); }
383const ws = new WebSocket(pageWs);
384let msgId = 0; const pending = new Map();
385const consoleLines = []; const consoleErrors = [];
386function send(method, params = {}) {
387 return new Promise((res, rej) => { const id = ++msgId; pending.set(id, { res, rej }); ws.send(JSON.stringify({ id, method, params })); });
389ws.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});
400await new Promise((res, rej) => { ws.addEventListener("open", res); ws.addEventListener("error", rej); });
401await send("Page.enable"); await send("Runtime.enable"); await send("Log.enable");
402// A devicePixelRatio of 2 so the canvas buffer (1280x960) is not the virtual
403// size: the sampler's scale mapping is then exercised, not the identity.
404await send("Emulation.setDeviceMetricsOverride", { width: 800, height: 600, deviceScaleFactor: 2, mobile: false });
406async 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;
411async 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;
417const query = `?grid=${GRID}&steps=${STEPS}&rule=${RULE}&substeps=${SUBSTEPS}${BENCH ? "&bench" : ""}`;
418await send("Page.navigate", { url: `http://127.0.0.1:${PORT}/index.html${query}` });
420if (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 registered
440 // its next frame first, so a callback registered now runs after the draw
441 // 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 }