Skip to content

Commit 407299e

Browse files
committed
test(cli): add Vitest suite with unit and E2E tests
Add Vitest as dev dependency with three test layers: - Unit tests for decision-tree, reporters (JSON and human), and viewport presets - E2E tests using Playwright against a local HTTP server to verify LCP and CLS snippets return valid results on a real browser session
1 parent 42bfb62 commit 407299e

7 files changed

Lines changed: 2629 additions & 898 deletions

File tree

cli/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
"snippets",
1212
"README.md"
1313
],
14+
"scripts": {
15+
"test": "vitest run",
16+
"test:watch": "vitest",
17+
"test:unit": "vitest run tests/unit",
18+
"test:e2e": "vitest run tests/e2e"
19+
},
20+
"devDependencies": {
21+
"vitest": "^2.0.0"
22+
},
1423
"engines": {
1524
"node": ">=20.12.0"
1625
},

cli/tests/e2e/cwv.test.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { createServer } from "node:http";
3+
import { readFileSync } from "node:fs";
4+
import { fileURLToPath } from "node:url";
5+
import { dirname, join } from "node:path";
6+
import { runSnippets, VIEWPORT_PRESETS } from "../../src/runner.js";
7+
import { loadSnippet } from "../../src/load-snippet.js";
8+
9+
const HERE = dirname(fileURLToPath(import.meta.url));
10+
const FIXTURE_HTML = readFileSync(join(HERE, "../fixtures/index.html"), "utf8");
11+
12+
let server;
13+
let baseUrl;
14+
15+
beforeAll(
16+
() =>
17+
new Promise((resolve) => {
18+
server = createServer((_req, res) => {
19+
res.writeHead(200, { "Content-Type": "text/html" });
20+
res.end(FIXTURE_HTML);
21+
});
22+
server.listen(0, "127.0.0.1", () => {
23+
const { port } = server.address();
24+
baseUrl = `http://127.0.0.1:${port}`;
25+
resolve();
26+
});
27+
}),
28+
10000
29+
);
30+
31+
afterAll(
32+
() =>
33+
new Promise((resolve) => {
34+
server.close(resolve);
35+
})
36+
);
37+
38+
function makeItems(ids) {
39+
return ids.map((id) => ({
40+
id,
41+
path: `CoreWebVitals/${id}`,
42+
source: loadSnippet(`CoreWebVitals/${id}`),
43+
}));
44+
}
45+
46+
describe("Core Web Vitals snippets on a local page", () => {
47+
it(
48+
"LCP returns a numeric value with a rating",
49+
async () => {
50+
const { results } = await runSnippets({
51+
url: baseUrl,
52+
items: makeItems(["LCP"]),
53+
waitMs: 500,
54+
viewport: VIEWPORT_PRESETS.mobile,
55+
});
56+
57+
const lcp = results.find((r) => r.id === "LCP");
58+
expect(lcp.status).toBe("ok");
59+
expect(typeof lcp.value).toBe("number");
60+
expect(lcp.value).toBeGreaterThanOrEqual(0);
61+
expect(["good", "needs-improvement", "poor"]).toContain(lcp.rating);
62+
expect(lcp.unit).toBe("ms");
63+
},
64+
30000
65+
);
66+
67+
it(
68+
"CLS returns 0 on a stable page",
69+
async () => {
70+
const { results } = await runSnippets({
71+
url: baseUrl,
72+
items: makeItems(["CLS"]),
73+
waitMs: 500,
74+
viewport: VIEWPORT_PRESETS.mobile,
75+
});
76+
77+
const cls = results.find((r) => r.id === "CLS");
78+
expect(cls.status).toBe("ok");
79+
expect(cls.value).toBe(0);
80+
expect(cls.rating).toBe("good");
81+
expect(cls.unit).toBe("score");
82+
},
83+
30000
84+
);
85+
86+
it(
87+
"respects the viewport preset passed to runSnippets",
88+
async () => {
89+
const { results: mobileResults } = await runSnippets({
90+
url: baseUrl,
91+
items: makeItems(["LCP"]),
92+
waitMs: 500,
93+
viewport: VIEWPORT_PRESETS.mobile,
94+
});
95+
const { results: desktopResults } = await runSnippets({
96+
url: baseUrl,
97+
items: makeItems(["LCP"]),
98+
waitMs: 500,
99+
viewport: VIEWPORT_PRESETS.desktop,
100+
});
101+
102+
expect(mobileResults[0].status).toBe("ok");
103+
expect(desktopResults[0].status).toBe("ok");
104+
},
105+
60000
106+
);
107+
});

