270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
// 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');
|
|
|
|
// The new symbol is now the active creation parent. Return to the tab root
|
|
// to ask for a sibling lane in `main`; leaving it selected would correctly
|
|
// create the lane inside that symbol.
|
|
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
|
|
cljs.core.keyword('arthur.events.ui/select'), null))`);
|
|
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');
|
|
|
|
// Return to the root active row, then make a sibling lane there. Lane
|
|
// creation follows the selected target; the unit suite separately asserts
|
|
// that leaving a symbol selected creates the lane inside that symbol.
|
|
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
|
|
cljs.core.keyword('arthur.events.ui/select'), null))`);
|
|
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/select'), 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;
|
|
}
|
|
}
|