arthur/frontend/test/browser/take.mjs

300 lines
12 KiB
JavaScript

// Drives a real Chrome at the running dev server and checks that the frozen
// take is a MOVING MOUTH on a canvas.
//
// This exists because port-plan step 5 is the first step whose done-criterion is
// a picture, and a picture cannot be asserted from cljs.test. A take that
// resolves to the right numbers and draws nothing would pass every assertion in
// arthur.flow.freeze-test: a blank canvas under a perfectly correct transport is
// the bug class unit tests miss, and it has already happened once here.
//
// No dependencies, deliberately. Playwright is not installed and CDP needs
// nothing: `node --experimental-websocket` has a global WebSocket and
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
//
// cd frontend
// mise exec -- npx shadow-cljs compile app
// mise exec -- npx shadow-cljs watch app # or `server`, for :dev-http
// mise exec -- node --experimental-websocket test/browser/take.mjs
//
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
// something" can be checked by eye as well as by pixel count.
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = join(HERE, 'out');
const URL_ = process.env.ARTHUR_URL ?? 'http://localhost:8778/index.html';
const PORT = 9333;
const CHROME = process.env.CHROME ??
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
// The palette's background, from domain/palette. A pixel of this colour is
// nothing drawn, and every check below is a count of pixels that are not it.
const BG = [0x12, 0x14, 0x1c];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
function check(ok, label, detail = '') {
console.log(`${ok ? ' ok ' : ' FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
if (!ok) failures++;
}
// ---------------------------------------------------------------------------
// the CDP connection
async function connect() {
const profile = mkdtempSync(join(tmpdir(), 'arthur-chrome-'));
const chrome = spawn(CHROME, [
'--headless=new',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${profile}`,
// The take plays against audio.wav, and the clock IS the audio element, so
// without these the frame never advances and "plays back" cannot be checked
// at all — the failure would look like a broken rAF loop.
'--autoplay-policy=no-user-gesture-required',
'--mute-audio',
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--window-size=1200,900',
URL_,
], { stdio: ['ignore', 'ignore', 'pipe'] });
chrome.stderr.on('data', () => {});
let wsUrl = null;
for (let i = 0; i < 100 && !wsUrl; i++) {
await sleep(100);
try {
const targets = await fetch(`http://127.0.0.1:${PORT}/json/list`).then((r) => r.json());
wsUrl = targets.find((t) => t.type === 'page' && t.url.includes('index.html'))
?.webSocketDebuggerUrl;
} catch { /* not up yet */ }
}
if (!wsUrl) throw new Error(`Chrome never offered a page target on ${PORT}`);
const ws = new WebSocket(wsUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
let id = 0;
const pending = new Map();
const logs = [];
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
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);
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
logs.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
} else if (msg.method === 'Runtime.exceptionThrown') {
logs.push(msg.params.exceptionDetails.text + ' ' +
(msg.params.exceptionDetails.exception?.description ?? ''));
}
};
const send = (method, params = {}) =>
new Promise((res, rej) => {
const n = ++id;
pending.set(n, { res, rej });
ws.send(JSON.stringify({ id: n, method, params }));
});
await send('Runtime.enable');
await send('Page.enable');
return {
send, logs,
async eval(expr) {
const r = await this.send('Runtime.evaluate', {
expression: expr, awaitPromise: true, returnByValue: true,
});
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description ??
r.exceptionDetails.text);
}
return r.result.value;
},
async shot(name) {
const r = await this.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64'));
},
close() { ws.close(); chrome.kill(); rmSync(profile, { recursive: true, force: true }); },
};
}
// ---------------------------------------------------------------------------
// what we ask the page
//
// Every probe reads the canvas's own pixels rather than a screenshot: the CSS
// scales the stage up by 2 with image-rendering:pixelated, so a screenshot is
// four pixels per raster pixel and is for looking at, not for counting.
const PROBE = `(() => {
const c = document.querySelector('canvas.stage');
if (!c) return null;
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
const bg = [${BG.join(',')}];
let drawn = 0;
const tones = new Set();
let cx = 0, cy = 0;
for (let i = 0; i < d.length; i += 4) {
if (d[i] === bg[0] && d[i+1] === bg[1] && d[i+2] === bg[2]) continue;
drawn++;
tones.add((d[i] << 16) | (d[i+1] << 8) | d[i+2]);
const p = i / 4;
cx += p % c.width; cy += Math.floor(p / c.width);
}
// A cheap 32-bit hash of the whole buffer: two frames with the same drawn
// count can still be different pictures, and "the mouth moved" is a question
// about the picture.
let h = 2166136261;
for (let i = 0; i < d.length; i += 4) { h ^= d[i] + d[i+1] * 31 + d[i+2] * 131; h = Math.imul(h, 16777619); }
return {
w: c.width, h: c.height, drawn, tones: [...tones].length,
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0,
frame: document.querySelector('.readout span')?.textContent ?? '',
scene: [...document.querySelectorAll('.transport .row button')]
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
};
})()`;
const SEEK = (f) => `(() => {
const el = document.querySelector('input.scrub');
// React installs its own value setter on the element, so assigning .value
// directly updates the DOM and not React's idea of it, and onChange never
// fires. The prototype-level setter plus a bubbling 'input' event is what
// React's synthetic onChange actually listens for.
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(el, '${f}');
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value;
})()`;
const CLICK = (label) => `(() => {
const b = [...document.querySelectorAll('.transport button')]
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
if (!b) return false;
b.click();
return true;
})()`;
// ---------------------------------------------------------------------------
async function main() {
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
const page = await connect();
try {
// Mounted, and painting. Polled rather than waited on a fixed delay: the
// canvas :ref fires after the loop starts, so there genuinely is a window in
// which the page is up and the canvas is blank.
let probe = null;
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe && probe.drawn > 0) break;
await sleep(100);
}
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
console.log(`\ncanvas ${probe.w}x${probe.h}, scene ${JSON.stringify(probe.scene)}`);
check(probe.scene.includes('take'), 'the take is the scene that opens');
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
// --- it is a mouth: two tones, one inside the other ---
//
// The three-layer structure is what makes a flat shape read as an opening
// rather than a blob, so the interior being a SECOND tone is the check that
// this is a mouth and not one polygon.
await page.eval(SEEK(10)); // beat 1: wide open
await sleep(120);
const open = await page.eval(PROBE);
await page.shot('take-open');
check(open.tones >= 2, 'an open mouth draws an outline and an interior',
`${open.tones} tones`);
await page.eval(SEEK(28)); // beat 3: shut
await sleep(120);
const shut = await page.eval(PROBE);
await page.shot('take-shut');
check(shut.tones === 1, 'a shut mouth draws the outline alone',
`${shut.tones} tones`);
check(open.hash !== shut.hash, 'the open and the shut mouth are different pictures');
check(open.drawn > shut.drawn, 'the open mouth covers more of the stage',
`${open.drawn} vs ${shut.drawn} px`);
// --- it moves under the head, and the head is a channel ---
const filmed = [];
for (const f of [0, 40, 80, 120, 160, 200]) {
await page.eval(SEEK(f));
await sleep(120);
filmed.push(await page.eval(PROBE));
}
check(new Set(filmed.map((p) => p.hash)).size === filmed.length,
'every sampled frame is a different picture');
const xs = filmed.map((p) => p.cx);
check(Math.max(...xs) - Math.min(...xs) > 8,
'as filmed, the head carries the mouth across the stage',
`centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`);
check(await page.eval(CLICK('locked')), 'the locked take is selectable');
await sleep(200);
const locked = [];
for (const f of [0, 40, 80, 120, 160, 200]) {
await page.eval(SEEK(f));
await sleep(120);
locked.push(await page.eval(PROBE));
}
await page.shot('take-locked');
const lxs = locked.map((p) => p.cx);
check(locked.every((p) => p.drawn > 200), 'the locked take draws too');
// The same blocks with one node's channels written differently: the mouth
// still articulates, and the head no longer wanders. That is the claim
// "stabilisation is a channel, not a mode", by eye.
check(Math.max(...lxs) - Math.min(...lxs) < (Math.max(...xs) - Math.min(...xs)) / 2,
'locked, the head holds still while the mouth still articulates',
`centroid x spans ${(Math.max(...lxs) - Math.min(...lxs)).toFixed(1)} px`);
check(new Set(locked.map((p) => p.hash)).size > 3,
'and it is still a performance, not a still frame');
// --- it PLAYS, against the audio clock ---
check(await page.eval(CLICK('take')), 'back to the take');
await sleep(150);
await page.eval(SEEK(0));
await sleep(150);
check(await page.eval(CLICK('play')), 'play is clickable');
const during = [];
for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); }
await page.eval(CLICK('pause'));
// The readout is "frame 12 / 229", so the first run of digits is the
// playhead. Parsed rather than reached for in app-db on purpose: what the
// page SHOWS is what a person would check, and the readout agreeing with the
// picture is half of what the transport is for.
const nums = during.map((p) => parseInt((p.frame.match(/\d+/) ?? [NaN])[0], 10));
check(nums[nums.length - 1] > nums[0] + 5, 'the playhead advances under the clock',
`frame ${nums[0]} -> ${nums[nums.length - 1]}`);
check(new Set(during.map((p) => p.hash)).size > 4,
'and the picture changes while it runs',
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
await page.shot('take-playing');
check(page.logs.length === 0, 'no errors on the console',
page.logs.slice(0, 3).join(' | '));
} finally {
page.close();
}
console.log(`\n${failures ? `${failures} FAILED` : 'all checks passed'}` +
` — screenshots in test/browser/out/\n`);
process.exit(failures ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(2); });