// Browser smoke test for the explicit-lane workflow. 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-explicit-lane-')); 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 { /* 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); const named = label => `(b => b.getAttribute('aria-label') === ${JSON.stringify(label)}` + ` || b.textContent.trim() === ${JSON.stringify(label)})`; const closeMenus = async () => { await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(x => x.click()); return true })()`); await sleep(80); }; const clickNew = async label => { await closeMenus(); assert(await evaluate(`(() => { const menu = document.querySelector('.loc .menu-wrap > button'); if (!menu) return false; menu.click(); return true; })()`), 'new menu exists'); await sleep(100); assert(await evaluate(`(() => { const item = [...document.querySelectorAll('.menu-item')].find(${named(label)}); if (!item || item.disabled) return false; item.click(); return true; })()`), `enabled creation command: ${label}`); await sleep(220); await closeMenus(); }; const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'), (_key, value) => value?.uuid ?? value)); const shortcut = async (key, code, modifiers = 2) => { const windowsVirtualKeyCode = key.toUpperCase().charCodeAt(0); await send('Input.dispatchKeyEvent', {type: 'rawKeyDown', key, code, modifiers, windowsVirtualKeyCode}); await send('Input.dispatchKeyEvent', {type: 'keyUp', key, code, modifiers: 0, windowsVirtualKeyCode}); await sleep(180); }; const mainInstances = s => Object.values(s.clip.symbols.main.nodes) .filter(n => n.kind === 'instance'); const laneSymbols = s => Object.values(s.clip.symbols).filter(sym => sym.display === 'lane'); assert.equal((await shot()).clip.symbols.main.display, undefined, 'a blank document starts as an ordinary symbol'); await clickNew('inside'); let s = await shot(); let placed = mainInstances(s); assert.equal(placed.length, 1, 'new symbol places one instance'); assert.equal(s.clip.symbols[placed[0].source.symbol].display, undefined, 'new symbol remains ordinary'); assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1, 'an ordinary symbol is an ordinary timeline row'); await clickNew('lane'); s = await shot(); placed = mainInstances(s); assert.equal(placed.length, 2, 'explicit lane places a second symbol'); assert.equal(laneSymbols(s).length, 1, 'only the lane command marks a symbol as a lane'); assert.equal(await evaluate('[...document.querySelectorAll(".tl-track")].filter(t => t.arthurLane).length'), 1, 'the explicit lane is drawn as one linear track'); assert.equal(await evaluate('document.querySelectorAll(".tl-rename").length'), 1, 'the lane exposes its rename control'); await evaluate('document.querySelector(".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'})); input.blur(); return true; })()`), 'lane rename editor opens'); await sleep(180); s = await shot(); assert(mainInstances(s).some(n => n.name === 'Foreground'), 'lane name persists'); // Drag a library symbol into the explicit lane. The row itself is the target; // no temporary lane is previewed or created. const drop = await evaluate(`(() => { const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]'); const track = [...document.querySelectorAll('.tl-track')].find(t => t.arthurLane); if (!source || !track) return null; source.scrollIntoView({block: 'center'}); const a = source.getBoundingClientRect(), b = track.getBoundingClientRect(); return {sx: a.left + a.width / 2, sy: a.top + a.height / 2, tx: b.left + b.width * .085, ty: b.top + b.height / 2}; })()`); assert(drop, 'a pool symbol and explicit lane are available'); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx, y: drop.sy}); await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: drop.sx, y: drop.sy, button: 'left', buttons: 1, clickCount: 1}); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 6, y: drop.sy, button: 'left', buttons: 1}); await sleep(60); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.sx + 18, y: drop.sy, button: 'left', buttons: 1}); await sleep(140); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: drop.tx, y: drop.ty, button: 'left', buttons: 1}); await sleep(300); assert.equal(await evaluate('document.querySelectorAll(".tl-label.ghost").length'), 0, 'pool drop does not preview an invented lane'); await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: drop.tx, y: drop.ty, button: 'left', buttons: 0, clickCount: 1}); await sleep(300); s = await shot(); assert.equal(Object.keys(laneSymbols(s)[0].nodes).length, 1, `the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`); assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane'); // A second explicit lane is a sibling in the open symbol even though the // first remains aimed. Move the clip between their linear tracks. await clickNew('lane'); s = await shot(); assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane'); const move = await evaluate(`(() => { const tracks = [...document.querySelectorAll('.tl-track')].filter(t => t.arthurLane); const from = tracks.find(t => t.querySelector('.tl-cel')); const to = tracks.find(t => t !== from); const cel = from?.querySelector('.tl-cel'); if (!cel || !to) return null; const a = cel.getBoundingClientRect(), b = to.getBoundingClientRect(); return {sx: a.left + a.width / 2, sy: a.top + a.height / 2, tx: b.left + b.width * .12, ty: b.top + b.height / 2}; })()`); assert(move, 'two explicit lanes and a source clip are available'); await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: move.sx, y: move.sy, button: 'left', buttons: 1, clickCount: 1}); await send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: move.tx, y: move.ty, button: 'left', buttons: 1}); await sleep(150); await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: move.tx, y: move.ty, button: 'left', buttons: 0, clickCount: 1}); await sleep(300); s = await shot(); assert.deepEqual(laneSymbols(s).map(x => Object.keys(x.nodes).length).sort(), [0, 1], 'a clip body moves from one explicit lane to the other'); // Start a clean local document for clipboard and timeline multi-selection. await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( cljs.core.keyword('arthur.events.project/new'))) `); await sleep(250); await clickNew('inside'); const clipboardBaseline = (await shot()).clip; await shortcut('d', 'KeyD'); assert.equal(mainInstances(await shot()).length, 2, 'duplicate copies the selected instance'); await shortcut('z', 'KeyZ'); assert.equal(mainInstances(await shot()).length, 1, 'one undo removes the whole duplicate'); await shortcut('z', 'KeyZ', 10); assert.equal(mainInstances(await shot()).length, 2, 'one redo restores the duplicate'); assert(await evaluate(`(() => { const rows = [...document.querySelectorAll('.tl-label')].filter(x => x.draggable); if (rows.length < 2) return false; rows[0].click(); rows[1].dispatchEvent(new MouseEvent('click', {bubbles: true, shiftKey: true})); return true; })()`), 'two timeline rows are available for multi-selection'); await sleep(200); assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2, 'timeline Shift-click mirrors the two-member selection'); assert(await evaluate(`(() => { const row = [...document.querySelectorAll('.tl-label')].find(x => x.draggable); if (!row) return false; row.click(); return true; })()`), 'a timeline row can select the redone instance'); await sleep(120); await shortcut('c', 'KeyC'); await shortcut('x', 'KeyX'); assert.equal(mainInstances(await shot()).length, 1, 'cut removes the selection'); await shortcut('z', 'KeyZ'); assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut'); await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( cljs.core.keyword('arthur.events.ui/aim'), null))`); await sleep(80); await shortcut('v', 'KeyV'); assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot'); await shortcut('z', 'KeyZ'); await shortcut('z', 'KeyZ'); assert.equal(mainInstances(await shot()).length, 1, 'clipboard edits undo independently'); assert.deepEqual((await shot()).clip, clipboardBaseline, 'clipboard exercises undo back to the byte-for-byte document'); assert.equal(errors.length, 0, JSON.stringify(errors)); console.log('PASS: lanes, mirrored selection, and atomic clipboard commands'); } 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; } }