// Steps through every slide in headless Chromium and saves a screenshot of each held frame. // Fails on console errors, failed or external requests, a step that doesn't hold on its last // frame, a step boundary that isn't still, a visible cut between steps, a URL hash that doesn't // match the step, a deep link that opens the wrong step, or next not interrupting a playing step. // Usage: node scripts/verify.mjs [url] [outDir] // Needs `playwright` resolvable from here; set CHROMIUM_PATH to pick a binary. import { mkdirSync, writeFileSync } from 'node:fs' import { chromium } from 'playwright' const url = process.argv[2] ?? 'http://localhost:4310/packages/invisible-software/demo' const outDir = process.argv[3] ?? 'verify-shots' mkdirSync(outDir, { recursive: true }) const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }) const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }) const origin = new URL(url).origin const problems = [] page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`) }) page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)) page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`)) page.on('request', (r) => { if (!r.url().startsWith(origin) && !r.url().startsWith('data:')) problems.push(`external request: ${r.url()}`) }) page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()}: ${r.url()}`) }) const ready = (p) => p.waitForFunction(() => window.__slideshow?.state.ready, null, { timeout: 60000 }) await page.goto(url) await ready(page) if (await page.locator('iframe').count()) problems.push('page uses iframes') const steps = await page.evaluate(() => window.__slideshow.STEPS) const labels = await page.evaluate(() => window.__slideshow.SLIDES.map((s) => s.label)) const pad = (n) => String(n).padStart(2, '0') const shots = [] await page.screenshot({ path: `${outDir}/00-start.png` }) shots.push({ file: '00-start.png', id: 'start' }) const inputs = ['ArrowRight', 'Space', 'PageDown', 'click'] const perSlide = {} for (let k = 0; k < steps.length; k++) { const input = inputs[k % inputs.length] if (input === 'click') await page.mouse.click(960, 540) else await page.keyboard.press(input) // One press must carry the whole step, including every automatic segment, with no more input. await page.waitForFunction((k) => { const s = window.__slideshow.state return s.step === k && !s.playing }, k, { timeout: 60000 }) const endSlide = await page.evaluate(() => window.__slideshow.state.slide) if (endSlide !== steps[k].endSlide) problems.push(`step ${steps[k].id} stopped on slide ${endSlide}, expected ${steps[k].endSlide}`) await page.waitForTimeout(450) const s = steps[k] const file = `${pad(k + 1)}-step-${s.id}.png` await page.screenshot({ path: `${outDir}/${file}` }) const t = await page.evaluate(() => window.__slideshow.state.t) if (Math.abs(t - s.to) > 1e-6) problems.push(`step ${s.id} held at ${t}, expected ${s.to}`) const hash = await page.evaluate(() => location.hash) if (hash !== '#' + s.id) problems.push(`step ${s.id} URL hash ${hash}, expected #${s.id}`) shots.push({ file, id: s.id, slide: labels[s.endSlide], from: s.from, to: s.to, input }) console.log(`step ${k + 1}/${steps.length} ${file} (${input})`) } // Left Arrow goes back one held step. await page.keyboard.press('ArrowLeft') const back = await page.evaluate(() => window.__slideshow.state) if (back.step !== steps.length - 2 || back.playing) problems.push(`ArrowLeft landed on step ${back.step}`) // Next during playback starts the following step right away instead of finishing the current one. const k0 = steps.findIndex((s) => s.id === '3.2') await page.evaluate((k) => window.__slideshow.hold(k), k0 - 1) await page.keyboard.press('ArrowRight') await page.waitForTimeout(250) await page.keyboard.press('ArrowRight') await page.waitForTimeout(50) const cut = await page.evaluate(() => window.__slideshow.state) if (cut.step !== k0 + 1 || !cut.playing || cut.t > steps[k0 + 1].from + 0.4) { problems.push(`interrupting 3.2 gave step ${cut.step} t=${cut.t} playing=${cut.playing}, expected 3.3 just started`) } // At the very end, next during playback finishes the last step. await page.evaluate((k) => window.__slideshow.hold(k), steps.length - 2) await page.keyboard.press('ArrowRight') await page.waitForTimeout(200) await page.keyboard.press('ArrowRight') const end = await page.evaluate(() => window.__slideshow.state) if (end.step !== steps.length - 1 || end.playing || end.t !== steps.at(-1).to) problems.push(`next at the end landed on step ${end.step} t=${end.t}`) // Every motion segment starts and ends at rest with no cut from the segment before it, and every // step except the openers begins with a human action: an on-screen tap within 1.5 s of its start. const still = await page.evaluate(() => { const { SLIDES, SEGS, STEPS, probe, hold, state } = window.__slideshow const out = [] const quiet = (si, t) => { const a = probe(si, t), b = probe(si, +(t + 0.05).toFixed(3)) return { moving: a.active.length > 0 || a.sig !== b.sig, sig: a.sig, active: a.active } } SEGS.forEach((s, k) => { const name = `segment ${SLIDES[s.slide].label}:${s.from}-${s.to}` const end = quiet(s.slide, s.to) if (end.moving) out.push(`${name} ends while moving (${end.active.join(', ') || 'DOM changing'})`) const prevSame = k > 0 && SEGS[k - 1].slide === s.slide const before = prevSame ? probe(s.slide, SEGS[k - 1].to).sig : probe(s.slide, 0).sig if (probe(s.slide, s.from).sig !== before) out.push(`${name} starts with a visible cut`) }) // Only the scene openers may start without a human: 2.1 (Grok Bot's first notification), // 6.1 (Victoria's notification after Max books) and 7.1 (arriving on Max's empty Muse chat). const OPENERS = ['2.1', '6.1', '7.1'] STEPS.forEach((st, k) => { if (OPENERS.includes(st.id)) { if (!st.opener || st.human) out.push(`step ${st.id} should be a no-human opener`); return } if (st.opener) { out.push(`step ${st.id} is an opener but is not one of ${OPENERS.join(', ')}`); return } if (!st.human) { out.push(`step ${st.id} does not start with a human segment`); return } const seg = st.segs[0] SLIDES[seg.slide].tl.seek(seg.to) const taps = SLIDES[seg.slide].root.querySelector('.phone').getAnimations({ subtree: true }) .filter((a) => a.animationName === 'tap') .map((a) => a.effect.getComputedTiming().delay / 1000) if (!taps.some((d) => d >= seg.from - 0.05 && d <= seg.from + 1.5)) out.push(`step ${st.id} has no tap near its start (${seg.from}s)`) }) hold(state.step) return out }) problems.push(...still) // Deep links: loading `#.` holds that step; editing the hash jumps there. const expectHeld = async (p, id, label) => { const want = steps.findIndex((s) => s.id === id) const got = await p.evaluate(() => ({ ...window.__slideshow.state, hash: location.hash })) const wantT = want < 0 ? 0 : steps[want].to const wantHash = '#' + (want < 0 ? 'start' : id) if (got.step !== want || got.playing || Math.abs(got.t - wantT) > 1e-6 || got.hash !== wantHash) { problems.push(`${label}: got step ${got.step} t=${got.t} ${got.hash}, expected ${want} t=${wantT} ${wantHash}`) } } const deep = await browser.newPage({ viewport: { width: 1920, height: 1080 } }) deep.on('pageerror', (e) => problems.push(`deep pageerror: ${e.message}`)) for (const [hash, id] of [['#5.2', '5.2'], ['#2', '2.1'], ['#3.3', '3.3'], ['#6', '6.1'], ['#2.4', '2.4'], ['#5.5', '5.5'], ['#nope', 'start'], ['', 'start']]) { await deep.goto('about:blank') await deep.goto(url + hash) await ready(deep) await expectHeld(deep, id, `deep link ${hash || '(none)'}`) } await deep.goto('about:blank') await deep.goto(url + '#3.2') await ready(deep) await deep.waitForTimeout(450) await deep.screenshot({ path: `${outDir}/deeplink-3.2.png` }) await expectHeld(deep, '3.2', 'deep link #3.2') await deep.keyboard.press('ArrowRight') await deep.waitForFunction(() => !window.__slideshow.state.playing, null, { timeout: 60000 }) await expectHeld(deep, '3.3', 'ArrowRight after #3.2') await deep.evaluate(() => { location.hash = '#2.1' }) await deep.waitForTimeout(300) await expectHeld(deep, '2.1', 'hashchange to #2.1') await browser.close() writeFileSync(`${outDir}/verify-report.json`, JSON.stringify({ url, steps: steps.length, shots, problems }, null, '\t')) if (problems.length) { console.error(problems.join('\n')) process.exit(1) } console.log(`OK: ${steps.length} steps, each (except the 2.1, 6.1 and 7.1 openers) starts with a human tap and plays its automatic follow-up without input, every segment starts and ends at rest with no cuts, next interrupts playback, URL hash tracks every step, deep links hold, no iframes, no console errors, no external requests.`)