// Local editor smoke test. Uses the in-memory blank document and disables the // project route, so it never creates an account, project, or 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-sequence-')); const port = 9335; 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 { /* browser 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 { resolve, reject } = pending.get(msg.id); pending.delete(msg.id); if (msg.error) reject(new Error(JSON.stringify(msg.error))); else 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 r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails)); return r.result.value; }; await send('Runtime.enable'); for (let i = 0; i < 100; i++) { if (await evaluate('typeof arthur !== "undefined" && !!arthur.events?.ui && !!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); const entry = arthur.footage.store.entry(cljs.core.get(db, k('clip/current'))); return cljs.core.clj__GT_js(entry); }; return true; })()`); await sleep(250); // A command is named the same wherever it is drawn, and since the transport // strip was consolidated it is drawn in one of two places: as a button in the // strip, or as a row in one of the strip's menus. So the test asks for it by // name and this finds it — opening each menu in turn to look — rather than the // test knowing which menu anything ended up in. An icon button is matched on // its `aria-label`, which is also what a screen reader is told it is. // Two bars carry commands: the location bar says where an edit lands and holds // what creates things there, the transport strip holds what acts on a cel. const bars = ['.loc', '.pane.time .pane-head', '.section']; const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', '); const named = label => `(b => b.textContent.trim() === ${JSON.stringify(label)}` + ` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`; const shut = async () => { await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`); await sleep(120); }; // Leaves the control on screen and returns what to select it with. const reveal = async label => { await shut(); if (await evaluate(`![...document.querySelectorAll('${within('button')}')].find(${named(label)})`)) { const menus = await evaluate( `[...document.querySelectorAll('${within('.menu-wrap > button')}')].map(b => b.textContent.trim())`); let found = false; for (const menu of menus) { await evaluate(`(() => { [...document.querySelectorAll('${within('.menu-wrap > button')}')] .find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`); await sleep(180); if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; } await shut(); } assert(found, `a control named: ${label}`); return '.menu-item'; } return within('button'); }; const click = async label => { const where = await reveal(label); assert(await evaluate(`(() => { const b = [...document.querySelectorAll('${where}')].find(${named(label)}); if (!b || b.disabled) return false; b.click(); return true; })()`), `enabled control: ${label}`); await sleep(180); await shut(); }; // UUIDs expose a mutable hash cache through clj->js; compare their identity, // not that implementation detail, when asserting exact undo restoration. const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'), (_key, value) => value?.uuid ?? value)); const instances = s => Object.values(s.clip.symbols.main.nodes) .filter(n => n.kind === 'instance') .sort((a, b) => a.time.at - b.time.at); const placed = s => instances(s).map(n => [ n.time.at + n.span[0] / (n.time.rate ?? 1), n.time.at + n.span[1] / (n.time.rate ?? 1), ]); const key = async (key, extra = {}) => { await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra}); await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra}); await sleep(180); }; const undo = () => key('z', {modifiers: 2}); const drag = async (selector, df, {zone = 0.5, shift = false} = {}) => { const points = await evaluate(`(() => { const handle = document.querySelector(${JSON.stringify(selector)}); if (!handle) return null; const track = handle.closest('.tl-track'); const h = handle.getBoundingClientRect(); const t = track.getBoundingClientRect(); const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]); const x = h.left + h.width * ${zone}; const y = h.top + h.height / 2; return {x, y, end: x + t.width * ${df} / frames}; })()`); assert(points, `drag handle exists: ${selector}`); const modifiers = shift ? 8 : 0; await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: points.x, y: points.y, button: 'left', buttons: 1, clickCount: 1, modifiers, }); await sleep(100); await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: points.end, y: points.y, button: 'left', buttons: 1, modifiers, }); await sleep(100); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: points.end, y: points.y, button: 'left', buttons: 0, clickCount: 1, modifiers, }); await sleep(250); }; const dropPoolSymbol = async frame => { const points = await evaluate(`(() => { const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]'); const track = document.querySelector('.tl-track'); if (!source || !track) return null; source.scrollIntoView({block: 'center'}); const a = source.getBoundingClientRect(), b = track.getBoundingClientRect(); const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]); return {sx: a.left + a.width / 2, sy: a.top + a.height / 2, tx: b.left + b.width * (${frame} + 0.25) / frames, ty: b.top + b.height / 2}; })()`); assert(points, 'a library symbol and lane are available to drag'); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx, y: points.sy}); await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy, button: 'left', buttons: 1, clickCount: 1}); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.sx + 12, y: points.sy, button: 'left', buttons: 1}); await sleep(120); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty, button: 'left', buttons: 1}); await sleep(120); assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0, 'targeting an existing lane does not preview a temporary new row'); const lanePreview = await evaluate(`(() => { const db = cljs.core.deref(re_frame.db.app_db), k = cljs.core.keyword; return {ghosts: document.querySelectorAll('.tl-track .tl-cel.ghost').length, drop: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('drop')]))}; })()`); assert.equal(lanePreview.ghosts, 1, `the pool drop preview is drawn inside the targeted lane: ${JSON.stringify(lanePreview)}`); await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty, button: 'left', buttons: 0, clickCount: 1}); await sleep(300); }; const dragClipBetweenLanes = async frame => { const points = await evaluate(`(() => { const tracks = [...document.querySelectorAll('.tl-track')]; const source = tracks[1]?.querySelector('.tl-cel'); const target = tracks[0]; if (!source || !target) return null; const a = source.getBoundingClientRect(), b = target.getBoundingClientRect(); const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]); return {sx: a.left + a.width / 2, sy: a.top + a.height / 2, tx: b.left + b.width * (${frame} + 0.25) / frames, ty: b.top + b.height / 2}; })()`); assert(points, 'two lanes and a source clip are available'); await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: points.sx, y: points.sy, button: 'left', buttons: 1, clickCount: 1}); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: points.tx, y: points.ty, button: 'left', buttons: 1}); await sleep(150); assert.equal(await evaluate('document.querySelectorAll(".tl-track")[0].querySelectorAll(".tl-cel.ghost").length'), 1, 'cross-lane movement previews in the destination lane'); await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: points.tx, y: points.ty, button: 'left', buttons: 0, clickCount: 1}); await sleep(300); }; assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true, 'timing buttons are disabled without a symbol clip'); await click('inside'); let s = await shot(); assert.deepEqual(placed(s), [[0, 1]], 'new at the root automatically makes a lane and a one-frame symbol clip'); assert.equal(await evaluate('document.querySelectorAll(".tl-label .kind").length'), 1, 'new temporal content creates a lane row rather than a row per symbol'); assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0, 'there is one temporal interface'); assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3, 'timing operations are direct buttons'); await drag('.tl-cel .tl-edge.out', 3); s = await shot(); assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint'); for (let i = 0; i < 4; i++) await click('+1'); await click('inside'); await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2); for (let i = 0; i < 3; i++) await click('+1'); await click('inside'); s = await shot(); assert.deepEqual(placed(s), [[0, 4], [4, 7], [7, 8]]); await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.5}); assert.deepEqual(placed(await shot()), [[0, 5], [5, 7], [7, 8]], 'the middle of a junction rolls both edges'); await undo(); await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.9}); assert.deepEqual(placed(await shot()), [[0, 4], [5, 7], [7, 8]], 'the right side trims only the right clip'); await undo(); await drag('.tl-cel:nth-of-type(2) .tl-junction', -1, {zone: 0.1}); assert.deepEqual(placed(await shot()), [[0, 3], [4, 7], [7, 8]], 'the left side trims only the left clip'); await undo(); await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2, {shift: true}); assert.deepEqual(placed(await shot()), [[0, 4], [4, 9], [9, 10]], 'Shift-edge ripples every later clip on the lane'); await undo(); await drag('.tl-cel:first-of-type .tl-edge.out', 2); assert.deepEqual(placed(await shot()), [[0, 6], [6, 7], [7, 8]], 'ordinary growth trims adjacent spans and never overlaps'); await dropPoolSymbol(10); s = await shot(); assert.deepEqual(placed(s), [[0, 6], [6, 7], [7, 8], [10, 11]], 'an arbitrary library symbol drops into an existing lane'); assert.equal(instances(s).at(-1).playback.speed, 1, 'a dropped symbol plays naturally instead of becoming a held drawing'); await evaluate(`re_frame.core.dispatch(cljs.core.vector(cljs.core.keyword('arthur.events.ui/new-lane')))`); await sleep(180); s = await shot(); const renameControls = await evaluate('document.querySelectorAll(".tl-label .tl-rename").length'); assert.equal(renameControls, 2, `both lanes expose rename controls: ${JSON.stringify(s.clip.symbols.main.nodes)}`); await evaluate('document.querySelector(".tl-label .tl-rename").click()'); await sleep(80); assert(await evaluate(`(() => { const input = document.querySelector('.tl-name-input'); if (!input) return false; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'Foreground'); input.dispatchEvent(new InputEvent('input', {bubbles: true, inputType: 'insertText', data: 'Foreground'})); input.blur(); return true; })()`), 'lane rename editor opens'); await sleep(180); s = await shot(); assert(Object.values(s.clip.symbols.main.nodes).some(n => n.layout === 'sequence' && n.name === 'Foreground'), 'a lane name is editable and persisted in the document'); await dragClipBetweenLanes(12); s = await shot(); const lanes = Object.values(s.clip.symbols.main.nodes).filter(n => n.layout === 'sequence'); assert.deepEqual(lanes.map(l => instances(s).filter(n => n.parent === l.id).length).sort(), [1, 3], 'a clip body can move from one lane to another'); assert.equal(errors.length, 0, JSON.stringify(errors)); console.log('PASS: generic lanes preview, rename, move, place, trim, roll, and ripple clips'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' })); await sleep(350); } ws?.close(); chrome.kill(); await new Promise(resolve => { if (chrome.exitCode !== null || chrome.signalCode !== null) resolve(); else chrome.once('exit', resolve); }); try { rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (error) { console.warn(`Temporary browser profile retained at ${profile}: ${error.code}`); } }