storebound/tests/browser/capture-web.browser.ts

185 lines
12 KiB
TypeScript

// `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: /<key>/scene/<name>?lang=xx, a SPA (popstate) that counts its boots per key: each
// browser test has its own key, so they run at the same time (test.concurrent) ----
const boots = new Map<string, number>();
const booted = (key: string) => boots.get(key) ?? 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') {
const key = u.searchParams.get('k') ?? '';
boots.set(key, booted(key) + 1);
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 = '<div style="padding:20px">' + lang + ' ' + name + '</div><div style="margin:20px;width:' + (40 * name.length) + 'px;height:60px;background:#fff"></div>';
window.scene = null;
window.shows = (window.shows || 0) + 1;
setTimeout(() => { window.scene = name; }, 30);
}
addEventListener('popstate', show);
show();`, { headers: { 'content-type': 'text/javascript' } });
}
const key = encodeURIComponent(u.pathname.split('/')[1] ?? '');
return new Response(`<!doctype html><html><head><meta charset="utf-8"><script src="/app.js?k=${key}" defer></script></head><body></body></html>`, { 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 <args>` in it. */
function project(cfg: ShipConfig, files: Record<string, string>): { 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]);
// failed frames are warnings on stderr (exit 1): show them, or a CI failure only says which file is missing
if (code !== 0) console.error(`storebound shots capture ${args.join(' ')} (exit ${code}):\n${err}`);
return { out, err, code };
} };
}
const shot = (name: string, extra: object = {}) => ({ path: `/scene/${name}`, waitFor: `window.scene === '${name}'`, delayMs: 20, ...extra });
/** `key`: the fixture app's boot counter this config's runs count on (one per browser test). */
function config(capture: Partial<ShotsCapture> = {}, key = 'app'): 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}/${key}`, 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.concurrent('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.concurrent('per-language session boots the app once per language; per-shot once per frame', async () => {
const p = project(config({ session: 'per-language' }, 'lang'), files);
try {
const r = await p.cli(['--device', 'phone', '--concurrency', '2', '--force']);
expect(booted('lang')).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 } } }, 'fresh'), files);
try {
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(booted('fresh')).toBe(2);
} finally { rmSync(bad.root, { recursive: true, force: true }); }
const q = project(config({}, 'shot'), files);
await q.cli(['--device', 'phone', '--lang', 'en', '--force']);
rmSync(q.root, { recursive: true, force: true });
expect(booted('shot')).toBe(3);
} finally { rmSync(p.root, { recursive: true, force: true }); }
}, 180_000);
test.concurrent('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);
});