igneum/tools/scene/parity.mjs
igneum-labs 4ab50d9c7a Chain scene parity test: one recorded feed through the home fold, /live and the app's Inspect view, three frames each pixel-equal, in the gate (7 October 2026, 15:5x UK)
tools/scene/parity.mjs serves site/ (tools/site-serve.mjs) and the app's UI (tools/ui-mock/server.mjs) on a build box, answers
every page's /api/live from scene/fixtures/live-2026-10-07.json, freezes the clock at the fixture's instant (Date, timers,
requestAnimationFrame) and reads each scene canvas at 900 by 420 px at the first push and the next two polls: the read is the
push's own synchronous paint, so a frame depends on the fake time alone, never on how long a fetch took. Known-failed first: the
app with --included moved by one unit must differ from /live at every instant (it does: 1,549 / 1,282 / 1,157 px). Then home
fold = /live, app Inspect = /live, app with this machine's key = /live with the same key (the overlay is the same picture), and
the app's overlay frame differs from its base frame (the overlay is drawn). The compact card is rendered and reported, not
compared. A RED line names the differing pixels and their box. tools/scene/parity-remote.sh carries the files to the box
(~/.config/igneum/build-server-2 by default) under this lane's prefix and runs it there; a plain CI runner with no box and no
Playwright prints a skip line. One new line in tools/ci/pre-push.sh.

First run on build-2 at 15:4x UK, release-0.3.21 tree plus scene-parity: every comparison equal at T+0, T+2 and T+4 s.
Found on the way and fixed in the harness, not the renderer: lane order keeps the history of earlier layouts (the scene must be
at the compared size before its first layout); the app's recorded mock card is a real devnet key (cleared for the base case).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 4102c935e518e83eba39d880daad1a57b77c3bf8)
2026-10-07 13:51:47 +00:00

184 lines
16 KiB
JavaScript

