nuts / files / scripts / screenshot.ts
screenshot.ts
| 1 | /** |
| 2 | * scripts/screenshot.ts |
| 3 | * |
| 4 | * Takes screenshots of the running Vite dev server and saves them to |
| 5 | * screenshots/ (gitignored by default — golden files are checked in separately). |
| 6 | * |
| 7 | * Usage: |
| 8 | * pnpm screenshot # takes screenshots, composites, diffs against golden |
| 9 | * pnpm screenshot --update # promote existing screenshots/composite to golden (no re-run) |
| 10 | * |
| 11 | * Shots taken: |
| 12 | * gallery.png — full gallery canvas (?gallery mode) |
| 13 | * ui.png — default app UI, no items |
| 14 | * settings.png — settings panel open |
| 15 | */ |
| 16 | |
| 17 | import { chromium } from 'playwright' |
| 18 | import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs' |
| 19 | import { join } from 'node:path' |
| 20 | import { createServer } from 'vite' |
| 21 | import { fileURLToPath } from 'node:url' |
| 22 | import { makeComposite } from './composite.ts' |
| 23 | import { compareWithGolden } from './diff.ts' |
| 24 | |
| 25 | const ROOT = fileURLToPath(new URL('..', import.meta.url)) |
| 26 | const OUT_DIR = join(ROOT, 'screenshots') |
| 27 | const GOLDEN_DIR = join(ROOT, 'screenshots', 'golden') |
| 28 | const UPDATE_GOLDEN = process.argv.includes('--update') |
| 29 | |
| 30 | mkdirSync(OUT_DIR, { recursive: true }) |
| 31 | mkdirSync(GOLDEN_DIR, { recursive: true }) |
| 32 | |
| 33 | // ── --update: promote existing screenshots to golden without re-running ─── |
| 34 | if (UPDATE_GOLDEN) { |
| 35 | const names = ['gallery', 'ui', 'settings'] |
| 36 | for (const name of names) { |
| 37 | const src = join(OUT_DIR, `${name}.png`) |
| 38 | if (!existsSync(src)) { |
| 39 | console.error(`Missing ${name}.png — run \`pnpm screenshot\` first.`) |
| 40 | process.exit(1) |
| 41 | } |
| 42 | writeFileSync(join(GOLDEN_DIR, `${name}.png`), readFileSync(src)) |
| 43 | console.log(` ✓ ${name}.png → golden`) |
| 44 | } |
| 45 | console.log('\nBuilding composite…') |
| 46 | await makeComposite(true) |
| 47 | console.log(' ✓ composite.png → golden') |
| 48 | process.exit(0) |
| 49 | } |
| 50 | |
| 51 | // ── Start Vite in-process ───────────────────────────────────────────────── |
| 52 | console.log('Starting Vite dev server…') |
| 53 | const server = await createServer({ root: ROOT, server: { port: 5174, strictPort: true } }) |
| 54 | await server.listen() |
| 55 | // Detect https vs http (basicSsl plugin makes it https) |
| 56 | const protocol = (server.config.server.https) ? 'https' : 'http' |
| 57 | const BASE = `${protocol}://localhost:5174` |
| 58 | console.log(` ${BASE}`) |
| 59 | |
| 60 | // Give the dev server a moment to fully warm up (transforms, etc.) |
| 61 | await new Promise(r => setTimeout(r, 1500)) |
| 62 | |
| 63 | // ── Launch Chromium ─────────────────────────────────────────────────────── |
| 64 | const browser = await chromium.launch() |
| 65 | const context = await browser.newContext({ ignoreHTTPSErrors: true }) |
| 66 | |
| 67 | async function shot(name: string, url: string, setup?: (page: import('playwright').Page) => Promise<void>): Promise<void> { |
| 68 | const page = await context.newPage() |
| 69 | await page.setViewportSize({ width: 390, height: 844 }) // iPhone 14 size |
| 70 | await page.goto(url) |
| 71 | if (setup) await setup(page) |
| 72 | const dest = join(OUT_DIR, `${name}.png`) |
| 73 | await page.screenshot({ path: dest, fullPage: true }) |
| 74 | console.log(` ✓ ${name}.png`) |
| 75 | await page.close() |
| 76 | } |
| 77 | |
| 78 | // ── Gallery ─────────────────────────────────────────────────────────────── |
| 79 | await shot('gallery', `${BASE}/?gallery`, async (page) => { |
| 80 | // Wait for the gallery to finish rendering |
| 81 | await page.waitForSelector('[data-gallery-ready="true"]', { timeout: 10_000 }) |
| 82 | // Fit the viewport to the canvas size |
| 83 | const size = await page.evaluate(() => { |
| 84 | const c = document.getElementById('canvas') as HTMLCanvasElement |
| 85 | return { width: c.offsetWidth, height: c.offsetHeight } |
| 86 | }) |
| 87 | await page.setViewportSize({ width: size.width, height: size.height }) |
| 88 | }) |
| 89 | |
| 90 | // ── Default UI (no items) ───────────────────────────────────────────────── |
| 91 | await shot('ui', `${BASE}/?screenshot`, async (page) => { |
| 92 | // On desktop/headless, the app auto-starts after 400 ms (no touch) |
| 93 | await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 }) |
| 94 | }) |
| 95 | |
| 96 | // ── Settings panel ──────────────────────────────────────────────────────── |
| 97 | await shot('settings', `${BASE}/?screenshot`, async (page) => { |
| 98 | await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 }) |
| 99 | await page.locator('#settings-btn').click() |
| 100 | await page.waitForSelector('#settings-panel:not([hidden])', { timeout: 5_000 }) |
| 101 | }) |
| 102 | |
| 103 | // ── Composite ──────────────────────────────────────────────────────────── |
| 104 | console.log('\nBuilding composite…') |
| 105 | await makeComposite(false) |
| 106 | console.log(' ✓ composite.png') |
| 107 | |
| 108 | // ── Diff against golden ─────────────────────────────────────────────────── |
| 109 | { |
| 110 | const diff = await compareWithGolden() |
| 111 | if (diff === null) { |
| 112 | console.log('\n No golden yet — run with --update to create one.') |
| 113 | } else { |
| 114 | const pct = (diff.pct * 100).toFixed(2) |
| 115 | if (diff.pass) { |
| 116 | console.log(`\n ✓ Matches golden (${pct}% changed)`) |
| 117 | } else { |
| 118 | console.error(`\n ✗ Differs from golden: ${pct}% changed`) |
| 119 | if (diff.diffFile) console.error(` Diff image: ${diff.diffFile}`) |
| 120 | console.error(' Run with --update to accept as new golden.') |
| 121 | // Don't exit(1) here — screenshots are still saved. |
| 122 | // CI can run `pnpm diff-screenshots` separately for a hard fail. |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // ── Cleanup ─────────────────────────────────────────────────────────────── |
| 128 | await context.close() |
| 129 | await browser.close() |
| 130 | await server.close() |
| 131 | |
| 132 | console.log(`\nScreenshots saved to ${OUT_DIR}`) |
| 133 | console.log('Run with --update to promote to golden.') |