264 lines
16 KiB
JavaScript
264 lines
16 KiB
JavaScript
// 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;
|
|
}
|
|
}
|