arthur/frontend/test/browser/take.mjs
Olive Vaughn 78fb120edd ikd mn
2026-10-04 18:14:05 -04:00

820 lines
39 KiB
JavaScript

// Drives a real Chrome at the running server and checks two things that no
// assertion in cljs.test can: that the frozen take is a MOVING MOUTH on a canvas,
// and that a document which has been through the server comes back as the same
// picture.
//
// 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.
//
// Since step 9 the page is Django's, so the suite needs the backend up rather than
// shadow-cljs's `:dev-http`, which is gone. ARTHUR_URL is unchanged because the
// port is unchanged — 8778 was never 8777, which is still the old JS tool's.
//
// mise exec -- python manage.py runserver 8778 # from the REPO ROOT
// cd frontend && mise exec -- npx shadow-cljs watch app
// cd frontend && 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, spawnSync } 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];
// :mouth-dark, from domain/palette, packed the way PROBE packs a tone. It is the
// mouth INTERIOR's colour and nothing else on the stage carries it, so its
// presence is the whole of "the mouth is open" without needing to know where the
// mouth is. A whole-canvas tone COUNT cannot answer that any more: step 7 put
// eyes, brows, an iris, a pupil and teeth on the same stage, so every frame has
// several tones whatever the mouth is doing.
const MOUTH_DARK = (0x24 << 16) | (0x16 << 8) | 0x1a;
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'));
},
// Teardown must not be able to fail the run. `chrome.kill()` returns as soon
// as the signal is sent, so Chrome is often still writing its profile when
// rmSync walks it, and macOS then throws ENOTEMPTY on a directory that
// reappeared mid-delete. That throw used to propagate out of main's `finally`
// and past the summary line, so the suite exited 2 and printed no verdict
// whether it had passed or failed — which is how a stale assertion sat here
// unnoticed. A leaked temp profile is worth less than a truthful exit code.
close() {
ws.close();
chrome.kill();
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
} catch (e) {
console.log(` note could not remove the temp profile — ${e.code ?? e.message}`);
}
},
};
}
// ---------------------------------------------------------------------------
// 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, toneSet: [...tones],
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0,
frame: document.querySelector('.time .pane-head .at-frame')?.textContent ?? '',
// What the top bar says about the document. The media pool holds the OPEN
// document's library now and no longer names documents at all, so the status
// line is the page's own answer to "what am I looking at".
doc: document.querySelector('.top .status')?.textContent ?? '',
};
})()`;
// The scrubber is the timeline's ruler — there is no range input any more — so a
// seek is a pointer event at the frame's own column. The ruler maps x to
// floor(x / width * frames), and a mark that names one frame sits at its centre,
// so (f + 0.5) lands inside f and nowhere near its neighbours.
//
// The frame count comes off the readout rather than being passed in: the caller
// knows which frame it wants, not how long the clip it is looking at happens to
// be, and reading it here is one place instead of every call site.
const SEEK = (f) => `(() => {
const read = document.querySelector('.time .pane-head .at-frame').textContent;
// Split, not a regex: this is inside a template literal, where an escaped
// slash collapses to a bare one and the two together open a line comment that
// eats the rest of the statement. The readout is "12 / 229" and nothing else.
const frames = Number(read.split('/')[1]);
if (!frames) return 'no readout';
const ruler = document.querySelector('.tl-ruler');
const box = ruler.getBoundingClientRect();
ruler.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 1,
clientX: box.left + (${f} + 0.5) * box.width / frames,
clientY: box.top + box.height / 2,
}));
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
return document.querySelector('.time .pane-head .at-frame').textContent;
})()`;
// Everything the page has to say about loading, saving and opening. Read off the
// page rather than out of app-db, for the same reason the playhead is: what the
// page SHOWS is what a person would check.
const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body > .dim')]
.map((d) => d.textContent).join(' | ')`;
// Any button on the page, by its own label. The controls are spread across five
// panes now — the transport is in the timeline's header, save and open are in the
// top bar, the clips are rows in the media pool — and a helper that knew which
// pane each one lived in would be a second copy of the layout.
//
// `firstChild` is the label: a media-pool row has a second line in a child span,
// so matching on textContent would never find "take". `aria-label` is the same
// label for a control whose face is a glyph — the transport's play and pause —
// and it is the right fallback rather than a test hook, because it is already
// what that button is called for anybody not reading it with their eyes.
const CLICK = (label) => `(() => {
const want = ${JSON.stringify(label)};
const b = [...document.querySelectorAll('button')]
.find((b) => (b.firstChild?.textContent ?? '').trim() === want
|| b.getAttribute('aria-label') === want);
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// Documents are behind File -> Open now, not in the media pool: the pool holds
// the OPEN document's library, and a whole project is not a thing you put on a
// stage. So reaching a built-in scene or a saved project is two clicks with a
// turn of the event loop between them, as it is for anybody using the app.
const MENU_OPEN = `(() => {
if (document.querySelector('.menu')) return true;
const b = [...document.querySelectorAll('button')]
.find((b) => b.textContent.trim() === 'open \u25be');
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// Scoped to `.example`, and it has to be: the local server accumulates a saved
// project called "take" on every run of this suite, so an unscoped match by
// label picks whichever of those sorted first and the fixture is never reached.
const MENU_PICK = (label) => `(() => {
const b = [...document.querySelectorAll('.menu .menu-item.example')]
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// The first row under PROJECTS, which the server orders by -updated: this is
// what "open" meant before there was a list to pick from.
const MENU_PICK_NEWEST = `(() => {
const b = document.querySelector('.menu .menu-item:not(.example)');
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// ---------------------------------------------------------------------------
async function main() {
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
const page = await connect();
const fromMenu = async (what) => {
if (!(await page.eval(MENU_OPEN))) return false;
await sleep(150);
return page.eval(what);
};
try {
// Mounted. 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 there is nothing to measure.
//
// It is NOT polled for pixels any more. The app opens on a blank document —
// no clip is loaded until one is asked for — so "the canvas has drawn
// something" is now a thing this suite makes happen rather than a thing it
// waits for.
let probe = null;
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe && probe.w) break;
await sleep(100);
}
if (!probe) throw new Error('no canvas.stage on the page — ' +
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
// `/` is the index, and a project is only ever at its own address, so the
// suite signs up, makes one, and opens it — the page's own navigation, so
// this CDP session and its console stay attached.
const home = await page.eval(`(async () => {
const post = (url, body) => fetch(url, {method: 'POST', body: JSON.stringify(body),
headers: {'Content-Type': 'application/json',
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)[1]}}).then(r => r.json());
await post('/api/signup', {username: 'suite-' + Date.now().toString(36), password: 'password1'});
const made = await post('/api/projects', {name: 'untitled'});
arthur.events.collab.navigate_BANG_(arthur.events.collab.project_path(made.id, made.name));
return location.pathname; })()`);
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (/opened/.test(probe.doc)) break;
await sleep(100);
}
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc} at ${home}`);
check(probe.drawn === 0 && /opened untitled/.test(probe.doc),
'a new project opens on a blank document',
`${probe.drawn} px drawn — ${probe.doc}`);
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
// Everything below is about the frozen take, so open it out of the pool.
check(await fromMenu(MENU_PICK('take')), 'the take opens from the open menu');
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe.drawn > 0) break;
await sleep(100);
}
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 its OWN tone is the check that
// this is a mouth and not one polygon. Asked of :mouth-dark specifically
// rather than of the tone count: the count is now dominated by the eyes and
// brows, which are on the stage whatever the mouth is doing.
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.toneSet.includes(0x171a22), 'nested face pupils reach the rasterizer');
check(open.toneSet.includes(MOUTH_DARK),
'an open mouth draws an outline and an interior',
`${open.tones} tones, interior ${open.toneSet.includes(MOUTH_DARK) ? 'present' : 'MISSING'}`);
await page.eval(SEEK(28)); // beat 3: shut
await sleep(120);
const shut = await page.eval(PROBE);
await page.shot('take-shut');
check(!shut.toneSet.includes(MOUTH_DARK), 'a shut mouth draws the outline alone',
`${shut.tones} tones, interior ${shut.toneSet.includes(MOUTH_DARK) ? 'STILL DRAWN' : 'gone'}`);
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 fromMenu(MENU_PICK('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 fromMenu(MENU_PICK('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 "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');
// --- it round-trips through the server ---
//
// THE DONE CRITERION of port-plan step 9, end to end: tier 1 over HTTP, tier 2
// as content-addressed blocks, and the same frames on the far side. The ops are
// compared frame for frame in arthur.domain.project-test, which is the strict
// version of this; what only a browser can check is that the whole path — the
// CSRF header, the block upload, the leaf write, the reload, the typed arrays
// rebuilt out of base64 — draws the same pixels at the end of it.
async function statusMatching(pattern, tries = 120) {
for (let i = 0; i < tries; i++) {
const text = await page.eval(STATUS);
if (pattern.test(text)) return text;
await sleep(250);
}
return null;
}
async function sample(frames) {
const out = [];
for (const f of frames) {
await page.eval(SEEK(f));
await sleep(120);
out.push(await page.eval(PROBE));
}
return out;
}
const FRAMES = [0, 10, 28, 80, 160];
check(await fromMenu(MENU_PICK('take')), 'back to the take, for the round trip');
await sleep(150);
const sent = await sample(FRAMES);
// There is no save: every edit saves itself, and a built-in example opened
// in a project becomes a project of its own at once.
const saved = await statusMatching(/saved r\d+/);
check(saved !== null, 'the take becomes a saved project by itself', saved ?? (await page.eval(STATUS)));
// Upload count may be zero when the content-addressed blocks already exist
// on this server. The saved clip must still reference them.
const savedBlocks = await page.eval(`(async () => {
const listed = await fetch('/api/projects').then(r => r.json());
const project = await fetch('/api/projects/' + listed.projects[0].id).then(r => r.json());
return project.clips[0].blocks.length;
})()`);
check(savedBlocks > 0, 'the saved clip references its tier 2 blocks',
`${savedBlocks} blocks`);
// Unchanged, nothing is written: the project's seq stands still. This is
// the assertion that the keys are stable and a clean document is clean.
const seqOf = `(async () => {
const id = location.pathname.split('/')[2];
return (await fetch('/api/projects/' + id).then(r => r.json())).seq; })()`;
const seqBefore = await page.eval(seqOf);
await sleep(800);
const seqAfter = await page.eval(seqOf);
check(seqBefore === seqAfter, 'an unchanged document writes nothing',
`seq ${seqBefore} -> ${seqAfter}`);
check(await fromMenu(MENU_PICK_NEWEST), 'open is clickable');
const opened = await statusMatching(/opened /);
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
const back = await sample(FRAMES);
await page.shot('take-round-trip');
check(back.every((p) => p.drawn > 200), 'the reopened take draws',
back.map((p) => p.drawn).join(','));
check(FRAMES.every((f, i) => sent[i].hash === back[i].hash),
'every sampled frame is the same picture after the round trip',
FRAMES.filter((f, i) => sent[i].hash !== back[i].hash).join(',') || 'all identical');
check(back[1].toneSet.includes(MOUTH_DARK),
'and the open mouth still has an interior on the far side');
// The reopened clip is not one of the built-ins: this is the document that came
// back from the server, not the one that was in the page all along.
check(/opened /.test(back[0].doc), 'the picture is the reopened document',
back[0].doc);
// Paint through the visible controls. This exercises the SVG pointer path,
// the authored node, the raster preview and the document round trip.
await page.eval(SEEK(0));
await sleep(150);
// A lane row means "new one-frame cel". This test authors drawing keys
// across the existing take, so make that cel the active row first; creation
// then enters the symbol it places, exactly as a person clicking its block.
await page.eval(`(() => {
const cel = document.querySelector('.tl-cel');
if (!cel?.arthurCel?.select) return false;
re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), cel.arthurCel.select));
return true;
})()`);
await sleep(150);
const beforePaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
return [...d];
})()`);
// Arming the tool and clicking the vertices are two evals with a turn of the
// event loop between them, and they have to be: picking the tool dispatches a
// re-frame event, which is queued rather than applied, so a pointerdown in the
// same synchronous block arrives while the tool is still unset and is dropped.
// A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => {
const button = document.querySelector('.toolbox button.tool[title^="Pen"]');
if (!button) return false;
button.click();
return true;
})()`);
await sleep(100);
// The overlay reaches past the stage on every side, so a stage pixel is
// found through the SVG's own transform, as the stage finds it coming back.
// Each point is its own eval: a click is a re-frame event, and the next one
// reads the draft it added to.
const PEN_AT = (x, y) => `(() => {
const svg = document.querySelector('.paint-overlay.tool-pen');
if (!svg) return false;
const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM());
svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: p.x, clientY: p.y,
}));
return true;
})()`;
let painted = armed;
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
painted = painted && await page.eval(PEN_AT(x, y));
await sleep(60);
}
// Closed as a person closes it: on the first point.
const finished = painted && await page.eval(PEN_AT(10, 10));
await sleep(250);
const afterPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
return [...d];
})()`);
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
'a polygon drawn with the paint controls appears on the canvas',
`${beforePaint} -> ${afterPaint}`);
for (const f of [8, 16]) {
await page.eval(SEEK(f));
await sleep(100);
check(await page.eval(`(() => {
const button = [...document.querySelectorAll('.params button')]
.find(b => b.textContent === 'drawing key here');
if (!button || button.disabled) return false;
button.click();
return true;
})()`), `a drawing key can be added at frame ${f}`);
await sleep(100);
}
await page.eval(SEEK(8));
await sleep(100);
const tweenSelected = await page.eval(`(() => {
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
const select = label?.querySelector('select');
if (!select) return false;
select.value = 'linear';
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
await sleep(100);
check(tweenSelected && await page.eval(`(() => {
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the second drawing gap can be set to tween');
check((await statusMatching(/saved r\d+/)) !== null,
'the painted shape saves by itself', await page.eval(STATUS));
check(await fromMenu(MENU_PICK_NEWEST), 'the painted document can be reopened');
check((await statusMatching(/opened /)) !== null,
'the painted shape is reopened', await page.eval(STATUS));
await sleep(150);
const reopenedPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
})()`);
check(afterPaint.join(',') === reopenedPaint.join(','),
'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`);
// A reopened document has nothing selected, so the shape's keys are not in
// the inspector until it is selected again, as anybody would.
await page.eval(`(() => {
const c = cljs.core, k = c.keyword;
const db = c.deref(re_frame.db.app_db);
const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')),
c.vector(k('symbols'), k('main'), k('nodes')));
const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes)));
re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'),
c.vector(k('node'), k('main'), id, c.vector(id))));
})()`);
await page.eval(SEEK(8));
await sleep(100);
check(await page.eval(`(() => {
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the per-gap tween setting survives the project round trip');
// The local 8625 study is optional in a fresh test database. When present,
// tune its shared symbol while its stage is playing.
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
.then(r => r.ok)`);
if (stageSource) {
check(await fromMenu(MENU_PICK('8625 stage study')), 'the 8625 stage loads');
const stageLoaded = await statusMatching(/loaded 8625 stage study/, 160);
check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS)));
const eyeSelected = await page.eval(`(() => {
const select = document.querySelector('.params .section:last-child select');
// Settings belong to tracked features shared by the stage placements.
const option = [...select.options]
.find(o => o.textContent.trim().endsWith('feature · face-1/eye-r'));
if (!option) return false;
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
check(eyeSelected, 'a stage instance exposes its tracked right eye');
await sleep(100);
const irisSlider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'iris-size');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);
check(irisSlider !== null, 'the stage eye has an iris-size slider');
if (irisSlider) {
check(await page.eval(CLICK('play')), 'the stage starts playing');
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
await page.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
});
await page.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
});
const preview = await statusMatching(/preview · unsaved/, 160);
const debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
'the panel reports the affected feature and tier', debug);
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`);
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
await page.eval(CLICK('pause'));
}
const subjectSelected = await page.eval(`(() => {
const select = document.querySelector('.params .section:last-child select');
const option = [...select.options]
.find(o => o.textContent.includes('subject · face-1'));
if (!option) return false;
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
check(subjectSelected, 'the stage exposes its tracked subject');
if (subjectSelected) {
await sleep(100);
const anchorSlider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'anchor-avg');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);
check(anchorSlider !== null, 'the subject has an anchor slider');
if (anchorSlider) {
const anchorStart = Date.now();
await page.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: anchorSlider.x, y: anchorSlider.y,
button: 'left', clickCount: 1,
});
await page.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: anchorSlider.x, y: anchorSlider.y,
button: 'left', clickCount: 1,
});
let debug = '';
for (let i = 0; i < 160; i++) {
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
if (debug.includes('head-pos')) break;
await sleep(250);
}
check(debug.includes('head-pos') && debug.includes(':face-1/teeth'),
'anchor invalidates the head and teeth', debug);
const preview = await statusMatching(/preview · unsaved/, 160);
check(preview !== null, 'the anchor edit finishes previewing',
preview ? `${Date.now() - anchorStart}ms` : (await page.eval(STATUS)));
}
}
const teethSelected = await page.eval(`(() => {
const select = document.querySelector('.params .section:last-child select');
const option = [...select.options]
.find(o => o.textContent.trim().endsWith('feature · face-1/teeth'));
if (!option) return false;
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
check(teethSelected, 'the stage exposes its tracked teeth');
if (teethSelected) {
await sleep(100);
const slider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'cavity-erode');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
// otherwise reports a y outside the window, the click lands on nothing,
// and the failure reads as "regeneration is broken" rather than "the
// slider was off-screen". Which is exactly what it read as once.
row.scrollIntoView({ block: 'center' });
const r = row.querySelector('input').getBoundingClientRect();
return { x: r.x + r.width * 0.9, y: r.y + r.height / 2 };
})()`);
check(slider !== null, 'the teeth have a pixel-setting slider');
if (slider) {
await page.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: slider.x, y: slider.y,
button: 'left', clickCount: 1,
});
await page.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: slider.x, y: slider.y,
button: 'left', clickCount: 1,
});
let debug = '';
for (let i = 0; i < 160; i++) {
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
if (debug.includes('dirty: [:face-1/teeth]')) break;
await sleep(250);
}
check(debug.includes('dirty: [:face-1/teeth]'),
'the pixel setting invalidates only teeth', debug);
const preview = await statusMatching(/preview · unsaved/, 160);
check(preview !== null, 'the teeth edit finishes previewing',
preview ?? (await page.eval(STATUS)));
}
}
}
// Exercise the actual file input and FormData path, then wait for the
// extraction job's footage to appear in the server list.
const uploadDir = mkdtempSync(join(tmpdir(), 'arthur-upload-'));
try {
const video = join(uploadDir, 'browser-upload.mp4');
const encoded = spawnSync('ffmpeg', [
'-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi',
'-i', 'color=c=blue:s=64x48:r=4:d=1', '-c:v', 'mpeg4', video,
]);
if (encoded.status !== 0) throw new Error(`ffmpeg failed: ${encoded.stderr}`);
const { root } = await page.send('DOM.getDocument');
const { nodeId } = await page.send('DOM.querySelector', {
nodeId: root.nodeId, selector: 'input[type=file]',
});
await page.send('DOM.setFileInputFiles', { files: [video], nodeId });
// Waited for in the MEDIA POOL rather than in the status line: the row
// appearing under this project's media is the durable evidence, and it is
// what a person would look at.
let extracted = null;
for (let i = 0; i < 160 && extracted === null; i++) {
await sleep(250);
if (await page.eval(`[...document.querySelectorAll('.pool-item')]
.some((b) => (b.querySelector('.text')?.firstChild?.textContent ?? '').trim() === 'browser-upload.mp4')`)) {
extracted = 'listed in the media pool';
}
}
check(extracted !== null, 'the page uploads and extracts a video',
extracted ?? (await page.eval(STATUS)));
const uploadedFootage = await page.eval(`(async () => {
const listed = await fetch('/api/footage').then(r => r.json());
return listed.footage.some(f => f.label === 'browser-upload.mp4' && f.frames === 4);
})()`);
check(uploadedFootage, 'the uploaded footage is selectable');
} finally {
rmSync(uploadDir, { recursive: true, force: true });
}
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); });