// 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); const click = async label => { assert(await evaluate(`(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)}); if (!b || b.disabled) return false; b.click(); return true; })()`), `enabled button: ${label}`); await sleep(180); }; const shot = async () => (await evaluate('laneSnapshot()')); const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance') .sort((a, b) => a.time.at - b.time.at); await click('+ lane'); await click('new drawing'); await click('hold +'); await click('hold +'); await click('hold +'); await click('new drawing'); let s = await shot(); assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 4], [4, 1]]); assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 2); assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1); assert.equal(await evaluate(`cljs.core.get_in(cljs.core.deref(re_frame.db.app_db), cljs.core.vector(cljs.core.keyword('playback'), cljs.core.keyword('frame')))`), 4, 'new drawing seeks to its cel'); // Shorten this test shot to the occupied extent, purely in memory. await evaluate(`(() => { const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db); arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')), clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 5)); document.querySelector('.tl-cel').click(); })()`); await sleep(200); const before = await shot(); await click('hold +'); s = await shot(); assert.deepEqual(s.clip, before.clip, 'refused overflow makes no document change'); assert.equal(s.history.done.length, before.history.done.length); await click('extend shot and apply'); s = await shot(); assert.equal(s.clip.symbols.main.frames, 6); assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 5], [5, 1]]); assert.equal(s.history.done.length, before.history.done.length + 1); await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`); await sleep(250); assert.deepEqual((await shot()).clip, before.clip, 'one undo restores cel, ripple, and shot length'); // Sharing: one drawing exposed twice, then one cel decoupled. Room is // made first so these assertions are about content and not about overflow. await evaluate(`(() => { const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db); arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')), clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 20)); document.querySelector('.tl-cel').click(); })()`); await sleep(200); const enabled = async label => await evaluate(`(() => { const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)}); return !!b && !b.disabled; })()`); assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet'); await click('reuse'); s = await shot(); let cels = instances(s); assert.equal(cels.length, 3); assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing'); assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3); assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, 'three cels, still one row'); assert.equal(await enabled('make unique'), true); await click('make unique'); s = await shot(); cels = instances(s); assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that cel has its own drawing'); assert.equal(await enabled('make unique'), false, 'and is not shared any more'); await click('duplicate'); s = await shot(); cels = instances(s); assert.equal(cels.length, 4); assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4, 'four cels of four drawings: nothing is shared once every copy is made'); assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps'); // A drawing into the middle of a hold: split, then insert. Both act at the // playhead, and neither guesses what the other one is for. 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)]) .sort((a, b) => a[0] - b[0]); assert.deepEqual(placed(s), [[0, 4], [4, 5], [5, 6], [6, 7]]); await evaluate(`document.querySelector('.tl-cel').click()`); await sleep(200); assert.equal(await enabled('split'), false, 'the start of a cel is not inside it'); await click('+1'); await click('+1'); assert.equal(await enabled('split'), true); await click('split'); s = await shot(); assert.deepEqual(placed(s), [[0, 2], [2, 4], [4, 5], [5, 6], [6, 7]], 'one cel became two, over the frames it had'); await click('insert'); s = await shot(); assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 5], [5, 6], [6, 7], [7, 8]], 'the new drawing took frame 2 and everything from there rippled later'); assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 6); assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, 'six cels, still one row'); assert.equal(s.history.done.length, before.history.done.length + 5); // Trim, move and blank: three gestures that move nothing but their own // cel, and a shot whose length does not follow what is in it. await evaluate(`[...document.querySelectorAll('.tl-cel')][2].click()`); await sleep(200); assert.deepEqual(placed(await shot()).slice(2, 4), [[3, 5], [5, 6]]); await click('+1'); assert.equal(await enabled('trim out'), true, 'the playhead is inside it'); await click('trim out'); s = await shot(); assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 4], [5, 6], [6, 7], [7, 8]], 'it ends at the playhead and every other cel stayed'); assert.equal(await enabled('move here'), true); await click('move here'); s = await shot(); assert.deepEqual(placed(s), [[0, 2], [2, 3], [4, 5], [5, 6], [6, 7], [7, 8]], 'and moves to the playhead, into the gap it just made'); await click('blank'); s = await shot(); assert.deepEqual(placed(s), [[0, 2], [2, 3], [5, 6], [6, 7], [7, 8]], 'blanked: a gap where it was, and nothing closed it'); assert.equal(s.clip.symbols.main.frames, 20, 'the shot is as long as it was authored'); assert.equal(s.history.done.length, before.history.done.length + 8); assert.equal(errors.length, 0, JSON.stringify(errors)); console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split, insert, trim, move and blank; no server writes'); } 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}`); } }