Download tests/api.mjs from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 19.1 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tests/api.mjs
- Command line
-
hf download hf://spaces/SharpMeow/unroll/tests/api.mjs
-
curl -L -o api.mjs https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tests/api.mjs
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); | |