main
mjs 200 lines 6.94 KB
Raw
1 #!/usr/bin/env node
2 /**
3 * verify-visual.mjs
4 * Playwright-based visual verification for SquadScope design changes.
5 *
6 * Usage:
7 * node scripts/design/verify-visual.mjs
8 * node scripts/design/verify-visual.mjs --base http://localhost:1313/SquadScope/
9 * node scripts/design/verify-visual.mjs --out screenshots/my-branch/
10 *
11 * Prerequisites:
12 * npx playwright install chromium
13 *
14 * Run:
15 * npx -y playwright install chromium --with-deps 2>/dev/null; node scripts/design/verify-visual.mjs
16 */
17
18 import { chromium } from 'playwright';
19 import { mkdir, writeFile } from 'fs/promises';
20 import { existsSync } from 'fs';
21 import { join } from 'path';
22
23 // ---------------------------------------------------------------------------
24 // Config
25 // ---------------------------------------------------------------------------
26
27 const args = process.argv.slice(2);
28
29 function getArg(name, fallback) {
30 const idx = args.indexOf(name);
31 return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
32 }
33
34 const BASE_URL = getArg('--base', 'http://localhost:1313/SquadScope/').replace(/\/$/, '');
35 const DATE_SLUG = getArg('--date', '2026-05-25');
36 const OUT_DIR = getArg('--out', join('screenshots', 'design-verification', DATE_SLUG));
37
38 const VIEWPORTS = [
39 { name: 'mobile-320', width: 320, height: 568 },
40 { name: 'mobile-360', width: 360, height: 640 },
41 { name: 'mobile', width: 375, height: 667 },
42 { name: 'mobile-390', width: 390, height: 844 },
43 { name: 'mobile-414', width: 414, height: 896 },
44 { name: 'tablet', width: 768, height: 1024 },
45 { name: 'desktop', width: 1280, height: 800 },
46 { name: 'wide', width: 1920, height: 1080 },
47 ];
48
49 const THEMES = ['light', 'dark'];
50
51 const PAGES = [
52 { key: 'home', path: '/' },
53 { key: 'about', path: '/about/' },
54 { key: 'weekly-w22', path: '/weekly/2026/w22/' },
55 { key: 'monthly-may', path: '/monthly/2026/05/' },
56 { key: 'yearly-2026', path: '/yearly/2026/' },
57 ];
58
59 /**
60 * CSS injected into every page before screenshotting.
61 * Hides dynamic content that changes every run and creates screenshot noise.
62 */
63 const NOISE_SUPPRESSION_CSS = `
64 /* Cost dashboard run date — changes every crawl */
65 .cost-dashboard__date,
66 [data-cost-date],
67 .run-date,
68 .last-updated { visibility: hidden !important; }
69
70 /* Live run counters */
71 .dynamic-counter,
72 .repo-count,
73 [data-live] { visibility: hidden !important; }
74
75 /* Disable CSS animations / transitions for stable screenshots */
76 *, *::before, *::after {
77 animation-duration: 0s !important;
78 transition-duration: 0s !important;
79 }
80 `;
81
82 // ---------------------------------------------------------------------------
83 // Helpers
84 // ---------------------------------------------------------------------------
85
86 function pad(n) { return String(n).padStart(3, ' '); }
87
88 async function ensureDir(dir) {
89 if (!existsSync(dir)) await mkdir(dir, { recursive: true });
90 }
91
92 async function captureScreenshot(page, url, viewport, theme, outPath) {
93 await page.setViewportSize({ width: viewport.width, height: viewport.height });
94 await page.emulateMedia({ colorScheme: theme });
95
96 try {
97 await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
98 } catch {
99 // Fallback for Hugo pages that may not fully reach networkidle
100 await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
101 await page.waitForTimeout(800);
102 }
103
104 await page.addStyleTag({ content: NOISE_SUPPRESSION_CSS });
105 // Extra settle time for web fonts
106 await page.waitForTimeout(300);
107
108 await page.screenshot({ path: outPath, fullPage: true });
109 return true;
110 }
111
112 // ---------------------------------------------------------------------------
113 // Main
114 // ---------------------------------------------------------------------------
115
116 async function main() {
117 await ensureDir(OUT_DIR);
118
119 const browser = await chromium.launch({ headless: true });
120
121 const results = [];
122 let passed = 0;
123 let failed = 0;
124
125 console.log(`\n🎨 SquadScope Visual Verification`);
126 console.log(` Base URL : ${BASE_URL}`);
127 console.log(` Output : ${OUT_DIR}`);
128 console.log(` Matrix : ${PAGES.length} pages × ${VIEWPORTS.length} viewports × ${THEMES.length} themes = ${PAGES.length * VIEWPORTS.length * THEMES.length} screenshots\n`);
129
130 for (const theme of THEMES) {
131 for (const viewport of VIEWPORTS) {
132 const context = await browser.newContext({
133 viewport: { width: viewport.width, height: viewport.height },
134 colorScheme: theme,
135 });
136 const page = await context.newPage();
137
138 for (const pg of PAGES) {
139 const filename = `${pg.key}-${viewport.name}-${theme}.png`;
140 const outPath = join(OUT_DIR, filename);
141 const url = `${BASE_URL}${pg.path}`;
142
143 let status = '';
144 let note = '';
145
146 try {
147 await captureScreenshot(page, url, viewport, theme, outPath);
148 passed++;
149 } catch (err) {
150 status = '';
151 note = err.message.split('\n')[0].slice(0, 60);
152 failed++;
153 }
154
155 results.push({ page: pg.key, viewport: viewport.name, theme, status, note, filename });
156 process.stdout.write(` ${status} ${filename.padEnd(45)} ${note}\n`);
157 }
158
159 await context.close();
160 }
161 }
162
163 await browser.close();
164
165 // Summary table
166 console.log('\n─────────────────────────────────────────────────────────────');
167 console.log(' Page Viewport Light Dark');
168 console.log('─────────────────────────────────────────────────────────────');
169
170 for (const pg of PAGES) {
171 for (const vp of VIEWPORTS) {
172 const light = results.find(r => r.page === pg.key && r.viewport === vp.name && r.theme === 'light');
173 const dark = results.find(r => r.page === pg.key && r.viewport === vp.name && r.theme === 'dark');
174 const col1 = `${pg.key}`.padEnd(20);
175 const col2 = vp.name.padEnd(10);
176 console.log(` ${col1}${col2} ${light?.status ?? '?'} ${dark?.status ?? '?'}`);
177 }
178 }
179
180 console.log('─────────────────────────────────────────────────────────────');
181 console.log(` Total: ${pad(passed + failed)} screenshots — ${pad(passed)} passed, ${pad(failed)} failed`);
182 console.log(` Output dir: ${OUT_DIR}\n`);
183
184 // Write a manifest
185 const manifest = {
186 generatedAt: DATE_SLUG,
187 baseUrl: BASE_URL,
188 outDir: OUT_DIR,
189 results,
190 };
191 await writeFile(join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
192 console.log(` Manifest written to ${join(OUT_DIR, 'manifest.json')}\n`);
193
194 if (failed > 0) process.exit(1);
195 }
196
197 main().catch(err => {
198 console.error('Fatal error:', err);
199 process.exit(1);
200 });