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);