// Functional tests for window.unroll, the URL parameters and keyboard operation. // Usage: node tests/api.mjs [baseURL] [corsZipURL] (needs playwright-core and a Chrome; CHROME=/path) import { chromium } from 'playwright-core'; const BASE = process.argv[2] || 'http://localhost:8080/'; const ZIP = process.argv[3] || ''; const browser = await chromium.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); page.setDefaultTimeout(180000); // shared CI boxes can be slow; real browsers are far faster const pageErrors = []; page.on('pageerror', (e) => pageErrors.push(String(e))); const results = []; let chain = Promise.resolve(); // checks run in order, each before the next page step, so a check sees the state it was written for const check = (name, condFn, info) => { const p = chain.then(async () => { let cond; inCheck = true; try { cond = await condFn(); } catch (e) { cond = false; info = { threw: String(e), info }; } inCheck = false; return { name, pass: !!cond, ...(cond ? {} : { info }) }; }); chain = p.catch(() => {}); results.push(p); }; let step = 0; let inCheck = false; const U = (fn, arg) => (inCheck ? U0(fn, arg) : chain.then(() => U0(fn, arg))); const U0 = (fn, arg) => { const n = ++step; if (process.env.VERBOSE) console.error(`step ${n}: ${String(fn).slice(0, 90)}`); return Promise.race([page.evaluate(fn, arg), new Promise((_, rej) => setTimeout(() => rej(new Error(`step ${n} hung for 150 s: ${String(fn).slice(0, 120)}`)), 150000))]); }; async function load(url) { await page.goto(url, { waitUntil: 'load' }); await page.waitForFunction(() => window.unroll && window.unroll.waitFor, null, { timeout: 120000 }); const r = await U(() => window.unroll.waitFor('ready', { timeout: 60000 })); if (!r.ok) throw new Error('page did not get ready: ' + r.error); } await chain; await load(BASE); let r; // meta r = await U(() => window.unroll.schema()); check('schema lists methods, events, url params, keys', async () => (r.ok && r.methods['destroy.tear'] && r.events.change && r.urlParams.t && r.keys.Space), r); r = await U(async () => { const j = await fetch('api.json', { cache: 'no-cache' }).then((x) => x.json()); const sch = await window.unroll.schema(); const live = Object.keys(sch.methods).filter((k) => !sch.methods[k].extension).sort(); return { same: JSON.stringify(Object.keys(j.methods).sort()) === JSON.stringify(live), app: j.app.version, v: (await window.unroll.version()).version }; }); check('api.json is up to date with the API and version.json', async () => (r.same && r.app === r.v), r); r = await U(() => window.unroll.getState()); check('getState is JSON with the main fields', async () => (r.ok && r.ready && typeof r.t === 'number' && r.scroll && r.camera && r.description), r); check('getState survives a JSON round trip', async () => (JSON.stringify(r).length > 100)); r = await U(() => window.unroll.describe()); check('describe returns text', async () => (r.ok && typeof r.text === 'string' && r.text.length > 20), r); // validation r = await U(() => window.unroll.setUnroll('x')); check('wrong type is rejected', async () => (!r.ok && r.code === 'invalid' && /number/.test(r.error)), r); r = await U(() => window.unroll.setUnroll(2)); check('out of range is rejected', async () => (!r.ok && r.code === 'invalid'), r); r = await U(() => window.unroll.setLayer('nope')); check('unknown layer is rejected with choices', async () => (!r.ok && /xray/.test(r.error)), r); r = await U(() => window.unroll.focus('w999')); check('unknown winding is rejected', async () => (!r.ok && r.code === 'not_found'), r); r = await U(() => window.unroll.camera.zoom(-1)); check('bad zoom is rejected', async () => (!r.ok), r); r = await U(() => window.unroll.panel('bogus')); check('unknown panel is rejected', async () => (!r.ok), r); r = await U(() => window.unroll.openScroll('nope')); check('unknown scroll is rejected', async () => (!r.ok && r.code === 'not_found'), r); r = await U(() => window.unroll.on('change')); check('on without callback is rejected', async () => (!r.ok), r); // unroll and layers r = await U(async () => { await window.unroll.setUnroll(0.3); return window.unroll.waitFor({ t: { gte: 0.29, lte: 0.31 } }, { timeout: 45000 }); }); check('setUnroll + waitFor', async () => (r.ok), r); r = await U(async () => { await window.unroll.setLayer('xray'); return window.unroll.getState(); }); check('setLayer xray', async () => (r.layer === 'xray'), r.layer); r = await U(async () => { await window.unroll.setLayer('overlay'); return window.unroll.waitFor('overlayLoaded', { timeout: 30000 }); }); check('setLayer overlay loads the overlay', async () => (r.ok), r); r = await U(async () => { const a = await window.unroll.play(true); const b = await window.unroll.getState(); await window.unroll.play(false); return { a, playing: b.playing }; }); check('play on and off', async () => (r.a.ok && r.playing), r); // close-up and compare r = await U(() => window.unroll.focus('w046', { view: 'overlay' })); check('focus with view', async () => (r.ok && r.focus.open && r.focus.winding === 'w046' && r.focus.view === 'overlay'), r); r = await U(() => window.unroll.compare(true, 0.25)); check('compare at a position', async () => (r.ok && r.focus.view === 'compare' && Math.abs(r.focus.compare - 0.25) < 0.02), r); r = await U(() => window.unroll.closeup({ zoom: 2 })); check('closeup rejects unknown options', async () => (!r.ok && /scale/.test(r.error)), r); r = await U(async () => { const a = await window.unroll.getState(); await window.unroll.closeup({ scale: 2 }); const b = await window.unroll.getState(); await window.unroll.closeup({ fit: true }); return { a: a.focus.zoom, b: b.focus.zoom }; }); check('closeup scale zooms', async () => (r.b > r.a * 1.5), r); r = await U(() => window.unroll.version()); check('version() matches version.json', async () => (r.ok && /^\d+\.\d+\.\d+$/.test(r.version) && r.sha && r.date), r); check('About footer shows the version', async () => (/^v\d+\.\d+\.\d+ ยท /.test(await U(() => document.getElementById('aVersion').textContent)))); r = await U(() => window.unroll.focus(null)); check('focus(null) closes', async () => (r.ok && !r.focus.open), r); // panels, sound, fullscreen for (const p of ['about', 'help', 'open', 'why']) { r = await U(async (p) => { await window.unroll.panel(p, true); const s = await window.unroll.getState(); await window.unroll.panel(p, false); const s2 = await window.unroll.getState(); return { on: s.panels[p], off: s2.panels[p] }; }, p); check(`panel ${p} opens and closes`, () => (r.on === true && r.off === false), r); } r = await U(() => window.unroll.sound(true)); check('sound on', async () => (r.ok), r); await U(() => window.unroll.sound(false)); r = await U(() => window.unroll.fullscreen(true)); check('fullscreen answers in JSON', async () => (typeof r.ok === 'boolean'), r); await U(() => window.unroll.fullscreen(false)); // camera r = await U(async () => { const a = await window.unroll.camera.get(); await window.unroll.camera.orbit(30, 10); const b = await window.unroll.camera.get(); return { a, b }; }); check('camera.orbit moves the camera', async () => (r.b.ok && JSON.stringify(r.a.position) !== JSON.stringify(r.b.position)), r); r = await U(async () => { const a = await window.unroll.camera.get(); await window.unroll.camera.zoom(1.5); const b = await window.unroll.camera.get(); return { a: a.distance, b: b.distance }; }); check('camera.zoom changes distance', async () => (r.b < r.a), r); r = await U(async () => { const a = await window.unroll.camera.get(); await window.unroll.camera.pan(10, 0); const b = await window.unroll.camera.get(); return { a: a.target, b: b.target }; }); check('camera.pan moves the target', async () => (JSON.stringify(r.a) !== JSON.stringify(r.b)), r); r = await U(() => window.unroll.camera.set([0, 0, 200], [0, 0, 0])); check('camera.set', async () => (r.ok), r); r = await U(() => window.unroll.camera.reset()); check('camera.reset', async () => (r.ok), r); // destroy r = await U(() => window.unroll.destroy.tools()); check('destroy.tools lists the four tools', async () => (r.ok && ['tear', 'crush', 'burn', 'water'].every((x) => r.tools.includes(x))), r); r = await U(() => window.unroll.destroy.tool('saw')); check('unknown tool is rejected', async () => (!r.ok && r.code === 'invalid'), r); const aim = await U(async () => { await window.unroll.camera.reset(); await window.unroll.waitFor('settled', { timeout: 20000 }); return window.unroll.destroy.aim(); }); check('destroy.aim finds papyrus on screen', async () => (aim.ok && aim.x > 0 && aim.winding), aim); r = await U(() => window.unroll.destroy.tear(1, 1)); check('a tool call off the papyrus fails clearly', async () => (!r.ok && r.code === 'not_found'), r); r = await U(async () => { const a = await window.unroll.destroy.aim(); return window.unroll.destroy.crush(a.x, a.y, { hold: 600 }); }); check('destroy.crush damages the scroll', async () => (r.ok && (r.after.masks > r.before.masks || r.after.particles > r.before.particles || r.after.shards > r.before.shards)), r); r = await U(async () => { const a = await window.unroll.destroy.aim(); return window.unroll.destroy.tear(a.x, a.y, { dx: 60, dy: 10, steps: 4 }); }); check('destroy.tear rips it', async () => (r.ok && r.after.masks >= 1), r); r = await U(async () => { const a = await window.unroll.destroy.aim(); return window.unroll.destroy.water(a.x, a.y, { hold: 500 }); }); check('destroy.water wets it', async () => (r.ok && r.tool === 'water'), r); r = await U(async () => { const a = await window.unroll.destroy.aim(); return window.unroll.destroy.burn(a.x, a.y); }); check('destroy.burn ignites it', async () => (r.ok && r.after.embers >= r.before.embers), r); r = await U(() => window.unroll.destroy.crush(10, 10, { hold: 99999 })); check('bad hold is rejected', async () => (!r.ok && /hold/.test(r.error)), r); r = await U(() => window.unroll.destroy.shake()); check('shake outside the Sandbox fails clearly', async () => (!r.ok && r.code === 'unavailable'), r); r = await U(() => window.unroll.destroy.sandbox(true)); check('destroy.sandbox on', async () => (r.ok && r.sandbox), r); r = await U(() => window.unroll.destroy.grab(640, 400, { dx: 320, dy: -60, steps: 16 })); check('destroy.grab breaks links in the Sandbox', async () => (r.ok && r.breaksAfter > r.breaksBefore), r); r = await U(() => window.unroll.destroy.shake()); check('destroy.shake in the Sandbox', async () => (r.ok), r); r = await U(async () => { await window.unroll.destroy.reset(); return window.unroll.destroy.stats(); }); check('destroy.reset clears damage and Sandbox breaks', async () => (r.ok && r.stats.damage.masks === 0 && r.stats.sandbox.breaksSinceReset === 0), r); r = await U(() => window.unroll.destroy.mode(false)); check('destroy.mode off', async () => (r.ok && r.on === false), r); r = await U(async () => { await window.unroll.setUnroll(0.5, { instant: true }); const a = await window.unroll.rollUp(true); await new Promise((res) => setTimeout(res, 600)); const b = await window.unroll.getState(); await window.unroll.rollUp(false); return { a, t: b.target }; }); check('rollUp rewinds', async () => (r.a.ok && r.a.rolling && r.t < 0.5), r); // screenshot, events, run r = await U(() => window.unroll.screenshot()); check('screenshot returns a PNG data URL', async () => (r.ok && r.dataURL.startsWith('data:image/png;base64,') && r.dataURL.length > 10000), r.error); r = await U(async () => { const got = []; const s = await window.unroll.on('layer', (p) => got.push(p.layer)); await window.unroll.setLayer('ink'); await new Promise((res) => setTimeout(res, 600)); const off = await window.unroll.off(s.id); return { got, off: off.ok }; }); check('on emits layer events, off unsubscribes', async () => (r.got.includes('ink') && r.off), r); r = await U(() => window.unroll.run([['setLayer', 'xray'], ['setUnroll', 0.5], ['getState']])); check('run executes a batch', async () => (r.ok && r.completed === 3), r); r = await U(() => window.unroll.run([['setLayer', 'bad'], ['setUnroll', 0.5]])); check('run stops on error', async () => (r.ok && r.completed === 0 && r.results.length === 1), r); r = await U(() => window.unroll.run([['setLayer', 'bad'], ['setUnroll', 0.5]], { continueOnError: true })); check('run continueOnError', async () => (r.completed === 1 && r.results.length === 2), r); r = await U(() => window.unroll.waitFor({ layer: 'nothing' }, { timeout: 300 })); check('waitFor times out cleanly', async () => (!r.ok && r.code === 'timeout'), r); r = await U(() => window.unroll.key('2')); check('key() runs a keyboard action', async () => (r.ok), r); r = await U(() => window.unroll.listScrolls()); check('listScrolls', async () => (r.ok && r.scrolls.length >= 1), r); r = await U(() => window.unroll.open.validate({ schema: 'unroll-scroll/1' })); check('open.validate reports problems', async () => (r.ok && r.valid === false && r.errors.length), r); r = await U(() => window.unroll.links()); check('links', async () => (r.ok && r.links.length), r); // extension hook r = await U(() => window.unroll.register('version', { x: () => 1 })); check('register refuses taken names', async () => (!r.ok), r); // deep link round trip r = await U(async () => { await window.unroll.setUnroll(0.42); await window.unroll.setLayer('xray'); await window.unroll.waitFor('settled', { timeout: 15000 }); return window.unroll.deepLink(true); }); check('deepLink', async () => (r.ok && /t=0\.42/.test(r.url)), r); await chain; await load(r.url); r = await U(() => window.unroll.getState()); check('deepLink restores t and layer', async () => (Math.abs(r.target - 0.42) < 0.005 && r.layer === 'xray'), { t: r.target, layer: r.layer }); // URL parameters await chain; await load(BASE + '?scroll=PHerc0139&t=0.6&layer=overlay&focus=w047&compare=0.3'); r = await U(() => window.unroll.waitFor({ 'focus.open': true, 'focus.view': 'compare' }, { timeout: 20000 })); check('URL restores scroll, t, layer, focus, compare', async () => (r.ok && r.state.scroll.id === 'PHerc0139' && r.state.layer === 'overlay' && r.state.focus.winding === 'w047' && Math.abs(r.state.focus.compare - 0.3) < 0.02 && Math.abs(r.state.target - 0.6) < 0.005), r.ok ? { scroll: r.state.scroll, layer: r.state.layer, target: r.state.target, t: r.state.t } : r); await chain; await load(BASE + '?panel=about'); r = await U(() => window.unroll.getState()); check('URL panel=about', async () => (r.panels.about), r.panels); await chain; await load(BASE + '?destroy=1&tool=water'); r = await U(() => window.unroll.getState()); check('URL destroy and tool', async () => (r.destroy.on && r.destroy.tool === 'water'), r.destroy); await chain; await load(BASE + '?crush=1&sandbox=1'); r = await U(() => window.unroll.getState()); check('URL crush and sandbox', async () => (r.destroy.on && r.destroy.tool === 'crush' && r.destroy.sandbox), r.destroy); // keyboard only await chain; await load(BASE); await chain; await page.focus('[data-testid="view-3d"]'); r = await U(() => window.unroll.camera.get()); await chain; await page.keyboard.press('ArrowLeft'); await page.waitForTimeout(400); const r2 = await U(() => window.unroll.camera.get()); check('canvas arrows orbit the camera', async () => (JSON.stringify(r.position) !== JSON.stringify(r2.position))); await chain; await page.keyboard.press('1'); await page.waitForTimeout(200); check('key 1 selects X-ray', async () => ((await U(() => window.unroll.getState())).layer === 'xray')); check('focus stays on the canvas after a shortcut', async () => (await U(() => document.activeElement.dataset.testid)) === 'view-3d', await U(() => document.activeElement.outerHTML.slice(0, 120))); await chain; await page.keyboard.press('Enter'); await page.waitForTimeout(800); check('Enter on the canvas opens the close-up', async () => ((await U(() => window.unroll.getState())).focus.open)); await chain; await page.keyboard.press('Escape'); await page.waitForTimeout(300); check('Escape closes it', async () => (!(await U(() => window.unroll.getState())).focus.open)); const tabbed = []; for (let i = 0; i < 30; i++) { await chain; await page.keyboard.press('Tab'); tabbed.push(await U(() => document.activeElement && document.activeElement.dataset.testid)); } check('Tab reaches the main controls', async () => (['layer-ink', 'play', 'unroll-slider', 'about', 'destroy-mode'].every((x) => tabbed.includes(x))), tabbed); const live = await U(() => document.getElementById('liveState').textContent); check('aria-live readout is filled', async () => (live.length > 10), live); const desc = await U(() => document.getElementById('viewDesc').textContent); check('text alternative describes the view', async () => (/winding/i.test(desc)), desc); r = await U(() => window.unroll.help()); check('help() gives a guide with groups', async () => (r.ok && r.groups && r.groups.destroy && r.start.length > 2), r); r = await U(() => window.unroll.help('setLayer')); check('help(method) describes one method', async () => (r.ok && r.method === 'setLayer' && r.call), r); r = await U(() => window.unroll.help('nope')); check('help(unknown) fails clearly', async () => (!r.ok && r.code === 'not_found'), r); // open your own from bytes and from a URL r = await U(async () => { const ex = await window.unroll.open.example(); const p = window.unroll.open.fromFiles([{ name: ex.name, data: ex.base64 }]); return { ex: ex.ok, size: ex.bytes }; }); check('open.example gives a zip', async () => (r.ex && r.size > 1000), r); await page.waitForURL(/s=local-/, { timeout: 60000 }); await page.waitForFunction(() => window.unroll && window.unroll.waitFor, null, { timeout: 120000 }); r = await U(() => window.unroll.waitFor('ready', { timeout: 60000 })); check('open.fromFiles (base64) opens the scroll', async () => (r.ok && r.state.scroll.local), r.ok ? r.state.scroll : r); r = await U(() => window.unroll.open.fromFiles([{ name: 'x.txt', data: 'aGVsbG8=' }])); check('open.fromFiles rejects a bad package clearly', async () => (!r.ok && r.error.length > 10), r); if (ZIP) { r = await U((z) => { window.unroll.open.fromURL(z); return true; }, ZIP); await page.waitForTimeout(500); await page.waitForFunction(() => window.unroll && window.unroll.waitFor, null, { timeout: 120000 }); r = await U(() => window.unroll.waitFor('ready', { timeout: 60000 })); check('open.fromURL opens a CORS zip', async () => (r.ok && r.state.scroll.local), r.ok ? r.state.scroll : r); } r = await U(async () => { const L = (await window.unroll.open.listLocal()).scrolls; for (const s of L) await window.unroll.open.removeLocal(s.key); return (await window.unroll.open.listLocal()).scrolls.length; }); check('open.removeLocal cleans up', async () => (r === 0), r); await browser.close(); const done = await Promise.all(results); const failed = done.filter((x) => !x.pass); console.log(JSON.stringify({ passed: done.length - failed.length, total: done.length, failed, pageErrors }, null, 1)); process.exit(failed.length || pageErrors.length ? 1 : 0);