| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { execSync } from 'node:child_process'; |
| 4 | import { mkdir, writeFile } from 'node:fs/promises'; |
| 5 | import { existsSync } from 'node:fs'; |
| 6 | import { join } from 'node:path'; |
| 7 | |
| 8 | const args = process.argv.slice(2); |
| 9 | |
| 10 | function getArg(name, fallback) { |
| 11 | const index = args.indexOf(name); |
| 12 | return index !== -1 && args[index + 1] ? args[index + 1] : fallback; |
| 13 | } |
| 14 | |
| 15 | const BASE_URL = getArg('--base', 'http://localhost:1313/SquadScope').replace(/\/$/, ''); |
| 16 | const OUTPUT_DIR = join('screenshots', 'lighthouse-results'); |
| 17 | const THRESHOLDS = { |
| 18 | accessibility: 0.95, |
| 19 | bestPractices: 0.95, |
| 20 | cls: 0.1, |
| 21 | }; |
| 22 | |
| 23 | const PAGES = [ |
| 24 | { key: 'home', path: '/' }, |
| 25 | { key: 'weekly', path: '/weekly/2026/w22/' }, |
| 26 | { key: 'monthly', path: '/monthly/2026/05/' }, |
| 27 | { key: 'yearly', path: '/yearly/2026/' }, |
| 28 | ]; |
| 29 | |
| 30 | function ensureDir(path) { |
| 31 | if (!existsSync(path)) { |
| 32 | return mkdir(path, { recursive: true }); |
| 33 | } |
| 34 | |
| 35 | return Promise.resolve(); |
| 36 | } |
| 37 | |
| 38 | function runLighthouse(url) { |
| 39 | const command = [ |
| 40 | 'npx -y lighthouse', |
| 41 | JSON.stringify(url), |
| 42 | '--quiet', |
| 43 | '--output=json', |
| 44 | '--output-path=stdout', |
| 45 | '--only-categories=accessibility,best-practices,performance', |
| 46 | '--chrome-flags="--headless --no-sandbox"', |
| 47 | '--form-factor=mobile', |
| 48 | ].join(' '); |
| 49 | |
| 50 | const output = execSync(command, { |
| 51 | cwd: process.cwd(), |
| 52 | encoding: 'utf8', |
| 53 | maxBuffer: 20 * 1024 * 1024, |
| 54 | stdio: ['ignore', 'pipe', 'pipe'], |
| 55 | }); |
| 56 | |
| 57 | return JSON.parse(output); |
| 58 | } |
| 59 | |
| 60 | function getScores(report) { |
| 61 | return { |
| 62 | accessibility: report.categories.accessibility?.score ?? 0, |
| 63 | bestPractices: report.categories['best-practices']?.score ?? 0, |
| 64 | cls: report.audits['cumulative-layout-shift']?.numericValue ?? Number.POSITIVE_INFINITY, |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | function getFailures(scores) { |
| 69 | const failures = []; |
| 70 | |
| 71 | if (scores.accessibility < THRESHOLDS.accessibility) { |
| 72 | failures.push(`a11y ${(scores.accessibility * 100).toFixed(0)} < 95`); |
| 73 | } |
| 74 | |
| 75 | if (scores.bestPractices < THRESHOLDS.bestPractices) { |
| 76 | failures.push(`best ${(scores.bestPractices * 100).toFixed(0)} < 95`); |
| 77 | } |
| 78 | |
| 79 | if (scores.cls > THRESHOLDS.cls) { |
| 80 | failures.push(`cls ${scores.cls.toFixed(3)} > 0.100`); |
| 81 | } |
| 82 | |
| 83 | return failures; |
| 84 | } |
| 85 | |
| 86 | function formatPercent(score) { |
| 87 | return `${(score * 100).toFixed(0)}%`; |
| 88 | } |
| 89 | |
| 90 | function formatCls(value) { |
| 91 | return Number.isFinite(value) ? value.toFixed(3) : 'n/a'; |
| 92 | } |
| 93 | |
| 94 | async function main() { |
| 95 | await ensureDir(OUTPUT_DIR); |
| 96 | |
| 97 | const results = []; |
| 98 | |
| 99 | for (const page of PAGES) { |
| 100 | const url = `${BASE_URL}${page.path}`; |
| 101 | const report = runLighthouse(url); |
| 102 | const scores = getScores(report); |
| 103 | const failures = getFailures(scores); |
| 104 | const result = { |
| 105 | page: page.key, |
| 106 | url, |
| 107 | accessibility: scores.accessibility, |
| 108 | bestPractices: scores.bestPractices, |
| 109 | cls: scores.cls, |
| 110 | ok: failures.length === 0, |
| 111 | failures, |
| 112 | }; |
| 113 | |
| 114 | results.push(result); |
| 115 | await writeFile(join(OUTPUT_DIR, `${page.key}.json`), JSON.stringify(report, null, 2)); |
| 116 | } |
| 117 | |
| 118 | await writeFile(join(OUTPUT_DIR, 'summary.json'), JSON.stringify({ baseUrl: BASE_URL, thresholds: THRESHOLDS, results }, null, 2)); |
| 119 | |
| 120 | console.log(`Lighthouse gates for ${BASE_URL}`); |
| 121 | console.table(results.map(result => ({ |
| 122 | page: result.page, |
| 123 | accessibility: formatPercent(result.accessibility), |
| 124 | bestPractices: formatPercent(result.bestPractices), |
| 125 | cls: formatCls(result.cls), |
| 126 | status: result.ok ? 'PASS' : `FAIL (${result.failures.join(', ')})`, |
| 127 | }))); |
| 128 | |
| 129 | if (results.some(result => !result.ok)) { |
| 130 | process.exit(1); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | main().catch(error => { |
| 135 | console.error(error instanceof Error ? error.message : error); |
| 136 | process.exit(1); |
| 137 | }); |