lighthouse fixes
This commit is contained in:
260
site/scripts/run-lighthouse.ts
Normal file
260
site/scripts/run-lighthouse.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user