// Browser smoke test for onion controls and rendered ghost pixels. // 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-onion-')); const port = 9337; 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); assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾'); await evaluate(`document.querySelector('.onion-controls > button').click()`); await sleep(150); assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true'); await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').click()`); await sleep(100); assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), true); await evaluate(`document.querySelector('.menu-scrim').click()`); await sleep(100); assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), false, 'a click outside shuts the onion settings'); await evaluate(`(() => { const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs); const db = c.deref(re_frame.db.app_db); const entry = arthur.footage.store.entry(c.get(db, k('clip/current'))); const base = c.get(entry, k('clip')); const cel = (id, drawing, at) => m(k('id'), k(id), k('kind'), k('instance'), k('z'), id, k('source'), m(k('symbol'), k(drawing)), k('span'), v(0, 2), k('time'), m(k('at'), at, k('rate'), 1), k('playback'), m(k('in'), 0, k('speed'), 0, k('end'), k('stop'))); const drawing = (id, x) => m(k('id'), k(id), k('frames'), 1, k('nodes'), m(k('mark'), m(k('id'), k('mark'), k('kind'), k('rect'), k('z'), 'a', k('channels'), m(v(k('style'), k('color')), arthur.domain.channel.framed(1), v(k('geom'), k('size')), arthur.domain.channel.framed(6), v(k('xform'), k('pos')), arthur.domain.channel.framed(v(x, 10)))))); const symbols = m(k('main'), m(k('id'), k('main'), k('frames'), 6, k('display'), k('lane'), k('nodes'), m(k('a'), cel('a','ink-a',0), k('b'), cel('b','ink-b',2), k('c'), cel('c','ink-c',4))), k('ink-a'), drawing('ink-a',10), k('ink-b'), drawing('ink-b',20), k('ink-c'), drawing('ink-c',30)); const doc = c.assoc(base, k('symbols'), symbols); const id = arthur.footage.store.install_BANG_(c.assoc(entry,k('clip'),doc),'onion-test'); c.swap_BANG_(re_frame.db.app_db, db => { db = c.assoc(db,k('clip/current'),id); db = c.assoc_in(db,v(k('ui'),k('open')),k('main')); db = c.assoc_in(db,v(k('ui'),k('solo')),c.hash_map()); db = c.assoc_in(db,v(k('playback'),k('frame')),2); db = c.assoc_in(db,v(k('playback'),k('playing?')),false); return db; }); re_frame.core.dispatch_sync(v(k('arthur.ui.layout/onion'),k('on?'),true)); arthur.ui.player.refresh_subs_BANG_(); return true; })()`); await sleep(300); const pixels = await evaluate(`(() => { const ctx = document.querySelector('canvas.onion-skin').getContext('2d'); return [10,20,30].map(x => Array.from(ctx.getImageData(x,10,1,1).data)); })()`); assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'the previous frame is red'); assert(pixels[1][2] > pixels[1][0] && pixels[1][3] > 0, 'the next frame is blue'); assert.equal(pixels[2][3], 0, 'two frames ahead is not ghosted at after=1'); const playbackAlpha = await evaluate(`(() => { const c=cljs.core, k=c.keyword; c.swap_BANG_(re_frame.db.app_db, db => c.assoc_in(db,c.vector(k('playback'),k('playing?')),true)); arthur.ui.player.refresh_subs_BANG_(); arthur.ui.player.paint_BANG_(2); return document.querySelector('canvas.onion-skin').getContext('2d').getImageData(10,10,1,1).data[3]; })()`); assert.equal(playbackAlpha, 0, 'ghosts clear during playback'); const preferences = await evaluate(`(() => { const c=cljs.core,k=c.keyword, read=()=>c.get(c.deref(re_frame.core.subscribe(c.vector(k('arthur.ui.layout/onion')))),k('on?')); c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('ink-a'))); const other = read(); c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('main'))); return [other,read()]; })()`); assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol'); assert.equal(errors.length, 0, JSON.stringify(errors)); console.log('PASS: onion controls, ghost colors, frames either side, playback hiding, per-symbol preferences'); } 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; } }