nuts / shampoo / files / scripts / screenshot.ts
Branch shampoo · default branch · all branches
screenshot.ts
88e406aa 2026-04-13 Pavel Safronov | 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.') |