diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 8d3b095a5..25e3c4e95 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -100,6 +100,7 @@ tree_checks() { run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check' run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs + run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test diff --git a/tools/scene/parity-remote.sh b/tools/scene/parity-remote.sh new file mode 100755 index 000000000..b6ecd75a0 --- /dev/null +++ b/tools/scene/parity-remote.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# The scene parity test on a build box (the Mac runs no headless browser: standing rule, 7 October 2026). Carries the files the +# test needs to the box under this lane's prefix, runs tools/scene/parity.mjs there and prints its report; the box's own +# Playwright build and Chromium (root installed the libraries on 7 October 2026, 14:5x UK) do the rendering. +# +# tools/scene/parity-remote.sh [--box N] [--out ] default box 2; --out fetches the frames and the report back +# +# Where it runs: a box named by ~/.config/igneum/build-server[-N] (build@); on a machine with no box file but a local +# Playwright (a CI runner that is itself a box) it runs parity.mjs in place; with neither it prints a skip line and exits 0, +# so a plain GitHub runner stays green while every box-side and Mac-side gate renders for real. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 +BOX=2; OUT="" +while [ $# -gt 0 ]; do case "$1" in --box) BOX="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; *) echo "unknown argument $1" >&2; exit 2 ;; esac; done +HOSTFILE="$HOME/.config/igneum/build-server"; [ "$BOX" = 1 ] || HOSTFILE="$HOSTFILE-$BOX" +if [ ! -s "$HOSTFILE" ]; then + if [ -n "${PLAYWRIGHT_CORE:-}" ] || [ -f /srv/builds/_bin/overlap/node_modules/playwright-core/package.json ]; then exec node tools/scene/parity.mjs ${OUT:+--out "$OUT"}; fi + echo "scene-parity: skipped, no build box file at $HOSTFILE and no Playwright here"; exit 0 +fi +HOST="$(cat "$HOSTFILE")"; KEY="$HOME/.ssh/igneum_ed25519" +SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST") +WT="$(basename "$(pwd)")"; DEST="/srv/builds/scene-parity/$WT" +"${SSH[@]}" "mkdir -p $DEST/site $DEST/tools/ui-mock $DEST/tools/scene $DEST/scene $DEST/app/igneum-app/ui" || { echo "scene-parity: box $HOST unreachable" >&2; exit 1; } +RS=(rsync -a --delete -e "ssh -i $KEY -o BatchMode=yes") +"${RS[@]}" --exclude img --exclude verify --exclude '*.png' --exclude '*.ico' site/ "$HOST:$DEST/site/" +"${RS[@]}" scene/ "$HOST:$DEST/scene/" +"${RS[@]}" tools/scene/ "$HOST:$DEST/tools/scene/" +"${RS[@]}" tools/ui-mock/ "$HOST:$DEST/tools/ui-mock/" +"${RS[@]}" tools/site-serve.mjs "$HOST:$DEST/tools/site-serve.mjs" +if [ -f app/igneum-app/ui/live-dag.js ]; then "${RS[@]}" --exclude '*.woff2' app/igneum-app/ui/ "$HOST:$DEST/app/igneum-app/ui/"; else "${SSH[@]}" "rm -rf $DEST/app"; fi +[ -f brand/igneum-coin-1024.png ] && "${SSH[@]}" "mkdir -p $DEST/brand" && "${RS[@]}" brand/igneum-coin-1024.png "$HOST:$DEST/brand/igneum-coin-1024.png" +# the copied-sources rule: re-stamped on the far side before anything runs (touch), so no stale mtime decides anything there +"${SSH[@]}" "cd $DEST && find . -type f -exec touch {} + && git init -q . 2>/dev/null; [ -f /srv/builds/_bin/overlap/env.sh ] && . /srv/builds/_bin/overlap/env.sh; cd $DEST && timeout 240 nice -n 10 node tools/scene/parity.mjs --out $DEST/_out" +RC=$? +if [ -n "$OUT" ]; then mkdir -p "$OUT"; rsync -a -e "ssh -i $KEY -o BatchMode=yes" "$HOST:$DEST/_out/" "$OUT/" 2>/dev/null; fi +exit $RC diff --git a/tools/scene/parity.mjs b/tools/scene/parity.mjs new file mode 100644 index 000000000..6c8a1d13c --- /dev/null +++ b/tools/scene/parity.mjs @@ -0,0 +1,184 @@ +#!/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);