// Browser smoke test for a palette swapper on the stage: a remap shape is // selected as a timeline row selects it, and the handles over it actually drag. // // WHAT A UNIT TEST CANNOT SEE, as `peg.mjs` says of a peg, and for a closely // related reason. A remap draws no colour of its own — it lights what is under // it — so `pick/hit-op` let a click go THROUGH it, and the stage's move gesture // is that hit-test. `handles` meanwhile hangs its box and corners off // `pick/bounds-of`, which reads the node's points and knows nothing about // colour. So a remap shape got a full set of handles over a shape that answered // no point on the stage at all: the first press on it started a marquee, the // marquee selected nothing, and the selection it had been given by a timeline // row was thrown away — which looks exactly like handles that do not work. // Every assertion about the maths still passed, hence this. // // A plain shape goes through the same two drags as the control, so a harness // that has stopped delivering pointer events cannot read as a pass. // // Uses an in-memory fixture and performs no server-side writes. import { spawn } from 'node:child_process'; import { mkdtempSync } 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-remap-')); const port = 9344; 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(r => setTimeout(r, ms)); let ws, id = 0; const pending = new Map(); const send = (method, params) => new Promise(res => { const n = ++id; pending.set(n, res); ws.send(JSON.stringify({ id: n, method, params })); }); const evalJS = async expr => { const r = await send('Runtime.evaluate', { expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails)); return r.result.value; }; let failures = 0; const ok = (cond, what, extra = '') => { console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`); if (!cond) failures++; }; // A press, two moves and a release, as a hand makes them: the stage only calls // a gesture a gesture once the pointer has left the press by a pixel. const drag = async (x, y, dx, dy) => { for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx / 2, dy / 2], ['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) { await send('Input.dispatchMouseEvent', { type, x: x + fx, y: y + fy, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse', }); await sleep(90); } await sleep(400); }; // The handles as DOM, in client pixels: what a hand would aim at. const rectOf = async sel => evalJS(` const q = document.querySelector('${sel}'); if (!q) return null; const r = q.getBoundingClientRect(); return {cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height}; `); const state = async which => evalJS(` const c = cljs.core, k = c.keyword, v = c.vector; const db = c.deref(re_frame.db.app_db); const e = arthur.footage.store.entry(c.get(db, k('clip/current'))); const n = c.get_in(c.get(e, k('clip')), v(k('symbols'), k('main'), k('nodes'), k('${which}'))); const ch = p => c.clj__GT_js(c.get_in(n, v(k('channels'), p, k('value')))); return {pos: ch(v(k('xform'), k('pos'))), scale: ch(v(k('xform'), k('scale'))), selection: c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))))}; `); 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 {} } assert(target, 'editor page'); ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise(r => ws.addEventListener('open', r)); ws.addEventListener('message', e => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); } }); await send('Runtime.enable'); for (let i = 0; i < 200; i++) { if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break; await sleep(250); } // Local route; a plain shape, and a remap shape beside it on its own ground. const setup = await evalJS(` const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map; c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test'))); let doc = arthur.domain.clip.blank(); doc = arthur.domain.paint.new_shape(doc, k('main'), k('plain'), 0, v(40, 40, 90, 40, 90, 90, 40, 90), 1); doc = arthur.domain.paint.new_shape(doc, k('main'), k('light'), 0, v(140, 40, 200, 40, 200, 100, 140, 100), v(k('remap'), m(1, 2))); const entry = m(k('clip'), doc, k('store'), m()); const cid = arthur.footage.store.install_BANG_(entry, 'remap-probe'); c.swap_BANG_(re_frame.db.app_db, db => c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')), k('clip/current'), cid, k('paint/revision'), 0)); return arthur.domain.symbol.remap_QMARK_( c.get_in(doc, v(k('symbols'),k('main'),k('nodes'),k('light'), k('channels'),v(k('style'),k('color')),k('value')))); `); ok(setup === true, 'the fixture holds a remap shape'); await sleep(800); // The rule the fix must not break: with nothing selected, a click still goes // through the light to what it lights. const through = await evalJS(` const c = cljs.core, v = c.vector; const hit = arthur.ui.player.at(v(170, 70)); return hit ? JSON.stringify(c.clj__GT_js(hit)) : null; `); ok(through === null, 'with nothing selected a click goes through the light', String(through)); for (const which of ['light', 'plain']) { console.log(`\n${which}:`); // Selected the way a timeline row selects it: a path, and no stage click. await evalJS(` const c = cljs.core, k = c.keyword, v = c.vector; re_frame.core.dispatch_sync(v(k('arthur.events.ui/set-tool'), k('select'))); re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'), v(k('node'), k('main'), k('${which}'), v(k('${which}'))))); return true; `); await sleep(500); const box = await rectOf('.paint-overlay .handles .box'); ok(!!box, 'the stage puts a box round it'); if (!box) continue; const before = await state(which); await drag(box.cx, box.cy, 25, 0); const moved = await state(which); ok(Array.isArray(moved.pos) && Math.abs(moved.pos[0] - 0) > 2, 'dragging its middle moves it', JSON.stringify(moved.pos)); ok(JSON.stringify(moved.selection) === JSON.stringify(before.selection), 'and the drag keeps the selection rather than marqueeing it away', JSON.stringify(moved.selection)); const corner = await rectOf('.paint-overlay .handles .corner'); ok(!!corner, 'and corners to scale by'); if (!corner) continue; await drag(corner.cx, corner.cy, 18, 18); const scaled = await state(which); ok(Array.isArray(scaled.scale) && Math.abs(scaled.scale[0] - 1) > 0.05, 'dragging a corner scales it', JSON.stringify(scaled.scale)); } console.log(failures ? `\n${failures} FAILED` : '\nPASS: a palette swapper can be grabbed, moved and scaled on the stage'); } finally { if (ws) ws.close(); chrome.kill(); }