| 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 | }); |