Top bar: every document command is an icon button in a run — panes, new/open, undo/history/redo, cut/copy/paste/duplicate, snapshots — with the title and status in the middle and export, share and account on the right. Pane toggles are pictures of the window with that pane filled in. Stage bar: tracing, onion and passepartout are all the same toggle-plus-settings split button, followed by one zoom group, with fit inside it. Popovers: one `menu/popover`, measured against its button and dismissed by a scrim, replaces the <details> popouts that never closed on an outside click. Palette: the strip ends in a palette button whose popover lists every palette with its swatch strip, renames the open one in place, and marks the ones on the stage. An info badge says when the palette being edited is not one the stage draws in at the playhead; both ends of a palette-lane blend count. `clip/palette-at` is the resolver's root palette lookup, pulled out so the strip asks the same question the renderer answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
807 lines
38 KiB
JavaScript
807 lines
38 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.querySelector('.top button[aria-label="open a project"]');
|
|
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.querySelectorAll('.palette-bar button')]
|
|
.find(b => b.textContent === 'pen');
|
|
if (!button) return false;
|
|
button.click();
|
|
return true;
|
|
})()`);
|
|
await sleep(100);
|
|
const painted = armed && await page.eval(`(() => {
|
|
const svg = document.querySelector('.paint-overlay');
|
|
const box = svg.getBoundingClientRect();
|
|
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
|
|
svg.dispatchEvent(new PointerEvent('pointerdown', {
|
|
bubbles: true, clientX: box.left + x * box.width / 320,
|
|
clientY: box.top + y * box.height / 200,
|
|
}));
|
|
}
|
|
return true;
|
|
})()`);
|
|
await sleep(100);
|
|
const finished = await page.eval(`(() => {
|
|
const button = [...document.querySelectorAll('.palette-bar button')]
|
|
.find(b => b.textContent === 'finish');
|
|
if (!button || button.disabled) return false;
|
|
button.click();
|
|
return true;
|
|
})()`);
|
|
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');
|
|
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');
|
|
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); });
|