Skip to content

Commit a828995

Browse files
committed
chore: compare unlighthouse reports preview vs production
1 parent 8b5fee6 commit a828995

3 files changed

Lines changed: 538 additions & 8 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
#!/usr/bin/env node
2+
/* eslint-disable no-console, no-restricted-syntax, no-plusplus, no-continue */
3+
4+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
5+
import { fileURLToPath } from "node:url";
6+
import { parseArgs } from "node:util";
7+
import { resolve } from "node:path";
8+
9+
interface CategoryScore {
10+
averageScore: number;
11+
}
12+
13+
interface MetricValue {
14+
averageNumericValue: number;
15+
}
16+
17+
interface CiResult {
18+
summary: {
19+
categories: Record<string, CategoryScore>;
20+
metrics: Record<string, MetricValue>;
21+
};
22+
}
23+
24+
interface DeviceResult {
25+
categories: Record<string, number>;
26+
metrics: Record<string, number>;
27+
}
28+
29+
interface DeviceComparison {
30+
categoryRows: string[];
31+
metricRows: string[];
32+
changed: boolean;
33+
}
34+
35+
function loadCiResult(filePath: string): CiResult {
36+
if (!existsSync(filePath)) {
37+
console.error(`Error: file not found: ${filePath}`);
38+
process.exit(1);
39+
}
40+
41+
return JSON.parse(readFileSync(filePath, "utf-8")) as CiResult;
42+
}
43+
44+
function extractDevice(ci: CiResult): DeviceResult {
45+
const categories: Record<string, number> = {};
46+
47+
for (const id of CATEGORY_ORDER) {
48+
const score = ci.summary.categories[id]?.averageScore;
49+
50+
if (score !== undefined) categories[id] = score;
51+
}
52+
53+
const metrics: Record<string, number> = {};
54+
55+
for (const id of METRIC_ORDER) {
56+
const val = ci.summary.metrics[id]?.averageNumericValue;
57+
58+
if (val !== undefined) metrics[id] = val;
59+
}
60+
61+
return { categories, metrics };
62+
}
63+
64+
function formatScore(score: number): string {
65+
return `${Math.round(score * 100)}%`;
66+
}
67+
68+
function formatDeltaScore(delta: number): string {
69+
const pp = Math.round(delta * 100);
70+
const sign = pp >= 0 ? "+" : "";
71+
const arrow = pp > 0 ? "↑" : "↓";
72+
73+
return `${arrow} ${sign}${pp}pp`;
74+
}
75+
76+
function formatTime(ms: number): string {
77+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
78+
79+
return `${Math.round(ms)}ms`;
80+
}
81+
82+
function formatDeltaTime(delta: number): string {
83+
// For time metrics, lower is better: negative delta = improvement (↑)
84+
const sign = delta >= 0 ? "+" : "";
85+
const arrow = delta < 0 ? "↑" : "↓";
86+
87+
return `${arrow} ${sign}${formatTime(delta)}`;
88+
}
89+
90+
function formatCls(value: number): string {
91+
return value.toFixed(3);
92+
}
93+
94+
function formatDeltaCls(delta: number): string {
95+
// For CLS, lower is better: negative delta = improvement (↑)
96+
const sign = delta >= 0 ? "+" : "";
97+
const arrow = delta < 0 ? "↑" : "↓";
98+
99+
return `${arrow} ${sign}${delta.toFixed(3)}`;
100+
}
101+
102+
const CATEGORY_ORDER = ["performance", "accessibility", "best-practices", "seo"];
103+
104+
const CATEGORY_LABELS: Record<string, string> = {
105+
performance: "Performance",
106+
accessibility: "Accessibility",
107+
"best-practices": "Best Practices",
108+
seo: "SEO",
109+
};
110+
111+
const METRIC_ORDER = [
112+
"largest-contentful-paint",
113+
"first-contentful-paint",
114+
"total-blocking-time",
115+
"cumulative-layout-shift",
116+
];
117+
118+
const METRIC_LABELS: Record<string, string> = {
119+
"largest-contentful-paint": "LCP",
120+
"first-contentful-paint": "FCP",
121+
"total-blocking-time": "TBT",
122+
"cumulative-layout-shift": "CLS",
123+
};
124+
125+
function compareDevice(
126+
production: DeviceResult,
127+
preview: DeviceResult,
128+
threshold: number,
129+
): DeviceComparison {
130+
let changed = false;
131+
const categoryRows: string[] = [];
132+
const metricRows: string[] = [];
133+
134+
for (const id of CATEGORY_ORDER) {
135+
const prod = production.categories[id];
136+
const prev = preview.categories[id];
137+
138+
if (prod === undefined || prev === undefined) continue;
139+
140+
const delta = prev - prod;
141+
142+
if (Math.abs(Math.round(delta * 100)) < threshold) continue;
143+
144+
changed = true;
145+
categoryRows.push(
146+
`| ${CATEGORY_LABELS[id] ?? id} | ${formatScore(prod)} | ${formatScore(prev)} | ${formatDeltaScore(delta)} |`,
147+
);
148+
}
149+
150+
for (const id of METRIC_ORDER) {
151+
const prod = production.metrics[id];
152+
const prev = preview.metrics[id];
153+
154+
if (prod === undefined || prev === undefined) continue;
155+
156+
const delta = prev - prod;
157+
158+
if (id === "cumulative-layout-shift") {
159+
if (Math.abs(delta) < 0.01) continue;
160+
changed = true;
161+
metricRows.push(
162+
`| ${METRIC_LABELS[id]} | ${formatCls(prod)} | ${formatCls(prev)} | ${formatDeltaCls(delta)} |`,
163+
);
164+
} else {
165+
if (Math.abs(delta) < threshold * 50) continue;
166+
changed = true;
167+
metricRows.push(
168+
`| ${METRIC_LABELS[id]} | ${formatTime(prod)} | ${formatTime(prev)} | ${formatDeltaTime(delta)} |`,
169+
);
170+
}
171+
}
172+
173+
return { categoryRows, metricRows, changed };
174+
}
175+
176+
function deviceSection(
177+
label: string,
178+
rows: string[],
179+
header: string,
180+
separator: string,
181+
): string[] {
182+
const lines = [`**${label}**`, ""];
183+
184+
if (rows.length > 0) {
185+
lines.push(header, separator, ...rows);
186+
} else {
187+
lines.push("_No changes detected._");
188+
}
189+
190+
lines.push("");
191+
192+
return lines;
193+
}
194+
195+
function compareResults(
196+
productionDesktop: CiResult,
197+
productionMobile: CiResult,
198+
previewDesktop: CiResult,
199+
previewMobile: CiResult,
200+
threshold: number,
201+
provider?: string,
202+
): { markdown: string; hasChanges: boolean } {
203+
const desktop = compareDevice(
204+
extractDevice(productionDesktop),
205+
extractDevice(previewDesktop),
206+
threshold,
207+
);
208+
const mobile = compareDevice(
209+
extractDevice(productionMobile),
210+
extractDevice(previewMobile),
211+
threshold,
212+
);
213+
214+
const hasChanges = desktop.changed || mobile.changed;
215+
const lines: string[] = [];
216+
217+
const providerLabel = provider
218+
? ` — ${provider.charAt(0).toUpperCase()}${provider.slice(1)}`
219+
: "";
220+
221+
lines.push(`## Unlighthouse Performance Comparison${providerLabel}`);
222+
lines.push(
223+
`Comparing PR preview against production. Changes ≥ ${threshold}pp shown.`,
224+
);
225+
lines.push("> ↑ = improved · ↓ = regressed");
226+
lines.push("");
227+
228+
lines.push("### Category Scores");
229+
lines.push("");
230+
lines.push(
231+
...deviceSection(
232+
"Desktop",
233+
desktop.categoryRows,
234+
"| Category | Production | PR Preview | Delta |",
235+
"|:---------|:-----------|:-----------|:------|",
236+
),
237+
);
238+
lines.push(
239+
...deviceSection(
240+
"Mobile",
241+
mobile.categoryRows,
242+
"| Category | Production | PR Preview | Delta |",
243+
"|:---------|:-----------|:-----------|:------|",
244+
),
245+
);
246+
247+
lines.push("### Core Web Vitals");
248+
lines.push("");
249+
lines.push(
250+
...deviceSection(
251+
"Desktop",
252+
desktop.metricRows,
253+
"| Metric | Production | PR Preview | Delta |",
254+
"|:-------|:-----------|:-----------|:------|",
255+
),
256+
);
257+
lines.push(
258+
...deviceSection(
259+
"Mobile",
260+
mobile.metricRows,
261+
"| Metric | Production | PR Preview | Delta |",
262+
"|:-------|:-----------|:-----------|:------|",
263+
),
264+
);
265+
266+
return { markdown: lines.join("\n"), hasChanges };
267+
}
268+
269+
export { extractDevice, compareDevice, compareResults };
270+
export type { CiResult, DeviceResult, DeviceComparison };
271+
272+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
273+
274+
if (isMain) {
275+
const { values } = parseArgs({
276+
options: {
277+
"preview-desktop": { type: "string" },
278+
"preview-mobile": { type: "string" },
279+
"production-desktop": { type: "string" },
280+
"production-mobile": { type: "string" },
281+
output: { type: "string" },
282+
"meta-output": { type: "string" },
283+
threshold: { type: "string" },
284+
provider: { type: "string" },
285+
},
286+
});
287+
288+
const previewDesktopPath = values["preview-desktop"] ?? "";
289+
const previewMobilePath = values["preview-mobile"] ?? "";
290+
const productionDesktopPath = values["production-desktop"] ?? "";
291+
const productionMobilePath = values["production-mobile"] ?? "";
292+
293+
if (
294+
!previewDesktopPath ||
295+
!previewMobilePath ||
296+
!productionDesktopPath ||
297+
!productionMobilePath
298+
) {
299+
console.error(
300+
"Usage: compare-unlighthouse.mts --preview-desktop <path> --preview-mobile <path> --production-desktop <path> --production-mobile <path> [--output <path>] [--meta-output <path>] [--threshold <n>] [--provider <name>]",
301+
);
302+
process.exit(1);
303+
}
304+
305+
const threshold = Number(values.threshold ?? "1");
306+
307+
const previewDesktop = loadCiResult(resolve(previewDesktopPath));
308+
const previewMobile = loadCiResult(resolve(previewMobilePath));
309+
const productionDesktop = loadCiResult(resolve(productionDesktopPath));
310+
const productionMobile = loadCiResult(resolve(productionMobilePath));
311+
312+
const { markdown, hasChanges } = compareResults(
313+
productionDesktop,
314+
productionMobile,
315+
previewDesktop,
316+
previewMobile,
317+
threshold,
318+
values.provider,
319+
);
320+
321+
const outputPath = values.output ? resolve(values.output) : null;
322+
const metaOutputPath = values["meta-output"] ? resolve(values["meta-output"]) : null;
323+
324+
if (outputPath) {
325+
writeFileSync(outputPath, markdown);
326+
console.error(`Unlighthouse comparison report written to ${outputPath}`);
327+
} else {
328+
process.stdout.write(markdown);
329+
}
330+
331+
if (metaOutputPath) {
332+
writeFileSync(metaOutputPath, `${JSON.stringify({ hasChanges }, null, 2)}\n`);
333+
console.error(`Meta output written to ${metaOutputPath}`);
334+
}
335+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const fs = require('fs');
2+
3+
module.exports = async ({ github, context, provider = 'unknown', reportPath = '/tmp/unlighthouse-report.md', metaPath = '/tmp/unlighthouse-meta.json' }) => {
4+
// Exit early if no changes
5+
const { hasChanges } = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
6+
7+
if (!hasChanges) return;
8+
9+
// Find PR from commit SHA (context.issue.number is 0 in deployment_status events;
10+
// deployment.ref is also the SHA in Vercel deployments, not the branch name)
11+
const sha = context.payload.deployment?.sha;
12+
13+
if (!sha) return;
14+
15+
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
16+
owner: context.repo.owner,
17+
repo: context.repo.repo,
18+
commit_sha: sha,
19+
});
20+
21+
const prNumber = prs[0]?.number;
22+
23+
if (!prNumber) return;
24+
25+
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
26+
const marker = `<!-- unlighthouse-${provider}-report -->`;
27+
const body = marker + '\n' + fs.readFileSync(reportPath, 'utf-8') + `[Full Unlighthouse report →](${runUrl})\n`;
28+
29+
const { data: comments } = await github.rest.issues.listComments({
30+
owner: context.repo.owner,
31+
repo: context.repo.repo,
32+
issue_number: prNumber,
33+
});
34+
35+
const existing = comments.find(c => c.body.includes(marker));
36+
37+
if (existing) {
38+
await github.rest.issues.updateComment({
39+
owner: context.repo.owner,
40+
repo: context.repo.repo,
41+
comment_id: existing.id,
42+
body,
43+
});
44+
} else {
45+
await github.rest.issues.createComment({
46+
owner: context.repo.owner,
47+
repo: context.repo.repo,
48+
issue_number: prNumber,
49+
body,
50+
});
51+
}
52+
};

0 commit comments

Comments
 (0)