// 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 .dim')?.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 .dim').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 .dim').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". const CLICK = (label) => `(() => { const b = [...document.querySelectorAll('button')] .find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)}); 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?')); console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc}`); check(probe.drawn === 0 && /new document/.test(probe.doc), 'the app 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); check(await page.eval(CLICK('save')), 'save is clickable'); const saved = await statusMatching(/saved r\d+/); check(saved !== null, 'the document saves', 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`); // Again, unchanged. Content addressing means the second save uploads nothing // and rewrites nothing: this is the assertion that the keys are stable across // two independent freezes of the same take, and that an unchanged leaf keeps // its version rather than being rewritten. check(await page.eval(CLICK('save')), 'save is clickable again'); const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/); check(resaved !== null, 'saving an unchanged document writes nothing', resaved ?? (await page.eval(STATUS))); 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); 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 === 'polygon'); 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 page.eval(CLICK('save')), 'the painted document can be saved'); check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null, 'the painted shape is saved', 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 .dim').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 .dim').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. Dropping or // choosing a video now runs straight on into detection, so "video // extracted" is a state the app passes through in one turn of the event // loop and not one a poller can be relied on to catch. The row appearing in // the pool 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.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); });