#!/usr/bin/env node
// Scene parity: the same recorded feed through the site's home fold, the site's /live and the miner app's Inspect view, three
// frames each at the same three instants of a fake clock, compared pixel for pixel. Runs on a build box (tools/scene/parity-remote.sh
// carries the tree there and runs this; never a headless browser on the Mac).
//
// node tools/scene/parity.mjs [--out <dir>] the report on stdout, one line per comparison; exit 1 on any difference
//
// What it does, in order (the known-failed case first, as the watcher-trust rule asks):
// 1. serves site/ through tools/site-serve.mjs and app/igneum-app/ui through tools/ui-mock/server.mjs on private ports;
// every page's /api/live is answered from scene/fixtures/live-2026-10-07.json by a Playwright route, the clock is frozen
// at the fixture's `now` (Date, timers, requestAnimationFrame), the viewport is 1280 by 900 at DPR 1, dark;
// 2. KNOWN-FAILED: the app page with one palette token changed (--included) must differ from /live at every instant;
// 3. base: no own key anywhere; home fold, /live and the app's Inspect scene byte-equal at T, T+2 s, T+5 s;
// 4. overlay: the same vote key named on every surface (localStorage on the site, the mock's card ids on the app); the
// three surfaces still byte-equal, and the app's overlay frame differs from its base frame (the overlay is drawn);
// 5. the app's compact card (its own mode, no site counterpart) is rendered once and reported, not compared.
// Every scene canvas is forced to 900 by 420 CSS px so the comparison is of the renderer, the tokens, the options and the
// feed, not of the box the page gives the scene. A surface whose files are absent (master carries no app UI scene) is
// reported as skipped and the remaining surfaces are compared.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..');
const out = process.argv.includes('--out') ? process.argv[process.argv.indexOf('--out') + 1] : '';
if (out) mkdirSync(out, { recursive: true });
const PW = [process.env.PLAYWRIGHT_CORE, '/srv/builds/_bin/overlap/node_modules/playwright-core/package.json', '/srv/builds/scene-parity/pw/node_modules/playwright-core/package.json', join(homedir(), '.cache/igneum-pw/node_modules/playwright-core/package.json')].filter(Boolean).find(p => existsSync(p));
const CHROME = [process.env.PLAYWRIGHT_CHROMIUM, join(homedir(), '.cache/ms-playwright/chromium_headless_shell-1194/chrome-linux/headless_shell'), join(homedir(), '.cache/ms-playwright/chromium-1194/chrome-linux/chrome')].filter(Boolean).find(p => existsSync(p));
if (!PW || !CHROME) { console.log('scene-parity: skipped, no Playwright Chromium here (PLAYWRIGHT_CORE, PLAYWRIGHT_CHROMIUM)'); process.exit(0); }
const { chromium } = createRequire(PW)('playwright-core');
const fixture = JSON.parse(readFileSync(join(repo, 'scene/fixtures/live-2026-10-07.json'), 'utf8'));
const T0 = new Date(fixture.now).getTime();
const KEY = fixture.miners[2].id; // a real key of the recording with a few blocks: the overlay case names it on every surface
const SIZE = { w: 900, h: 420 }, INSTANTS = [0, 2000, 4000]; // the first push and the next two polls (every surface polls every 2 s)
const hasApp = existsSync(join(repo, 'app/igneum-app/ui/live-dag.js')) && existsSync(join(repo, 'tools/ui-mock/server.mjs'));
const problems = [], lines = [];
const say = s => { lines.push(s); console.log(s); };
// ---- servers ----
function serve(cmd, args, env, ready) {
return new Promise((resolve, reject) => {
const p = spawn(process.execPath, [cmd, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
let log = ''; const t = setTimeout(() => reject(new Error('server did not start: ' + cmd + '\n' + log)), 15000);
const probe = async () => { try { const r = await fetch(ready); if (r.ok || r.status === 404) { clearTimeout(t); resolve(p); return; } } catch { } setTimeout(probe, 200); };
p.stdout.on('data', d => { log += d; }); p.stderr.on('data', d => { log += d; });
p.on('exit', c => { clearTimeout(t); reject(new Error(cmd + ' exited ' + c + '\n' + log)); });
setTimeout(probe, 300);
});
}
const port = base => base + Math.floor(Math.random() * 200);
const sitePort = port(4600), appPort = port(4900);
const servers = [];
servers.push(await serve(join(repo, 'tools/site-serve.mjs'), [], { PORT: String(sitePort), DATABASE_URL: 'postgres://none' }, `http://127.0.0.1:${sitePort}/live`));
if (hasApp) servers.push(await serve(join(repo, 'tools/ui-mock/server.mjs'), [String(appPort)], {}, `http://127.0.0.1:${appPort}/t/mock/api/state`));
const stop = () => { for (const s of servers) try { s.kill(); } catch { } };
process.on('exit', stop);
// ---- the browser ----
const browser = await chromium.launch({ executablePath: CHROME, args: ['--renderer-process-limit=1', '--font-render-hinting=none', '--disable-lcd-text'] });
// the mount shim: every mounted scene is listed with a push counter, so a capture can wait for a push to have landed
const capture = () => { let real; Object.defineProperty(window, 'IgneumDag', { configurable: true, get() { return real; }, set(v) { real = Object.assign({}, v, { mount(c, o) { const api = v.mount(c, o); const entry = { canvas: c, api, opts: o, pushes: 0 }; const push = api.push; api.push = function (d) { const ok = push(d); if (ok) entry.pushes++; return ok; }; (window.__dags = window.__dags || []).push(entry); return api; } }); } }); };
/** One surface: opens the page with the fixture as its feed and the clock frozen at T0; returns frame hashes at the instants. */
async function surface(name, url, { ownKey, mutate, selectCanvas, prepare } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' });
const page = await ctx.newPage();
const errors = []; page.on('pageerror', e => errors.push(String(e.message)));
await ctx.clock.install({ time: T0 });
await ctx.clock.pauseAt(T0 + 1); // frozen: only runFor moves it, so every surface is read at the same three instants
await page.addInitScript(capture);
// the scene's box before any script runs (lane order keeps the history of earlier layouts, so the first layout must already
// be at the compared size): a style appended the moment <head> exists; no border or padding inside the canvas box
await page.addInitScript(({ sel, w, h, extra }) => { const css = sel + '{width:' + w + 'px!important;height:' + h + 'px!important;max-width:none!important;border:0!important;padding:0!important;box-sizing:content-box!important}' + extra; const add = () => { if (!document.head) return false; const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); return true; }; if (!add()) new MutationObserver((m, o) => { if (add()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); }, { sel: selectCanvas, w: SIZE.w, h: SIZE.h, extra: mutate || '' });
if (ownKey) await page.addInitScript(k => { try { localStorage.setItem('igneum-my-key', k); localStorage.setItem('igneum-theme', 'dark'); } catch { } }, ownKey);
else await page.addInitScript(() => { try { localStorage.removeItem('igneum-my-key'); localStorage.setItem('igneum-theme', 'dark'); } catch { } });
await page.route('**/api/live*', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(fixture) }));
// the app's own keys come from its cards: none in the base case (the mock's recorded card is a real devnet key that is in the
// fixture), exactly the named key in the overlay case
if (name.startsWith('app')) await page.route('**/api/state*', async r => { const res = await r.fetch(); const s = await res.json(); if (s.mining && s.mining.cards) s.mining.cards.forEach((c, i) => { c.ids = ownKey && i === 0 ? [ownKey] : []; }); r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(s) }); });
await page.goto(url, { waitUntil: 'load' });
// the mount, then the first push (the page's own poll runs at load; the fetch resolves in real time)
if (prepare) await prepare(page);
await page.waitForFunction(sel => !!(window.__dags || []).find(x => x.canvas.matches(sel)), selectCanvas, { timeout: 15000 }).catch(() => { });
// the canvas in view, so the IntersectionObserver reports it and the motion loop runs on every surface alike (a canvas below
// the fold paints on push alone since 2.0.3, which is right for a viewer but not a comparison of the loop)
await page.evaluate(sel => { const c = document.querySelector(sel); if (c) c.scrollIntoView({ block: 'center' }); }, selectCanvas);
await page.waitForTimeout(200);
// the capture protocol, the same on every surface: advance the frozen clock exactly to the next poll, wait (in real time)
// for that push to land, read the canvas as the push's own synchronous paint left it. No animation frame runs between the
// push and the read, so the frame depends on the fake time alone, never on how long the fetch took.
const pushes = () => page.evaluate(sel => { const d = (window.__dags || []).find(x => x.canvas.matches(sel)); return d ? d.pushes : -1; }, selectCanvas);
const frames = [];
let at = 0, want = 1;
for (const ms of INSTANTS) {
if (ms > at) { await ctx.clock.runFor(ms - at); at = ms; }
const t0 = Date.now(); while (Date.now() - t0 < 8000 && (await pushes()) < want) { await page.waitForTimeout(50); if ((await pushes()) < want && Date.now() - t0 > 1500) { await ctx.clock.runFor(1); at += 1; } }
if ((await pushes()) < want) say(' push ' + want + ' never landed on ' + name + ' (' + (await pushes()) + ' so far)');
want++;
const f = await page.evaluate(sel => {
const d = (window.__dags || []).find(x => x.canvas.matches(sel)); if (!d) return { error: 'no scene on ' + sel };
const c = d.canvas, g = c.getContext('2d'), st = d.api.getStats();
const px = g.getImageData(0, 0, c.width, c.height).data; let h = 0, lit = 0;
for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; }
return { w: c.width, h: c.height, lit, blocks: st.blocks, lanes: st.lanes, window: st.window, narrow: st.narrow, state: st.state, frames: st.frames, png: c.toDataURL('image/png') };
}, selectCanvas);
if (f.error) { frames.push(f); continue; }
f.hash = createHash('sha256').update(f.png).digest('hex').slice(0, 16);
if (out) writeFileSync(join(out, `${name}-${ms}.png`), Buffer.from(f.png.split(',')[1], 'base64'));
f.pngData = f.png; delete f.png; frames.push(f);
}
await ctx.close();
return { name, frames, errors };
}
const describe = s => s.frames.map((f, i) => f.error ? f.error : `T+${INSTANTS[i] / 1000}s ${f.hash} ${f.w}x${f.h} lit ${f.lit} blocks ${f.blocks} lanes ${f.lanes} win ${f.window}${f.narrow ? ' narrow' : ''} ${f.state}`).join('; ');
async function compare(label, a, b, mustDiffer) {
const diffs = [];
for (let i = 0; i < INSTANTS.length; i++) {
const fa = a.frames[i], fb = b.frames[i];
if (fa.error || fb.error) { diffs.push('absent'); continue; }
if (fa.hash === fb.hash) { diffs.push('equal'); continue; }
const d = (fa.w === fb.w && fa.h === fb.h) ? await pixelDiff(fa.pngData, fb.pngData) : { n: -1 };
diffs.push('DIFFERS' + (d.n >= 0 ? ' (' + d.n + ' of ' + d.total + ' px, box ' + d.box.join(',') + ')' : ' (sizes differ)'));
}
const bad = mustDiffer ? diffs.some(d => !d.startsWith('DIFFERS')) : diffs.some(d => d !== 'equal');
say(` ${bad ? 'RED ' : 'ok '} ${label}: ${diffs.map((d, i) => `T+${INSTANTS[i] / 1000}s ${d}`).join(', ')}`);
if (bad) problems.push(label);
}
const dims = (a, b) => a.frames[0].w === b.frames[0].w && a.frames[0].h === b.frames[0].h;
async function pixelDiff(aPng, bPng) {
const ctx = await browser.newContext(); const page = await ctx.newPage();
const r = await page.evaluate(async ([a, b]) => {
const load = src => new Promise(res => { const i = new Image(); i.onload = () => res(i); i.src = src; });
const [ia, ib] = await Promise.all([load(a), load(b)]);
const c = document.createElement('canvas'); c.width = ia.width; c.height = ia.height; const g = c.getContext('2d');
g.drawImage(ia, 0, 0); const pa = g.getImageData(0, 0, c.width, c.height).data; g.clearRect(0, 0, c.width, c.height); g.drawImage(ib, 0, 0); const pb = g.getImageData(0, 0, c.width, c.height).data;
let n = 0, x0 = 1e9, y0 = 1e9, x1 = -1, y1 = -1;
for (let i = 0; i < pa.length; i += 4) { if (pa[i] !== pb[i] || pa[i + 1] !== pb[i + 1] || pa[i + 2] !== pb[i + 2] || pa[i + 3] !== pb[i + 3]) { n++; const p = i / 4, x = p % c.width, y = (p / c.width) | 0; if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; } }
return { n, box: n ? [x0, y0, x1 - x0 + 1, y1 - y0 + 1] : null, total: c.width * c.height };
}, [aPng, bPng]);
await ctx.close(); return r;
}
const SITE_LIVE = `http://127.0.0.1:${sitePort}/live`, SITE_HOME = `http://127.0.0.1:${sitePort}/`, APP = `http://127.0.0.1:${appPort}/t/mock/?scenario=live&theme=dark`;
const openInspect = async page => { await page.waitForSelector('#dag-inspect', { timeout: 15000 }); await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); await page.waitForTimeout(300); await page.click('#dag-inspect'); await page.waitForTimeout(300); };
try {
say(`scene-parity: fixture ${fixture.now}, ${fixture.blocks.length} blocks, key ${KEY} for the overlay case, canvases ${SIZE.w}x${SIZE.h}, instants ${INSTANTS.join('/')} ms`);
const live = await surface('live', SITE_LIVE, { selectCanvas: '#dag' });
say(' /live base: ' + describe(live));
const home = await surface('home', SITE_HOME, { selectCanvas: '#dag' });
say(' home fold base: ' + describe(home));
for (const s of [live, home]) for (const e of s.errors) say(' page error on ' + s.name + ': ' + e);
if (live.frames.some(f => !f.lit)) problems.push('/live drew nothing');
await compare('home fold = /live (same renderer, same feed, same options)', home, live, false);
if (hasApp) {
// known-failed first: one token changed on the app side must fail
const mutated = await surface('app-mutated', APP, { selectCanvas: '#dag-full', prepare: openInspect, mutate: ':root{--included:#3B7DD9!important}' });
say(' app Inspect with --included changed by one unit: ' + describe(mutated));
await compare('KNOWN-FAILED: app with a changed palette token differs from /live', mutated, live, true);
const app = await surface('app', APP, { selectCanvas: '#dag-full', prepare: openInspect });
say(' app Inspect base: ' + describe(app));
for (const e of app.errors) say(' page error on app: ' + e);
if (!dims(app, live)) problems.push('canvas buffers differ in size');
await compare('app Inspect = /live', app, live, false);
// the overlay: the same key on every surface
const liveK = await surface('live-key', SITE_LIVE, { selectCanvas: '#dag', ownKey: KEY });
const appK = await surface('app-key', APP, { selectCanvas: '#dag-full', prepare: openInspect, ownKey: KEY });
say(' /live with the key: ' + describe(liveK));
say(' app Inspect with the key: ' + describe(appK));
await compare('app Inspect with this machine\'s key = /live with the same key (the overlay is the same picture)', appK, liveK, false);
await compare('the app\'s overlay frame differs from its base frame (the overlay is drawn)', appK, app, true);
const compact = await surface('app-compact', APP, { selectCanvas: '#dag-live', prepare: async page => { await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); } });
say(' app compact card (its own mode, reported, not compared): ' + describe(compact));
} else {
say(' app surface: skipped (no app/igneum-app/ui/live-dag.js in this tree; the app lives on the release branches)');
}
} finally { await browser.close(); stop(); }
say(problems.length ? `scene-parity: RED, ${problems.length} problem(s): ${problems.join('; ')}` : 'scene-parity: ok');
if (out) writeFileSync(join(out, 'report.txt'), lines.join('\n') + '\n');
process.exit(problems.length ? 1 : 0);