Skip to content

Commit 4b3b9c2

Browse files
committed
chore: audit lighthouse on commits to canary
1 parent a00b864 commit 4b3b9c2

7 files changed

Lines changed: 601 additions & 4 deletions
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import { buildReport } from '../audit-unlighthouse.mts';
5+
import type { CiResult } from '../audit-unlighthouse.mts';
6+
7+
// ---------------------------------------------------------------------------
8+
// Fixtures
9+
// ---------------------------------------------------------------------------
10+
11+
const DEFAULT_METRICS: CiResult['summary']['metrics'] = {
12+
'largest-contentful-paint': { displayValue: '2.5 s' },
13+
'cumulative-layout-shift': { displayValue: '0.01' },
14+
'first-contentful-paint': { displayValue: '1.2 s' },
15+
'total-blocking-time': { displayValue: '100 ms' },
16+
'max-potential-fid': { displayValue: '200 ms' },
17+
interactive: { displayValue: '3.5 s' },
18+
};
19+
20+
function makeCiResult(overrides: {
21+
score?: number;
22+
performance?: number;
23+
accessibility?: number;
24+
'best-practices'?: number;
25+
seo?: number;
26+
metrics?: CiResult['summary']['metrics'];
27+
} = {}): CiResult {
28+
return {
29+
summary: {
30+
score: overrides.score ?? 0.85,
31+
categories: {
32+
performance: { score: overrides.performance ?? 0.80 },
33+
accessibility: { score: overrides.accessibility ?? 0.92 },
34+
'best-practices': { score: overrides['best-practices'] ?? 1.0 },
35+
seo: { score: overrides.seo ?? 0.90 },
36+
},
37+
metrics: overrides.metrics ?? { ...DEFAULT_METRICS },
38+
},
39+
};
40+
}
41+
42+
const BASE = makeCiResult();
43+
44+
// ---------------------------------------------------------------------------
45+
// Report heading
46+
// ---------------------------------------------------------------------------
47+
48+
describe('report heading', () => {
49+
it('contains the audit heading', () => {
50+
const markdown = buildReport(BASE, BASE);
51+
52+
assert.ok(markdown.includes('## Unlighthouse Audit'), 'Missing main heading');
53+
});
54+
55+
it('appends branch label when branch is given', () => {
56+
const markdown = buildReport(BASE, BASE, 'canary');
57+
58+
assert.ok(
59+
markdown.includes('## Unlighthouse Audit — `canary`'),
60+
'Missing branch label in heading',
61+
);
62+
});
63+
64+
it('handles branch names with slashes', () => {
65+
const markdown = buildReport(BASE, BASE, 'integrations/makeswift');
66+
67+
assert.ok(markdown.includes('`integrations/makeswift`'));
68+
});
69+
70+
it('omits branch label when none provided', () => {
71+
const markdown = buildReport(BASE, BASE);
72+
73+
assert.ok(!markdown.includes(' — '), 'Should not contain a branch label separator');
74+
});
75+
76+
it('contains the description text', () => {
77+
const markdown = buildReport(BASE, BASE);
78+
79+
assert.ok(markdown.includes('Unlighthouse scores for the latest commit on this branch.'));
80+
});
81+
});
82+
83+
// ---------------------------------------------------------------------------
84+
// Summary Score section
85+
// ---------------------------------------------------------------------------
86+
87+
describe('Summary Score section', () => {
88+
it('contains the Summary Score heading', () => {
89+
const markdown = buildReport(BASE, BASE);
90+
91+
assert.ok(markdown.includes('### Summary Score'));
92+
});
93+
94+
it('contains the aggregate score note', () => {
95+
const markdown = buildReport(BASE, BASE);
96+
97+
assert.ok(markdown.includes('Aggregate score across all categories as reported by Unlighthouse.'));
98+
});
99+
100+
it('renders scores as integers on a 1-100 scale', () => {
101+
const desktop = makeCiResult({ score: 0.85 });
102+
const mobile = makeCiResult({ score: 0.72 });
103+
const markdown = buildReport(desktop, mobile);
104+
105+
assert.ok(markdown.includes('| Score | 85 | 72 |'));
106+
});
107+
108+
it('rounds fractional scores correctly', () => {
109+
const desktop = makeCiResult({ score: 0.856 }); // rounds to 86
110+
const markdown = buildReport(desktop, BASE);
111+
112+
assert.ok(markdown.includes('86'), 'Score 0.856 should round to 86');
113+
});
114+
115+
it('contains the two-column header', () => {
116+
const markdown = buildReport(BASE, BASE);
117+
118+
assert.ok(markdown.includes('| | Desktop | Mobile |'));
119+
});
120+
});
121+
122+
// ---------------------------------------------------------------------------
123+
// Category Scores section
124+
// ---------------------------------------------------------------------------
125+
126+
describe('Category Scores section', () => {
127+
it('contains the Category Scores heading', () => {
128+
const markdown = buildReport(BASE, BASE);
129+
130+
assert.ok(markdown.includes('### Category Scores'));
131+
});
132+
133+
it('renders all four categories', () => {
134+
const markdown = buildReport(BASE, BASE);
135+
136+
assert.ok(markdown.includes('Performance'));
137+
assert.ok(markdown.includes('Accessibility'));
138+
assert.ok(markdown.includes('Best Practices'));
139+
assert.ok(markdown.includes('SEO'));
140+
});
141+
142+
it('renders desktop and mobile scores independently', () => {
143+
const desktop = makeCiResult({ performance: 0.80 });
144+
const mobile = makeCiResult({ performance: 0.93 });
145+
const markdown = buildReport(desktop, mobile);
146+
147+
assert.ok(
148+
markdown.includes('| Performance | 80 | 93 |'),
149+
'Performance row should show desktop then mobile score',
150+
);
151+
});
152+
153+
it('shows all four categories independently', () => {
154+
const desktop = makeCiResult({ seo: 0.88, accessibility: 0.75 });
155+
const mobile = makeCiResult({ seo: 0.91, accessibility: 0.82 });
156+
const markdown = buildReport(desktop, mobile);
157+
158+
assert.ok(markdown.includes('| SEO | 88 | 91 |'));
159+
assert.ok(markdown.includes('| Accessibility | 75 | 82 |'));
160+
});
161+
});
162+
163+
// ---------------------------------------------------------------------------
164+
// Core Web Vitals section
165+
// ---------------------------------------------------------------------------
166+
167+
describe('Core Web Vitals section', () => {
168+
it('contains the Core Web Vitals heading', () => {
169+
const markdown = buildReport(BASE, BASE);
170+
171+
assert.ok(markdown.includes('### Core Web Vitals'));
172+
});
173+
174+
it('renders all six metrics', () => {
175+
const markdown = buildReport(BASE, BASE);
176+
177+
assert.ok(markdown.includes('LCP'));
178+
assert.ok(markdown.includes('CLS'));
179+
assert.ok(markdown.includes('FCP'));
180+
assert.ok(markdown.includes('TBT'));
181+
assert.ok(markdown.includes('Max Potential FID'));
182+
assert.ok(markdown.includes('Time to Interactive'));
183+
});
184+
185+
it('passes displayValue through unchanged', () => {
186+
const desktop = makeCiResult({
187+
metrics: { ...DEFAULT_METRICS, 'largest-contentful-paint': { displayValue: '4.8 s' } },
188+
});
189+
const markdown = buildReport(desktop, BASE);
190+
191+
assert.ok(markdown.includes('4.8 s'));
192+
});
193+
194+
it('shows — for a metric missing from a result', () => {
195+
const desktopMissingMetric = makeCiResult({ metrics: {} });
196+
const markdown = buildReport(desktopMissingMetric, BASE);
197+
198+
assert.ok(markdown.includes('—'), 'Missing metric should show —');
199+
});
200+
201+
it('shows desktop and mobile displayValues per metric row', () => {
202+
const desktop = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '80 ms' } } });
203+
const mobile = makeCiResult({ metrics: { ...DEFAULT_METRICS, 'total-blocking-time': { displayValue: '320 ms' } } });
204+
const markdown = buildReport(desktop, mobile);
205+
206+
assert.ok(markdown.includes('| TBT | 80 ms | 320 ms |'));
207+
});
208+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, beforeEach } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { writeFileSync, mkdirSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
import { tmpdir } from 'node:os';
6+
import { createRequire } from 'node:module';
7+
8+
const require = createRequire(import.meta.url);
9+
const postReport = require('../post-unlighthouse-commit-comment.js') as (args: {
10+
github: ReturnType<typeof makeGithub>['github'];
11+
context: ReturnType<typeof makeContext>;
12+
reportPath?: string;
13+
}) => Promise<void>;
14+
15+
// ---------------------------------------------------------------------------
16+
// Helpers
17+
// ---------------------------------------------------------------------------
18+
19+
interface Calls {
20+
createCommitComment: object[];
21+
}
22+
23+
function makeGithub() {
24+
const calls: Calls = { createCommitComment: [] };
25+
const github = {
26+
rest: {
27+
repos: {
28+
createCommitComment: async (args: object) => {
29+
calls.createCommitComment.push(args);
30+
},
31+
},
32+
},
33+
};
34+
return { github, calls };
35+
}
36+
37+
function makeContext({
38+
owner = 'test-owner',
39+
repo = 'test-repo',
40+
sha = 'abc123',
41+
runId = 99,
42+
}: {
43+
owner?: string;
44+
repo?: string;
45+
sha?: string;
46+
runId?: number;
47+
} = {}) {
48+
return {
49+
repo: { owner, repo },
50+
runId,
51+
payload: {
52+
deployment: { sha },
53+
},
54+
};
55+
}
56+
57+
let reportPath: string;
58+
59+
beforeEach(() => {
60+
const tmpDir = join(tmpdir(), `post-unlighthouse-report-test-${Date.now()}`);
61+
mkdirSync(tmpDir, { recursive: true });
62+
reportPath = join(tmpDir, 'report.md');
63+
writeFileSync(reportPath, '## Unlighthouse Audit — `canary`\n\nSome results.');
64+
});
65+
66+
// ---------------------------------------------------------------------------
67+
// Early exits
68+
// ---------------------------------------------------------------------------
69+
70+
describe('early exits', () => {
71+
it('does nothing when deployment sha is missing', async () => {
72+
const { github, calls } = makeGithub();
73+
const context = { ...makeContext(), payload: {} };
74+
await postReport({ github, context, reportPath });
75+
76+
assert.equal(calls.createCommitComment.length, 0);
77+
});
78+
});
79+
80+
// ---------------------------------------------------------------------------
81+
// Commit comment creation
82+
// ---------------------------------------------------------------------------
83+
84+
describe('commit comment creation', () => {
85+
it('creates a commit comment with the deployment sha', async () => {
86+
const { github, calls } = makeGithub();
87+
await postReport({ github, context: makeContext({ sha: 'deadbeef' }), reportPath });
88+
89+
assert.equal(calls.createCommitComment.length, 1);
90+
assert.equal(
91+
(calls.createCommitComment[0] as { commit_sha: string }).commit_sha,
92+
'deadbeef',
93+
);
94+
});
95+
96+
it('passes the correct owner and repo', async () => {
97+
const { github, calls } = makeGithub();
98+
await postReport({
99+
github,
100+
context: makeContext({ owner: 'my-org', repo: 'my-repo' }),
101+
reportPath,
102+
});
103+
104+
assert.equal((calls.createCommitComment[0] as { owner: string }).owner, 'my-org');
105+
assert.equal((calls.createCommitComment[0] as { repo: string }).repo, 'my-repo');
106+
});
107+
});
108+
109+
// ---------------------------------------------------------------------------
110+
// Comment body
111+
// ---------------------------------------------------------------------------
112+
113+
describe('comment body', () => {
114+
it('starts with the canary marker', async () => {
115+
const { github, calls } = makeGithub();
116+
await postReport({ github, context: makeContext(), reportPath });
117+
118+
const body = (calls.createCommitComment[0] as { body: string }).body;
119+
120+
assert.ok(
121+
body.startsWith('<!-- canary-lighthouse-report -->\n'),
122+
`Body should start with marker, got: ${body.slice(0, 60)}`,
123+
);
124+
});
125+
126+
it('includes the report file content', async () => {
127+
const { github, calls } = makeGithub();
128+
await postReport({ github, context: makeContext(), reportPath });
129+
130+
const body = (calls.createCommitComment[0] as { body: string }).body;
131+
132+
assert.ok(body.includes('## Unlighthouse Audit'));
133+
assert.ok(body.includes('Some results.'));
134+
});
135+
136+
it('includes the workflow run link', async () => {
137+
const { github, calls } = makeGithub();
138+
await postReport({
139+
github,
140+
context: makeContext({ owner: 'my-org', repo: 'my-repo', runId: 12345 }),
141+
reportPath,
142+
});
143+
144+
const body = (calls.createCommitComment[0] as { body: string }).body;
145+
146+
assert.ok(
147+
body.includes('https://github.com/my-org/my-repo/actions/runs/12345'),
148+
'Body should contain the workflow run URL',
149+
);
150+
});
151+
152+
it('run link is preceded by a newline', async () => {
153+
const { github, calls } = makeGithub();
154+
await postReport({ github, context: makeContext(), reportPath });
155+
156+
const body = (calls.createCommitComment[0] as { body: string }).body;
157+
const linkIndex = body.indexOf('[Full Unlighthouse report');
158+
159+
assert.ok(linkIndex > 0, 'Run link should be present');
160+
assert.equal(body[linkIndex - 1], '\n', 'Run link should be preceded by a newline');
161+
});
162+
});

.github/scripts/__tests__/post-unlighthouse-comment.test.mts renamed to .github/scripts/__tests__/post-unlighthouse-pr-comment.test.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
66
import { createRequire } from 'node:module';
77

88
const require = createRequire(import.meta.url);
9-
const postComment = require('../post-unlighthouse-comment.js') as (args: {
9+
const postComment = require('../post-unlighthouse-pr-comment.js') as (args: {
1010
github: ReturnType<typeof makeGithub>['github'];
1111
context: ReturnType<typeof makeContext>;
1212
provider?: string;

0 commit comments

Comments
 (0)