unroll / tests /api.mjs
SharpMeow's picture
unroll.help(): quick start and methods by group, help(group) and help(method); version 0.9.5
08a393b
Raw History Blame Contribute Delete
19.1 kB
// 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);