Keep overview keyframes passive and edit expanded automation lanes

This commit is contained in:
Olive Vaughn 2026-10-04 02:05:22 -04:00
parent 606055382b
commit 4d441ae606
12 changed files with 856 additions and 69 deletions

View file

@ -0,0 +1,264 @@
// Browser smoke test for timeline keyframes and group editing. It uses the in-memory
// document and disables project routing, so it performs no server-side write.
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-timeline-'));
const port = 9336;
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++) {
try {
if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break;
} catch (error) {
if (!error.message.includes("Cannot find default execution context")) throw error;
}
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);
await evaluate(`(() => {
const k = cljs.core.keyword, v = cljs.core.vector, m = cljs.core.hash_map;
let c = arthur.domain.clip.blank();
const rect = (id, at, keys) => m(k('id'), k(id), k('kind'), k('rect'), k('z'), id,
k('time'), m(k('at'), at, k('rate'), 1), k('span'), v(0, 20),
k('channels'), keys ? m(v(k('xform'), k('pos')), arthur.domain.channel.keyed(m(2, v(2,0), 4, v(4,0)), k('linear'))) : m());
const target = m(k('id'), k('target'), k('kind'), k('instance'), k('z'), 'z',
k('source'), m(k('symbol'), k('box')), k('span'), v(0, 60),
k('time'), m(k('at'), 0, k('rate'), 1),
k('playback'), m(k('in'), 0, k('speed'), 1, k('end'), k('stop')));
c = cljs.core.assoc_in(c, v(k('symbols'), k('box')), m(k('id'), k('box'), k('frames'), 60, k('nodes'), m()));
c = cljs.core.assoc_in(c, v(k('symbols'), k('main'), k('nodes')), m(k('a'), rect('a',0,true), k('b'), rect('b',4,false), k('target'), target));
const id = arthur.footage.store.install_BANG_(m(k('clip'), c, k('store'), m()), 'timeline-browser');
const a = v(k('node'),k('main'),k('a'),v(k('a'))), b = v(k('node'),k('main'),k('b'),v(k('b')));
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db,
k('clip/current'), id, k('paint/revision'), 100,
k('ui'), m(k('open'),k('main'),k('selection'), b,k('selections'),v(a,b),k('expanded'),cljs.core.hash_set(v(k('a')))),
k('playback'),m(k('frame'),0,k('playing?'),false)));
return true;
})()`);
await sleep(250);
const shot = async () => JSON.parse(JSON.stringify((await evaluate('laneSnapshot()')).clip,
(key, value) => key === 'channels' && Object.keys(value).length === 0 ? undefined : value));
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) button.tl-key").length'), 0,
'symbol overview keyframes are passive');
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) .tl-key").length'), 2,
'symbol overview still shows keyframe ticks');
const initial = await shot();
await evaluate(`(() => {
const track = document.querySelector('.tl-span.on').closest('.tl-track');
track.focus(); track.dispatchEvent(new KeyboardEvent('keydown', {key:'ArrowRight',bubbles:true})); return true;
})()`);
await sleep(100);
assert.equal((await shot()).symbols.main.nodes.a.time.at, 1);
assert.equal((await shot()).symbols.main.nodes.b.time.at, 5);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group shift undoes atomically');
await evaluate(`(() => {
const span=document.querySelector('.tl-span.on'), track=span.closest('.tl-track');
const box=track.getBoundingClientRect(), x=box.left+box.width*10/120, dx=box.width*2/120;
span.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:20,clientX:x}));
track.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:20,clientX:x+dx}));
track.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:20,clientX:x+dx}));
return true;
})()`);
await sleep(150);
assert.equal((await shot()).symbols.main.nodes.a.time.at, 2, 'drag shifts the first selected node');
assert.equal((await shot()).symbols.main.nodes.b.time.at, 6, 'drag shifts the other selected node');
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2,
'group dragging preserves selection');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group dragging undoes atomically');
assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 2);
await evaluate(`(() => {
const keys = [...document.querySelectorAll('button.tl-key')];
for (let i=0;i<2;i++) {
keys[i].dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i}));
keys[i].dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i}));
} return true;
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2);
await evaluate(`(() => {
const key=document.querySelector('button.tl-key.selected');
const box=key.parentElement.getBoundingClientRect(), x=box.left+box.width*2/120;
key.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:x}));
key.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x+box.width*2/120}));
key.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x+box.width*2/120}));
return true;
})()`);
await sleep(100);
assert.deepEqual(await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current')));
return cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c,
v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('pos')),k('keys'))))));
})()`), [4,6], 'drag moves both selected keys without losing colliding source frames');
await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2);
await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 0);
assert((await shot()).symbols.main.nodes.a, 'deleting keys preserves their node');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'key dragging, nudge and delete undo independently');
await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const a=v(k('node'),k('main'),k('a'),v(k('a'))),b=v(k('node'),k('main'),k('b'),v(k('b')));
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select-many'),v(a,b)));
arthur.ui.drag.row_BANG_(v(k('a')),k('rect'),a,v(a,b));
const row=[...document.querySelectorAll('.tl-label')].find(x=>x.textContent.includes('box'));
const r=row.getBoundingClientRect();
row.dispatchEvent(new DragEvent('drop',{bubbles:true,clientY:r.top+r.height/2,dataTransfer:new DataTransfer()}));
return true;
})()`);
await sleep(150);
const moved=await shot();
assert(moved.symbols.box.nodes.a && moved.symbols.box.nodes.b, 'row drop reparents the entire group');
assert.equal(moved.symbols.box.nodes.b.time.at, 4);
assert(!moved.symbols.main.nodes.a && !moved.symbols.main.nodes.b);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group reparenting is one undo step');
await evaluate(`(() => {
const rows=[...document.querySelectorAll('.tl-label')].filter(x=>x.querySelector('.tl-delete') || /box|rect/.test(x.textContent));
rows[0].dispatchEvent(new MouseEvent('click',{bubbles:true}));
rows.at(-1).dispatchEvent(new MouseEvent('click',{bubbles:true,shiftKey:true}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 3, 'Shift selects the intervening row headers');
await evaluate(`document.querySelectorAll('.tl-label.on')[1].dispatchEvent(new MouseEvent('click',{bubbles:true,metaKey:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2, 'Command toggles just one row');
await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector,m=cljs.core.hash_map;
let c=cljs.core.get(arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current'))),k('clip'));
c=cljs.core.assoc_in(c,v(k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('scale'))),
arthur.domain.channel.keyed(m(2,v(1,1),4,v(2,2)),k('linear')));
const id=arthur.footage.store.install_BANG_(m(k('clip'),c,k('store'),m()),'selection-browser');
cljs.core.swap_BANG_(re_frame.db.app_db,db=>cljs.core.assoc(db,k('clip/current'),id,k('ui'),
m(k('open'),k('main'),k('expanded'),cljs.core.hash_set(v(k('a'))))));
})()`);
await sleep(200);
await evaluate(`(() => {
const tracks=document.querySelector('.tl-tracks');
const rows=[...tracks.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2);
const first=rows[0], last=rows[1], b=first.getBoundingClientRect(), end=last.getBoundingClientRect();
first.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:50,clientX:b.left+1,clientY:b.top+1}));
tracks.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1}));
tracks.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'marquee selects keys across both automation rows');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`);
await sleep(100);
assert.deepEqual(await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current')));
return ['pos','scale'].map(channel=>cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c,
v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k(channel)),k('keys')))))));
})()`), [[3,5],[3,5]], 'marquee selection nudges keys in both automations together');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 0);
await evaluate(`(() => {
const rows=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2);
const a=rows[0].querySelector('button.tl-key'), b=rows[1].querySelectorAll('button.tl-key')[1];
for (const [key,shift] of [[a,false],[b,true]]) {
const box=key.getBoundingClientRect();
for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:70,clientX:box.left,clientY:box.top,shiftKey:shift}));
}
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Shift-click selects a range across automation rows');
await evaluate(`(() => {
const key=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2)[1].querySelectorAll('button.tl-key')[1];
const box=key.getBoundingClientRect();
for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:71,clientX:box.left,metaKey:true}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 3, 'Command-click toggles one channel key');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'a',metaKey:true,bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Command-A selects all visible keyframes');
assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: passive overview ticks; automation keyframe selection, dragging, marquee, keyboard editing, and undo');
} finally {
if (ws?.readyState === WebSocket.OPEN) ws.close();
chrome.kill('SIGTERM');
await Promise.race([
new Promise(resolve => chrome.once('exit', resolve)),
sleep(2000).then(() => { if (chrome.exitCode === null) chrome.kill('SIGKILL'); }),
]);
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
if (error.code !== 'ENOTEMPTY') throw error;
}
}