Skip to content

Commit b47c850

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

3 files changed

Lines changed: 455 additions & 8 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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 CiResult {
10+
summary: {
11+
score: number;
12+
categories: Record<string, { score: number }>;
13+
metrics: Record<string, { displayValue: string }>;
14+
};
15+
}
16+
17+
function loadCiResult(filePath: string): CiResult {
18+
if (!existsSync(filePath)) {
19+
console.error(`Error: file not found: ${filePath}`);
20+
process.exit(1);
21+
}
22+
23+
return JSON.parse(readFileSync(filePath, "utf-8")) as CiResult;
24+
}
25+
26+
function dec(value: number): string {
27+
return value.toFixed(2);
28+
}
29+
30+
function row(
31+
label: string,
32+
prodDesktop: string,
33+
prodMobile: string,
34+
prevDesktop: string,
35+
prevMobile: string,
36+
): string {
37+
return `| ${label} | ${prodDesktop} | ${prodMobile} | ${prevDesktop} | ${prevMobile} |`;
38+
}
39+
40+
const CATEGORY_ORDER = ["performance", "accessibility", "best-practices", "seo"];
41+
42+
const CATEGORY_LABELS: Record<string, string> = {
43+
performance: "Performance",
44+
accessibility: "Accessibility",
45+
"best-practices": "Best Practices",
46+
seo: "SEO",
47+
};
48+
49+
const METRIC_ORDER = [
50+
"largest-contentful-paint",
51+
"cumulative-layout-shift",
52+
"first-contentful-paint",
53+
"total-blocking-time",
54+
"max-potential-fid",
55+
"interactive",
56+
];
57+
58+
const METRIC_LABELS: Record<string, string> = {
59+
"largest-contentful-paint": "LCP",
60+
"cumulative-layout-shift": "CLS",
61+
"first-contentful-paint": "FCP",
62+
"total-blocking-time": "TBT",
63+
"max-potential-fid": "Max Potential FID",
64+
interactive: "Time to Interactive",
65+
};
66+
67+
const COL_HEADER =
68+
"| | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |";
69+
const COL_SEP =
70+
"|:-|:------------|:------------|:----------------|:---------------|";
71+
72+
function compareResults(
73+
productionDesktop: CiResult,
74+
productionMobile: CiResult,
75+
previewDesktop: CiResult,
76+
previewMobile: CiResult,
77+
threshold: number,
78+
provider?: string,
79+
): { markdown: string; hasChanges: boolean } {
80+
const thresholdDecimal = threshold / 100;
81+
82+
// hasChanges: any summary or category score pair differs by >= threshold
83+
let hasChanges =
84+
Math.abs(previewDesktop.summary.score - productionDesktop.summary.score) >=
85+
thresholdDecimal ||
86+
Math.abs(previewMobile.summary.score - productionMobile.summary.score) >=
87+
thresholdDecimal;
88+
89+
if (!hasChanges) {
90+
for (const id of CATEGORY_ORDER) {
91+
const deltaDesktop = Math.abs(
92+
(previewDesktop.summary.categories[id]?.score ?? 0) -
93+
(productionDesktop.summary.categories[id]?.score ?? 0),
94+
);
95+
const deltaMobile = Math.abs(
96+
(previewMobile.summary.categories[id]?.score ?? 0) -
97+
(productionMobile.summary.categories[id]?.score ?? 0),
98+
);
99+
100+
if (deltaDesktop >= thresholdDecimal || deltaMobile >= thresholdDecimal) {
101+
hasChanges = true;
102+
break;
103+
}
104+
}
105+
}
106+
107+
const lines: string[] = [];
108+
109+
const providerLabel = provider
110+
? ` — ${provider.charAt(0).toUpperCase()}${provider.slice(1)}`
111+
: "";
112+
113+
lines.push(`## Unlighthouse Performance Comparison${providerLabel}`);
114+
lines.push("Comparing PR preview against production.");
115+
lines.push("");
116+
117+
lines.push("### Summary Score");
118+
lines.push("");
119+
lines.push(COL_HEADER);
120+
lines.push(COL_SEP);
121+
lines.push(
122+
row(
123+
"Score",
124+
dec(productionDesktop.summary.score),
125+
dec(productionMobile.summary.score),
126+
dec(previewDesktop.summary.score),
127+
dec(previewMobile.summary.score),
128+
),
129+
);
130+
lines.push("");
131+
132+
lines.push("### Category Scores");
133+
lines.push("");
134+
lines.push(
135+
"| Category | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |",
136+
);
137+
lines.push(
138+
"|:---------|:------------|:------------|:----------------|:---------------|",
139+
);
140+
141+
for (const id of CATEGORY_ORDER) {
142+
lines.push(
143+
row(
144+
CATEGORY_LABELS[id] ?? id,
145+
dec(productionDesktop.summary.categories[id]?.score ?? 0),
146+
dec(productionMobile.summary.categories[id]?.score ?? 0),
147+
dec(previewDesktop.summary.categories[id]?.score ?? 0),
148+
dec(previewMobile.summary.categories[id]?.score ?? 0),
149+
),
150+
);
151+
}
152+
153+
lines.push("");
154+
155+
lines.push("### Core Web Vitals");
156+
lines.push("");
157+
lines.push(
158+
"| Metric | Prod Desktop | Prod Mobile | Preview Desktop | Preview Mobile |",
159+
);
160+
lines.push(
161+
"|:-------|:------------|:------------|:----------------|:---------------|",
162+
);
163+
164+
for (const id of METRIC_ORDER) {
165+
lines.push(
166+
row(
167+
METRIC_LABELS[id] ?? id,
168+
productionDesktop.summary.metrics[id]?.displayValue ?? "—",
169+
productionMobile.summary.metrics[id]?.displayValue ?? "—",
170+
previewDesktop.summary.metrics[id]?.displayValue ?? "—",
171+
previewMobile.summary.metrics[id]?.displayValue ?? "—",
172+
),
173+
);
174+
}
175+
176+
lines.push("");
177+
178+
return { markdown: lines.join("\n"), hasChanges };
179+
}
180+
181+
export { compareResults };
182+
export type { CiResult };
183+
184+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
185+
186+
if (isMain) {
187+
const { values } = parseArgs({
188+
options: {
189+
"preview-desktop": { type: "string" },
190+
"preview-mobile": { type: "string" },
191+
"production-desktop": { type: "string" },
192+
"production-mobile": { type: "string" },
193+
output: { type: "string" },
194+
"meta-output": { type: "string" },
195+
threshold: { type: "string" },
196+
provider: { type: "string" },
197+
},
198+
});
199+
200+
const previewDesktopPath = values["preview-desktop"] ?? "";
201+
const previewMobilePath = values["preview-mobile"] ?? "";
202+
const productionDesktopPath = values["production-desktop"] ?? "";
203+
const productionMobilePath = values["production-mobile"] ?? "";
204+
205+
if (
206+
!previewDesktopPath ||
207+
!previewMobilePath ||
208+
!productionDesktopPath ||
209+
!productionMobilePath
210+
) {
211+
console.error(
212+
"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>]",
213+
);
214+
process.exit(1);
215+
}
216+
217+
const threshold = Number(values.threshold ?? "1");
218+
219+
const previewDesktop = loadCiResult(resolve(previewDesktopPath));
220+
const previewMobile = loadCiResult(resolve(previewMobilePath));
221+
const productionDesktop = loadCiResult(resolve(productionDesktopPath));
222+
const productionMobile = loadCiResult(resolve(productionMobilePath));
223+
224+
const { markdown, hasChanges } = compareResults(
225+
productionDesktop,
226+
productionMobile,
227+
previewDesktop,
228+
previewMobile,
229+
threshold,
230+
values.provider,
231+
);
232+
233+
const outputPath = values.output ? resolve(values.output) : null;
234+
const metaOutputPath = values["meta-output"]
235+
? resolve(values["meta-output"])
236+
: null;
237+
238+
if (outputPath) {
239+
writeFileSync(outputPath, markdown);
240+
console.error(`Unlighthouse comparison report written to ${outputPath}`);
241+
} else {
242+
process.stdout.write(markdown);
243+
}
244+
245+
if (metaOutputPath) {
246+
writeFileSync(
247+
metaOutputPath,
248+
`${JSON.stringify({ hasChanges }, null, 2)}\n`,
249+
);
250+
console.error(`Meta output written to ${metaOutputPath}`);
251+
}
252+
}
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)