cli/tests/fixtures/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>WebPerf Test Fixture</title>
7+
<style>
8+
body { margin: 0; font-family: sans-serif; padding: 20px; }
9+
</style>
10+
</head>
11+
<body>
12+
<h1 id="main-heading">WebPerf Snippets Test Page</h1>
13+
<p>A minimal page for testing performance snippets.</p>
14+
</body>
15+
</html>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, it, expect } from "vitest";
2+
import { nextSteps } from "../../src/decision-tree.js";
3+
4+
describe("nextSteps", () => {
5+
it("returns empty array when no rules match", () => {
6+
const results = [{ id: "LCP", status: "ok", value: 1200 }];
7+
expect(nextSteps(results)).toEqual([]);
8+
});
9+
10+
it("appends LCP-Sub-Parts when LCP > 2500ms", () => {
11+
const results = [{ id: "LCP", status: "ok", value: 3000 }];
12+
const steps = nextSteps(results);
13+
expect(steps).toHaveLength(1);
14+
expect(steps[0].id).toBe("LCP-Sub-Parts");
15+
expect(steps[0].path).toBe("CoreWebVitals/LCP-Sub-Parts");
16+
expect(steps[0].reason).toMatch(/LCP > 2\.5s/);
17+
});
18+
19+
it("does not append LCP-Sub-Parts when LCP === 2500ms (boundary)", () => {
20+
const results = [{ id: "LCP", status: "ok", value: 2500 }];
21+
expect(nextSteps(results)).toEqual([]);
22+
});
23+
24+
it("does not append LCP-Sub-Parts when LCP status is error", () => {
25+
const results = [{ id: "LCP", status: "error", value: 3000 }];
26+
expect(nextSteps(results)).toEqual([]);
27+
});
28+
29+
it("handles empty results", () => {
30+
expect(nextSteps([])).toEqual([]);
31+
});
32+
});

cli/tests/unit/reporters.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from "vitest";
2+
import { reportJson } from "../../src/reporters/json.js";
3+
import { reportHuman } from "../../src/reporters/human.js";
4+
5+
const samplePayload = {
6+
url: "https://example.com",
7+
navMs: 850,
8+
results: [
9+
{ id: "LCP", status: "ok", metric: "LCP", value: 1200, unit: "ms", rating: "good" },
10+
{ id: "CLS", status: "ok", metric: "CLS", value: 0, unit: "score", rating: "good" },
11+
],
12+
pageErrors: [],
13+
};
14+
15+
describe("reportJson", () => {
16+
it("returns valid JSON", () => {
17+
const output = reportJson(samplePayload);
18+
expect(() => JSON.parse(output)).not.toThrow();
19+
});
20+
21+
it("includes url, navMs, and results", () => {
22+
const parsed = JSON.parse(reportJson(samplePayload));
23+
expect(parsed.url).toBe("https://example.com");
24+
expect(parsed.navMs).toBe(850);
25+
expect(parsed.results).toHaveLength(2);
26+
});
27+
28+
it("preserves result fields", () => {
29+
const parsed = JSON.parse(reportJson(samplePayload));
30+
expect(parsed.results[0]).toMatchObject({
31+
id: "LCP",
32+
status: "ok",
33+
metric: "LCP",
34+
value: 1200,
35+
unit: "ms",
36+
rating: "good",
37+
});
38+
});
39+
});
40+
41+
describe("reportHuman", () => {
42+
it("includes the URL in the output", () => {
43+
const output = reportHuman(samplePayload);
44+
expect(output).toContain("https://example.com");
45+
});
46+
47+
it("includes metric IDs", () => {
48+
const output = reportHuman(samplePayload);
49+
expect(output).toContain("LCP");
50+
expect(output).toContain("CLS");
51+
});
52+
53+
it("reports error results", () => {
54+
const payload = {
55+
...samplePayload,
56+
results: [{ id: "LCP", status: "error", error: "No LCP entries buffered" }],
57+
};
58+
const output = reportHuman(payload);
59+
expect(output).toContain("No LCP entries buffered");
60+
});
61+
62+
it("reports page errors section when present", () => {
63+
const payload = { ...samplePayload, pageErrors: ["TypeError: x is not defined"] };
64+
const output = reportHuman(payload);
65+
expect(output).toContain("TypeError: x is not defined");
66+
});
67+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, it, expect } from "vitest";
2+
import { VIEWPORT_PRESETS } from "../../src/runner.js";
3+
4+
describe("VIEWPORT_PRESETS", () => {
5+
it("defines mobile, tablet, and desktop presets", () => {
6+
expect(VIEWPORT_PRESETS).toHaveProperty("mobile");
7+
expect(VIEWPORT_PRESETS).toHaveProperty("tablet");
8+
expect(VIEWPORT_PRESETS).toHaveProperty("desktop");
9+
});
10+
11+
it("mobile is the narrowest preset", () => {
12+
expect(VIEWPORT_PRESETS.mobile.width).toBeLessThan(VIEWPORT_PRESETS.tablet.width);
13+
expect(VIEWPORT_PRESETS.tablet.width).toBeLessThan(VIEWPORT_PRESETS.desktop.width);
14+
});
15+
16+
it("mobile width matches a phone form factor", () => {
17+
expect(VIEWPORT_PRESETS.mobile.width).toBe(375);
18+
});
19+
});

0 commit comments

Comments
 (0)