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