fuzzyworld

nuts / files / scripts / screenshot.ts

screenshot.ts

6.3 KB · history · blame

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
17import { chromium } from 'playwright'
18import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
19import { join } from 'node:path'
20import { createServer } from 'vite'
21import { fileURLToPath } from 'node:url'
22import { makeComposite } from './composite.ts'
23import { compareWithGolden } from './diff.ts'
24
25const ROOT = fileURLToPath(new URL('..', import.meta.url))
26const OUT_DIR = join(ROOT, 'screenshots')
27const GOLDEN_DIR = join(ROOT, 'screenshots', 'golden')
28const UPDATE_GOLDEN = process.argv.includes('--update')
29
30mkdirSync(OUT_DIR, { recursive: true })
31mkdirSync(GOLDEN_DIR, { recursive: true })
32
33// ── --update: promote existing screenshots to golden without re-running ───
34if (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 ─────────────────────────────────────────────────
52console.log('Starting Vite dev server…')
53const server = await createServer({ root: ROOT, server: { port: 5174, strictPort: true } })
54await server.listen()
55// Detect https vs http (basicSsl plugin makes it https)
56const protocol = (server.config.server.https) ? 'https' : 'http'
57const BASE = `${protocol}://localhost:5174`
58console.log(` ${BASE}`)
59
60// Give the dev server a moment to fully warm up (transforms, etc.)
61await new Promise(r => setTimeout(r, 1500))
62
63// ── Launch Chromium ───────────────────────────────────────────────────────
64const browser = await chromium.launch()
65const context = await browser.newContext({ ignoreHTTPSErrors: true })
66
67async 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 ───────────────────────────────────────────────────────────────
79await 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) ─────────────────────────────────────────────────
91await 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 ────────────────────────────────────────────────────────
97await 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 ────────────────────────────────────────────────────────────
104console.log('\nBuilding composite…')
105await makeComposite(false)
106console.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 ───────────────────────────────────────────────────────────────
128await context.close()
129await browser.close()
130await server.close()
131
132console.log(`\nScreenshots saved to ${OUT_DIR}`)
133console.log('Run with --update to promote to golden.')