#!/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
] 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 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);