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>
150 lines
7.3 KiB
JavaScript
150 lines
7.3 KiB
JavaScript
// Browser smoke test for onion controls and rendered ghost pixels.
|
|
// Uses an in-memory fixture and performs no server-side writes.
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
|
|
const profile = mkdtempSync(join(tmpdir(), 'arthur-onion-'));
|
|
const port = 9337;
|
|
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
|
|
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
|
|
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
|
|
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
|
|
], { stdio: 'ignore' });
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
let ws;
|
|
|
|
try {
|
|
let target;
|
|
for (let i = 0; i < 100 && !target; i++) {
|
|
await sleep(100);
|
|
try {
|
|
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
|
|
.find(t => t.type === 'page' && t.url.startsWith(url));
|
|
} catch { /* Chromium is still starting. */ }
|
|
}
|
|
assert(target, 'browser exposes the editor page');
|
|
ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
|
|
let serial = 0;
|
|
const pending = new Map();
|
|
const errors = [];
|
|
ws.onmessage = ({ data }) => {
|
|
const msg = JSON.parse(data);
|
|
if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails);
|
|
if (msg.id && pending.has(msg.id)) {
|
|
const waiting = pending.get(msg.id);
|
|
pending.delete(msg.id);
|
|
if (msg.error) waiting.reject(new Error(JSON.stringify(msg.error)));
|
|
else waiting.resolve(msg.result);
|
|
}
|
|
};
|
|
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
const id = ++serial;
|
|
pending.set(id, { resolve, reject });
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
const evaluate = async expression => {
|
|
const result = await send('Runtime.evaluate', {
|
|
expression, returnByValue: true, awaitPromise: true,
|
|
});
|
|
if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
|
|
return result.result.value;
|
|
};
|
|
|
|
await send('Runtime.enable');
|
|
for (let i = 0; i < 100; i++) {
|
|
if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break;
|
|
await sleep(100);
|
|
}
|
|
await evaluate(`(() => {
|
|
const k = cljs.core.keyword;
|
|
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test')));
|
|
window.laneSnapshot = () => {
|
|
const db = cljs.core.deref(re_frame.db.app_db);
|
|
return cljs.core.clj__GT_js(arthur.footage.store.entry(cljs.core.get(db, k('clip/current'))));
|
|
};
|
|
return true;
|
|
})()`);
|
|
await sleep(250);
|
|
|
|
assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾');
|
|
await evaluate(`document.querySelector('.onion-controls > button').click()`);
|
|
await sleep(150);
|
|
assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true');
|
|
await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').click()`);
|
|
await sleep(100);
|
|
assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), true);
|
|
await evaluate(`document.querySelector('.menu-scrim').click()`);
|
|
await sleep(100);
|
|
assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), false, 'a click outside shuts the onion settings');
|
|
await evaluate(`(() => {
|
|
const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs);
|
|
const db = c.deref(re_frame.db.app_db);
|
|
const entry = arthur.footage.store.entry(c.get(db, k('clip/current')));
|
|
const base = c.get(entry, k('clip'));
|
|
const cel = (id, drawing, at) => m(k('id'), k(id), k('kind'), k('instance'), k('z'), id,
|
|
k('source'), m(k('symbol'), k(drawing)), k('span'), v(0, 2),
|
|
k('time'), m(k('at'), at, k('rate'), 1),
|
|
k('playback'), m(k('in'), 0, k('speed'), 0, k('end'), k('stop')));
|
|
const drawing = (id, x) => m(k('id'), k(id), k('frames'), 1, k('nodes'),
|
|
m(k('mark'), m(k('id'), k('mark'), k('kind'), k('rect'), k('z'), 'a',
|
|
k('channels'), m(v(k('style'), k('color')), arthur.domain.channel.framed(1), v(k('geom'), k('size')), arthur.domain.channel.framed(6),
|
|
v(k('xform'), k('pos')), arthur.domain.channel.framed(v(x, 10))))));
|
|
const symbols = m(k('main'), m(k('id'), k('main'), k('frames'), 6, k('display'), k('lane'),
|
|
k('nodes'), m(k('a'), cel('a','ink-a',0), k('b'), cel('b','ink-b',2), k('c'), cel('c','ink-c',4))),
|
|
k('ink-a'), drawing('ink-a',10), k('ink-b'), drawing('ink-b',20), k('ink-c'), drawing('ink-c',30));
|
|
const doc = c.assoc(base, k('symbols'), symbols);
|
|
const id = arthur.footage.store.install_BANG_(c.assoc(entry,k('clip'),doc),'onion-test');
|
|
c.swap_BANG_(re_frame.db.app_db, db => {
|
|
db = c.assoc(db,k('clip/current'),id);
|
|
db = c.assoc_in(db,v(k('ui'),k('open')),k('main'));
|
|
db = c.assoc_in(db,v(k('ui'),k('solo')),c.hash_map());
|
|
db = c.assoc_in(db,v(k('playback'),k('frame')),2);
|
|
db = c.assoc_in(db,v(k('playback'),k('playing?')),false);
|
|
return db;
|
|
});
|
|
re_frame.core.dispatch_sync(v(k('arthur.ui.layout/onion'),k('on?'),true));
|
|
arthur.ui.player.refresh_subs_BANG_();
|
|
return true;
|
|
})()`);
|
|
await sleep(300);
|
|
const pixels = await evaluate(`(() => {
|
|
const ctx = document.querySelector('canvas.onion-skin').getContext('2d');
|
|
return [10,20,30].map(x => Array.from(ctx.getImageData(x,10,1,1).data));
|
|
})()`);
|
|
assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'previous cel is red');
|
|
assert.equal(pixels[1][3], 0, 'current cel is excluded');
|
|
assert(pixels[2][2] > pixels[2][0] && pixels[2][3] > 0, 'next cel is blue');
|
|
const playbackAlpha = await evaluate(`(() => {
|
|
const c=cljs.core, k=c.keyword;
|
|
c.swap_BANG_(re_frame.db.app_db, db => c.assoc_in(db,c.vector(k('playback'),k('playing?')),true));
|
|
arthur.ui.player.refresh_subs_BANG_();
|
|
arthur.ui.player.paint_BANG_(2);
|
|
return document.querySelector('canvas.onion-skin').getContext('2d').getImageData(10,10,1,1).data[3];
|
|
})()`);
|
|
assert.equal(playbackAlpha, 0, 'ghosts clear during playback');
|
|
const preferences = await evaluate(`(() => {
|
|
const c=cljs.core,k=c.keyword, read=()=>c.get(c.deref(re_frame.core.subscribe(c.vector(k('arthur.ui.layout/onion')))),k('on?'));
|
|
c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('ink-a')));
|
|
const other = read();
|
|
c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('main')));
|
|
return [other,read()];
|
|
})()`);
|
|
assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol');
|
|
assert.equal(errors.length, 0, JSON.stringify(errors));
|
|
console.log('PASS: onion controls, ghost colors, current-cel exclusion, playback hiding, per-symbol preferences');
|
|
} finally {
|
|
if (ws?.readyState === WebSocket.OPEN) ws.close();
|
|
chrome.kill('SIGTERM');
|
|
await new Promise(resolve => chrome.once('exit', resolve));
|
|
try {
|
|
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} catch (error) {
|
|
if (error.code !== 'ENOTEMPTY') throw error;
|
|
}
|
|
}
|