// 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'); 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')); let keys=m(); for(let i=0;i<1000;i++) keys=cljs.core.assoc(keys,i,v(i,0)); for(const channel of ['pos','scale']) c=cljs.core.assoc_in(c,v(k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k(channel))),arthur.domain.channel.keyed(keys,k('linear'))); c=cljs.core.assoc_in(c,v(k('symbols'),k('main'),k('frames')),1200); const id=arthur.footage.store.install_BANG_(m(k('clip'),c,k('store'),m()),'large-selection-browser'); cljs.core.swap_BANG_(re_frame.db.app_db,db=>cljs.core.assoc(db,k('clip/current'),id)); })()`); await sleep(300); assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'),2000); await evaluate(`(() => { window.shiftCalls=0; window.originalShifted=arthur.domain.keyframes.shifted; arthur.domain.keyframes.shifted=(...args)=>{window.shiftCalls++;return window.originalShifted(...args);}; document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'a',metaKey:true,bubbles:true})); })()`); await sleep(150); assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'),2000); assert.equal(await evaluate('window.shiftCalls'),0,'selection performs no group shift calculations per marker'); await evaluate(`(() => { const key=document.querySelector('button.tl-key'), b=key.parentElement.getBoundingClientRect(); key.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:90,clientX:b.left})); key.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:90,clientX:b.left+b.width*2/1200})); })()`); await sleep(150); assert.equal(await evaluate('window.shiftCalls'),1,'large group drag computes shifted positions once for all 2000 markers'); await evaluate(`(() => { const key=document.querySelector('button.tl-key'); key.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:90})); arthur.domain.keyframes.shifted=window.originalShifted; 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')))))); })()`).then(keys=>assert.equal(keys[0],2,'drop commits before the pointerup handler returns')); await sleep(150); assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'),2000,'drop preserves the entire large selection'); assert.equal(await evaluate('[...document.querySelectorAll("button.tl-key")].filter(el=>el.style.transform).length'),0,'committed keys replace the preview without leftover transforms'); assert.equal(errors.length, 0, JSON.stringify(errors)); console.log('PASS: passive overview ticks; automation editing, group edits, undo, and 2000-key selection performance'); } 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; } }