261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
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<string, unknown> = {
|
|
target,
|
|
runCount,
|
|
generatedAt: new Date().toISOString(),
|
|
variants: {},
|
|
};
|
|
|
|
let gateFailed = false;
|
|
|
|
for (const variant of VARIANTS) {
|
|
const byCategory: Record<CategoryKey, number[]> = {
|
|
performance: [],
|
|
accessibility: [],
|
|
"best-practices": [],
|
|
seo: [],
|
|
};
|
|
|
|
for (let runIndex = 1; runIndex <= runCount; runIndex += 1) {
|
|
let chrome:
|
|
| Awaited<ReturnType<typeof launch>>
|
|
| 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<ReturnType<typeof lighthouse>>
|
|
| 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<CategoryKey, number>;
|
|
|
|
if (assert100) {
|
|
for (const category of CATEGORIES) {
|
|
if (medians[category] < 100) {
|
|
gateFailed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
summary.variants = {
|
|
...(summary.variants as Record<string, unknown>),
|
|
[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;
|
|
});
|