import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawn } from "node:child_process"; import { setTimeout as sleep } from "node:timers/promises"; import lighthouse from "lighthouse"; import { launch } from "chrome-launcher"; type Theme = "light" | "dark" | "high-contrast"; type Device = "mobile" | "desktop"; type Variant = { id: `${Theme}-${Device}`; theme: Theme; device: Device; screenEmulation: { mobile: boolean; width: number; height: number; deviceScaleFactor: number; disabled: false; }; }; type CategoryKey = "performance" | "accessibility" | "best-practices" | "seo"; const VARIANTS: Variant[] = [ { id: "light-mobile", theme: "light", device: "mobile", screenEmulation: { mobile: true, width: 360, height: 800, deviceScaleFactor: 2, disabled: false }, }, { id: "dark-mobile", theme: "dark", device: "mobile", screenEmulation: { mobile: true, width: 360, height: 800, deviceScaleFactor: 2, disabled: false }, }, { id: "high-contrast-mobile", theme: "high-contrast", device: "mobile", screenEmulation: { mobile: true, width: 360, height: 800, deviceScaleFactor: 2, disabled: false }, }, { id: "light-desktop", theme: "light", device: "desktop", screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }, }, { id: "dark-desktop", theme: "dark", device: "desktop", screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }, }, { id: "high-contrast-desktop", theme: "high-contrast", device: "desktop", screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }, }, ]; const CATEGORIES: CategoryKey[] = ["performance", "accessibility", "best-practices", "seo"]; function hasFlag(flag: string): boolean { return process.argv.includes(flag); } function getArg(name: string): string | undefined { const prefix = `${name}=`; const value = process.argv.find((arg) => arg.startsWith(prefix)); return value ? value.slice(prefix.length) : undefined; } function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } async function startPreviewServerIfNeeded(targetUrl: URL): Promise<(() => void) | null> { const forceNoPreview = process.env.LIGHTHOUSE_SKIP_PREVIEW === "true"; if (forceNoPreview) return null; const isLocal = targetUrl.hostname === "127.0.0.1" || targetUrl.hostname === "localhost"; if (!isLocal) return null; const port = Number(targetUrl.port || "4321"); const astroCli = path.join(process.cwd(), "node_modules", "astro", "astro.js"); const child = spawn(process.execPath, [astroCli, "preview", "--host", "127.0.0.1", "--port", String(port)], { cwd: process.cwd(), stdio: "pipe", env: process.env, }); let ready = false; child.stdout.on("data", (buf) => { const line = String(buf); if (line.includes("Local") || line.includes("localhost") || line.includes("127.0.0.1")) { ready = true; } }); child.stderr.on("data", () => {}); for (let i = 0; i < 30; i += 1) { if (ready) break; await sleep(500); } await sleep(1500); return () => { child.kill("SIGTERM"); }; } async function run() { const outputDir = getArg("--out") || process.env.LIGHTHOUSE_OUTPUT_DIR || "lighthouse-reports"; const target = getArg("--url") || process.env.LIGHTHOUSE_TARGET_URL || "http://127.0.0.1:4321/"; const runCount = Number(getArg("--runs") || process.env.LIGHTHOUSE_RUNS || "3"); const assert100 = hasFlag("--assert-100"); if (!Number.isInteger(runCount) || runCount < 1) { throw new Error(`Invalid run count: ${runCount}`); } const targetUrl = new URL(target); await mkdir(outputDir, { recursive: true }); const stopPreview = await startPreviewServerIfNeeded(targetUrl); try { const summary: Record = { target, runCount, generatedAt: new Date().toISOString(), variants: {}, }; let gateFailed = false; for (const variant of VARIANTS) { const byCategory: Record = { performance: [], accessibility: [], "best-practices": [], seo: [], }; for (let runIndex = 1; runIndex <= runCount; runIndex += 1) { let chrome: | Awaited> | undefined; for (let attempt = 1; attempt <= 3; attempt += 1) { try { chrome = await launch({ chromeFlags: ["--headless=new", "--disable-gpu", "--no-sandbox"] }); break; } catch (error) { const message = String(error || ""); if (!message.includes("EPERM") || attempt === 3) { throw error; } await sleep(250 * attempt); } } if (!chrome) { throw new Error(`Failed to launch Chrome for ${variant.id} run ${runIndex}.`); } try { let result: | Awaited> | undefined; for (let attempt = 1; attempt <= 3; attempt += 1) { try { result = await lighthouse(target, { port: chrome.port, logLevel: "error", output: "json", onlyCategories: CATEGORIES, formFactor: variant.device, screenEmulation: variant.screenEmulation, throttlingMethod: "simulate", disableStorageReset: true, extraHeaders: { Cookie: `site_theme=${encodeURIComponent(variant.theme)}` }, }); break; } catch (error) { const message = String(error || ""); if (!message.includes("EPERM") || attempt === 3) { throw error; } await sleep(250 * attempt); } } if (!result?.lhr || typeof result.report !== "string") { throw new Error(`Lighthouse did not produce a report for ${variant.id} run ${runIndex}.`); } const lhr = result.lhr; const reportPath = path.join(outputDir, `${variant.id}.run-${runIndex}.json`); await writeFile(reportPath, result.report, "utf8"); for (const category of CATEGORIES) { const score = lhr.categories?.[category]?.score ?? 0; byCategory[category].push(Math.round(score * 100)); } } finally { chrome.kill(); } } const medians = Object.fromEntries( CATEGORIES.map((category) => [category, median(byCategory[category])]), ) as Record; if (assert100) { for (const category of CATEGORIES) { if (medians[category] < 100) { gateFailed = true; } } } summary.variants = { ...(summary.variants as Record), [variant.id]: { theme: variant.theme, device: variant.device, scoresByRun: byCategory, medianScores: medians, }, }; } await writeFile(path.join(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8"); if (assert100 && gateFailed) { throw new Error("Lighthouse quality gate failed: one or more variant category medians are below 100."); } // eslint-disable-next-line no-console console.log(`[lighthouse] wrote reports to ${outputDir}`); } finally { stopPreview?.(); } } run().catch((e) => { // eslint-disable-next-line no-console console.error(`[lighthouse] ${String(e)}`); process.exitCode = 1; });