File size: 19,125 Bytes
cc6b6d8 08a393b cc6b6d8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | // 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);
|