// `shots capture` (web) against a local fixture web app: parallel contexts give the same files as serial, the // per-language session boots the app once per language, the input-hash cache skips unchanged frames and re-runs changed // ones, and every frame has a timing breakdown. Pure helpers are tested without a browser. Skips the browser part when // no Chromium can launch. The browser runs run in a `storebound` child process (its own Chromium), like a real run. import { afterAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { ShipConfig, ShotsCapture } from '../cli/lib/config'; import { type CaptureReport, defaultConcurrency, planWeb, pool, progressLine, selectJobs, timingSummary } from '../cli/shots/capture-web'; import { BASE } from './helpers/store-mock'; const CLI = join(import.meta.dir, '..', 'cli', 'index.ts'); describe('capture helpers', () => { test('default concurrency: render min(4, cores/2), capture (GPU-heavy) min(3, cores/4), at least 1', () => { expect(defaultConcurrency('render', 10)).toBe(4); expect(defaultConcurrency('capture', 10)).toBe(2); expect(defaultConcurrency('capture', 2)).toBe(1); expect(defaultConcurrency('render', 1)).toBe(1); }); test('pool keeps the input order and never runs more than n at once', async () => { let now = 0, peak = 0; const out = await pool([5, 1, 3, 2, 4], 2, async x => { now++; peak = Math.max(peak, now); await Bun.sleep(x * 3); now--; return x * 10; }); expect(out).toEqual([50, 10, 30, 20, 40]); expect(peak).toBe(2); }); test('progress line with an ETA', () => { expect(progressLine(5, 20, 0, 10_000)).toBe(' 5/20 ETA 30.0s'); expect(progressLine(20, 20, 0, 10_000)).toBe('20/20'); }); test('cache selection: changed inputs and missing files run; --only-missing; --force', () => { const cap: ShotsCapture = { source: 'web', baseUrl: 'http://x', shots: { a: { path: '/a' }, b: { path: '/b' } } }; const dims = { phone: { w: 10, h: 20, dpr: 1, bar: 0 }, tablet: { w: 10, h: 20, dpr: 1, bar: 0 } }; const jobs = planWeb(cap, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b1' }); expect(jobs.map(j => j.frame)).toEqual(['en/phone/a.png', 'en/phone/b.png', 'tr/phone/a.png', 'tr/phone/b.png']); const cache = Object.fromEntries(jobs.map(j => [j.frame, j.hash])); const all = () => true; expect(selectJobs(jobs, cache, 'changed', all).run).toEqual([]); const jobs2 = planWeb({ ...cap, shots: { ...cap.shots, b: { path: '/b', delayMs: 5 } } }, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b1' }); expect(selectJobs(jobs2, cache, 'changed', all).run.map(j => j.frame)).toEqual(['en/phone/b.png', 'tr/phone/b.png']); const jobs3 = planWeb(cap, { langs: ['en', 'tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b2' }); expect(selectJobs(jobs3, cache, 'changed', all).run.length).toBe(4); expect(selectJobs(jobs3, cache, 'only-missing', (f: string) => !f.endsWith('tr/phone/a.png')).run.map(j => j.frame)).toEqual(['tr/phone/a.png']); expect(selectJobs(jobs, cache, 'force', all).run.length).toBe(4); }); test('timing summary: slowest frames with their slowest step, time per step', () => { const t = (goto: number, waitFor: number) => ({ goto, waitFor, eval: 0, delay: 100, screenshot: 50, total: goto + waitFor + 150 }); const s = timingSummary([{ frame: 'en/phone/a.png', timing: t(1000, 200) }, { frame: 'en/phone/b.png', timing: t(100, 5000) }], 1); expect(s.slowest).toEqual([{ frame: 'en/phone/b.png', total: 5250, step: 'waitFor' }]); expect(s.steps).toEqual({ goto: 1100, waitFor: 5200, eval: 0, delay: 200, screenshot: 100 }); }); }); // ---- a local fixture web app: /scene/?lang=xx, a SPA (popstate) that counts its boots ---- let boots = 0; const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch(req) { const u = new URL(req.url); if (u.pathname === '/app.js') { boots++; return new Response(` const COLORS = { en: '#1e66f5', de: '#40a02b', tr: '#d20f39' }; function show() { const name = location.pathname.split('/').pop() || 'home', lang = new URLSearchParams(location.search).get('lang'); document.body.style.cssText = 'margin:0;background:' + (COLORS[lang] || '#000') + ';font:24px sans-serif;color:#fff'; document.body.innerHTML = '
' + lang + ' ' + name + '
'; window.scene = null; window.shows = (window.shows || 0) + 1; setTimeout(() => { window.scene = name; }, 30); } addEventListener('popstate', show); show();`, { headers: { 'content-type': 'text/javascript' } }); } return new Response('', { headers: { 'content-type': 'text/html' } }); }, }); afterAll(() => server.stop(true)); // launch only: exiting closes the browser's pipe and it quits on its own (a graceful close of Google Chrome, the // fallback without playwright's Chromium, can take 30 s) const canLaunch = spawnSync('bun', ['-e', `const { launch } = await import(${JSON.stringify(join(import.meta.dir, '..', 'cli', 'shots', 'browser.ts'))}); await launch(${JSON.stringify(join(import.meta.dir, '..'))}, { headless: true }); process.exit(0);`], { timeout: 60_000 }).status === 0; if (!canLaunch) console.warn('capture-web browser tests skipped: no Chromium could launch (bunx playwright install chromium)'); /** A temp project with this config; cli() runs `storebound shots capture ` in it. */ function project(cfg: ShipConfig, files: Record): { root: string; setConfig: (c: ShipConfig) => void; cli: (args: string[]) => Promise<{ out: string; err: string; code: number }> } { const root = mkdtempSync(join(tmpdir(), 'sb-cap-')); for (const [p, t] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), t); } const setConfig = (c: ShipConfig) => writeFileSync(join(root, 'storebound.config.json'), JSON.stringify(c)); setConfig(cfg); // async: the fixture server lives in this process and must keep answering while the child captures return { root, setConfig, cli: async args => { const r = Bun.spawn(['bun', CLI, 'shots', 'capture', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1' } }); const [out, err, code] = await Promise.all([new Response(r.stdout).text(), new Response(r.stderr).text(), r.exited]); return { out, err, code }; } }; } const shot = (name: string, extra: object = {}) => ({ path: `/scene/${name}`, waitFor: `window.scene === '${name}'`, delayMs: 20, ...extra }); function config(capture: Partial = {}): ShipConfig { return { ...BASE, shots: { copy: 'store/shots/copy.json', frames: 'store/shots/frames', out: 'store/renders', scenes: [{ id: 'hero', palette: 'fire', frames: [{ src: 'hero.png' }] }], phone: { w: 180, h: 240, dpr: 1 }, capture: { source: 'web', baseUrl: `http://127.0.0.1:${server.port}`, query: 'lang={lang}', build: 'src', shots: { hero: shot('hero'), battle: shot('battle'), king: shot('king') }, ...capture } } }; } const files = { 'store/shots/copy.json': '{}', 'src/main.js': 'v1' }; const frames = (root: string) => ['en', 'de', 'tr'].flatMap(l => ['hero', 'battle', 'king'].map(n => join(root, 'store/shots/frames', l, 'phone', `${n}.png`))); describe.skipIf(!canLaunch)('shots capture (web) with a fixture app', () => { test('concurrency 3 gives the same files as serial', async () => { const p = project(config(), files); try { await p.cli(['--device', 'phone', '--concurrency', '1', '--force']); const serial = frames(p.root).map(f => readFileSync(f)); const r = await p.cli(['--device', 'phone', '--concurrency', '3', '--force']); expect(r.out).toContain('capturing 9 frames, 3 at a time'); const parallel = frames(p.root).map(f => readFileSync(f)); expect(parallel.length).toBe(9); for (const [i, b] of parallel.entries()) expect(Buffer.compare(b, serial[i]!)).toBe(0); } finally { rmSync(p.root, { recursive: true, force: true }); } }, 120_000); test('per-language session boots the app once per language; per-shot once per frame', async () => { const p = project(config({ session: 'per-language' }), files); try { boots = 0; const r = await p.cli(['--device', 'phone', '--concurrency', '2', '--force']); expect(boots).toBe(3); expect(r.out).toContain('(reused page)'); const report = JSON.parse(readFileSync(join(p.root, '.storebound/capture-report.json'), 'utf8')) as CaptureReport; expect(report.frames.filter(f => f.reused).length).toBe(6); expect(frames(p.root).every(f => existsSync(f))).toBe(true); // a shot whose precondition fails in the reused page (it needs a freshly booted page) falls back to a fresh page const bad = project(config({ session: 'per-language', reuseTimeoutMs: 300, shots: { hero: shot('hero'), fresh: { path: '/scene/fresh', waitFor: "window.scene === 'fresh' && window.shows === 1", delayMs: 20 } } }), files); try { boots = 0; const r2 = await bad.cli(['--device', 'phone', '--lang', 'en', '--force']); expect(r2.err).toContain('en/phone/fresh: reused page failed'); expect(r2.out).toContain('capture done: 2 frames'); expect(boots).toBe(2); } finally { rmSync(bad.root, { recursive: true, force: true }); } boots = 0; const q = project(config(), files); await q.cli(['--device', 'phone', '--lang', 'en', '--force']); rmSync(q.root, { recursive: true, force: true }); expect(boots).toBe(3); } finally { rmSync(p.root, { recursive: true, force: true }); } }, 180_000); test('cache skips unchanged frames, re-runs changed ones; --only-missing; timing breakdown', async () => { const p = project(config(), files); try { let r = await p.cli(['--device', 'phone']); expect(r.out).toContain('capturing 9 frames'); expect(r.out).toMatch(/tr\/phone\/king\.png goto [\d.]+s wait [\d.]+s eval [\d.]+s delay [\d.]+s shot [\d.]+s = [\d.]+s/); expect(r.out).toContain('time per step: goto'); expect(r.out).toContain('slowest: '); const report = JSON.parse(readFileSync(join(p.root, '.storebound/capture-report.json'), 'utf8')) as CaptureReport; expect(Object.keys(report.frames[0]!.timing).sort()).toEqual(['delay', 'eval', 'goto', 'screenshot', 'total', 'waitFor']); expect(report.summary.slowest.length).toBe(5); r = await p.cli(['--device', 'phone']); expect(r.out).toContain('9 of 9 frames unchanged, skipped'); expect(r.out).toContain('capturing 0 frames'); // a changed shot entry re-runs only that shot (3 languages) p.setConfig(config({ shots: { hero: shot('hero'), battle: shot('battle', { delayMs: 40 }), king: shot('king') } })); r = await p.cli(['--device', 'phone']); expect(r.out).toContain('6 of 9 frames unchanged'); expect(r.out).toContain('capturing 3 frames'); // a build change re-runs everything; --only-missing only the missing file await Bun.write(join(p.root, 'src/main.js'), 'v2 changed'); rmSync(frames(p.root)[0]!); r = await p.cli(['--device', 'phone', '--only-missing']); expect(r.out).toContain('capturing 1 frames'); r = await p.cli(['--device', 'phone']); expect(r.out).toContain('capturing 8 frames'); // the one just captured already has the new inputs } finally { rmSync(p.root, { recursive: true, force: true }); } }, 180_000); });