// Browser smoke test for pegs: a peg is made over a shape, nothing moves, and // the stage gives it handles that actually drag. // // WHAT A UNIT TEST CANNOT SEE. A peg is a `:group`, so it draws nothing, and // both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the // ops, and `handles` hangs its box, corners and knob off `pick/bounds-of`. So a // peg was selectable only from a timeline row and had nothing to grab — the // pivot that `gesture/refusal` tells you to make was unusable on the stage, and // every assertion about the maths still passed. Hence this: the handles are // asserted as DOM, with real pointer events through them. // // Uses an in-memory fixture and performs no server-side writes. 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-peg-')); const port = 9341; 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++; }; 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'); await send('Page.enable'); for (let i = 0; i < 160; i++) { if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break; await sleep(250); } // Local route, a turned+scaled shape in :main, selected. 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'))); const blank = arthur.domain.clip.blank(); let doc = arthur.domain.paint.new_shape(blank, k('main'), k('shape'), 0, v(100, 80, 140, 80, 140, 110, 100, 110), k('brow')); doc = c.update_in(doc, v(k('symbols'), k('main'), k('nodes'), k('shape'), k('channels')), ch => c.merge(ch, m(v(k('xform'), k('rot')), arthur.domain.channel.framed(0.4), v(k('xform'), k('scale')), arthur.domain.channel.framed(v(1.5, 0.75))))); const entry = m(k('clip'), doc, k('store'), m()); const cid = arthur.footage.store.install_BANG_(entry, 'peg-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)); re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'), v(k('node'), k('main'), k('shape'), v(k('shape'))))); return {cid: String(cid)}; `); console.log('setup:', JSON.stringify(setup)); await sleep(600); const before = await 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 res = arthur.domain.clip.resolver(c.get(e,k('clip')), k('main'), c.get(e,k('store')), arthur.domain.palette.index_of, null); const ops = res.call(null, 0); return Array.from(c.get(c.first(ops), k('pts'))); `); ok(Array.isArray(before) && before.length === 8, 'the shape draws before the peg', JSON.stringify(before)); // Add the peg, exactly as the menu item does. const made = await evalJS(` const c = cljs.core, k = c.keyword, v = c.vector; re_frame.core.dispatch_sync(v(k('arthur.events.ui/peg'), v(k('shape')))); const db = c.deref(re_frame.db.app_db); const e = arthur.footage.store.entry(c.get(db, k('clip/current'))); const doc = c.get(e, k('clip')); const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes'))); const shape = c.get(nodes, k('shape')); const pegId = c.get(shape, k('parent')); const peg = c.get(nodes, pegId); const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')), arthur.domain.palette.index_of, null); const sel = c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection')))); return { kind: String(c.get(peg, k('kind'))), pegPos: c.clj__GT_js(c.get_in(peg, v(k('channels'), v(k('xform'),k('pos')), k('value')))), hasPinv: !!c.get(shape, k('pinv')), pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))), selection: sel ? sel.map(String) : null, problems: c.clj__GT_js(arthur.domain.clip.problems(doc)), }; `); ok(made.kind === ':group', 'the peg is a group', made.kind); ok(made.hasPinv, 'the child got a :pinv'); ok((made.problems ?? []).length === 0, 'the document still validates', JSON.stringify(made.problems)); const moved = Math.max(...before.map((b, i) => Math.abs(b - made.pts[i]))); ok(moved < 1e-9, 'nothing moved when the peg appeared', `max drift ${moved}`); ok(String(made.selection?.[2]) !== ':shape', 'the peg is selected afterwards', JSON.stringify(made.selection)); console.log('peg pos:', JSON.stringify(made.pegPos)); await sleep(700); // THE BIT THAT UNIT TESTS CANNOT SEE: does the stage render grabbable handles? const dom = await evalJS(` const q = s => document.querySelector(s); const g = q('.paint-overlay .handles'); const grab = q('.paint-overlay .handles .peg-grab'); const r = grab && grab.getBoundingClientRect(); return { handles: !!g, pegGrab: !!grab, pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null, knobs: document.querySelectorAll('.paint-overlay .handles .knob').length, corners: document.querySelectorAll('.paint-overlay .handles .corner').length, boxes: document.querySelectorAll('.paint-overlay .handles .box').length, rect: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null, }; `); console.log('dom:', JSON.stringify(dom)); ok(dom.handles, 'the stage draws handles for the selected peg'); ok(dom.pegGrab, 'there is a .peg-grab to drag it by'); ok(dom.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents)); ok(dom.knobs === 1, 'a turn knob', String(dom.knobs)); ok(dom.corners === 4, 'four scale corners', String(dom.corners)); ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes)); // Drag it, through real pointer events on that element. if (dom.rect) { const { x, y } = dom.rect; for (const [type, dx] of [['mousePressed', 0], ['mouseMoved', 18], ['mouseMoved', 36], ['mouseReleased', 36]]) { await send('Input.dispatchMouseEvent', { type, x: x + dx, y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse', }); await sleep(80); } await sleep(400); const after = await 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 doc = c.get(e, k('clip')); const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes'))); const pegId = c.get(c.get(nodes, k('shape')), k('parent')); const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')), arthur.domain.palette.index_of, null); return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))), pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts')))}; `); const dx = after.pts[0] - made.pts[0]; const dy = after.pts[1] - made.pts[1]; console.log('after drag: peg pos', JSON.stringify(after.pos), 'child moved by', dx.toFixed(3), dy.toFixed(3)); ok(dx > 2, 'dragging the peg cross moved its child', `dx=${dx.toFixed(3)}`); ok(Math.abs(dy) < 1.5, 'and only horizontally, as dragged', `dy=${dy.toFixed(3)}`); } console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, selected, and dragged on the stage'); } finally { if (ws) ws.close(); chrome.kill(); try { rmSync(profile, { recursive: true, force: true }); } catch (error) { if (error.code !== 'ENOTEMPTY') throw error; } } if (failures) process.exitCode = 1;