Skip to content

Commit 4a9d44f

Browse files
committed
feat(cli): loading workflow (TTFB, FCP, render-blocking, scripts, fonts)
Add --workflow loading for timing-sensitive loading metrics that require a real page load, complementing the deterministic audit workflow. - New cli/src/workflows/loading.js with 6 steps: TTFB, FCP, render-blocking, resource-hints, scripts, fonts - Two new decision-tree rules: TTFB > 600ms → TTFB-Sub-Parts, FCP > 1.8s → render-blocking (both run in the shared page session from #76) - Register loading workflow in bin.js and update help text - E2E tests in cli/tests/e2e/loading.test.js Closes #77
1 parent 41be120 commit 4a9d44f

4 files changed

Lines changed: 146 additions & 1 deletion

File tree

cli/src/bin.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { loadSnippet } from "./load-snippet.js";
44
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
55
import { cwvWorkflow } from "./workflows/cwv.js";
66
import { auditWorkflow } from "./workflows/audit.js";
7+
import { loadingWorkflow } from "./workflows/loading.js";
78
import { RULES } from "./decision-tree.js";
89
import { reportHuman } from "./reporters/human.js";
910
import { reportJson } from "./reporters/json.js";
1011

1112
const WORKFLOWS = {
1213
"core-web-vitals": cwvWorkflow,
1314
audit: auditWorkflow,
15+
loading: loadingWorkflow,
1416
};
1517

1618
const SNIPPET_ALIASES = {
@@ -53,7 +55,7 @@ Run curated WebPerf Snippets headlessly via Playwright.
5355
5456
Options:
5557
--workflow <name> Workflow to run (default: core-web-vitals)
56-
Workflows: core-web-vitals, audit
58+
Workflows: core-web-vitals, audit, loading
5759
--snippet <name> Run a single snippet by alias or Category/Name path
5860
Aliases: LCP, CLS, LCP-Subparts, fonts,
5961
render-blocking, resource-hints, preload-scripts,

cli/src/decision-tree.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ export const RULES = [
66
append: { id: "LCP-Subparts", path: "CoreWebVitals/LCP-Subparts" },
77
reason: "LCP > 2.5s — drilling into sub-parts",
88
},
9+
{
10+
when: (r) => r.id === "TTFB" && r.status === "ok" && r.value > 600,
11+
append: { id: "ttfb-subparts", path: "Loading/TTFB-Sub-Parts" },
12+
reason: "TTFB > 600ms — drilling into sub-parts",
13+
},
14+
{
15+
when: (r) => r.id === "FCP" && r.status === "ok" && r.value > 1800,
16+
append: { id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
17+
reason: "FCP > 1.8s — checking render-blocking resources",
18+
},
919
];
1020

1121
export function nextSteps(results) {

cli/src/workflows/loading.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Loading performance workflow — timing-sensitive measurements requiring a real page load.
2+
// Complements the audit workflow (deterministic structural checks) with live metrics.
3+
export const loadingWorkflow = {
4+
id: "loading",
5+
title: "Loading Performance",
6+
steps: [
7+
{ id: "TTFB", path: "Loading/TTFB" },
8+
{ id: "FCP", path: "Loading/FCP" },
9+
{ id: "render-blocking", path: "Loading/Find-render-blocking-resources" },
10+
{ id: "resource-hints", path: "Loading/Resource-Hints-Validation" },
11+
{ id: "scripts", path: "Loading/Script-Loading" },
12+
{ id: "fonts", path: "Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold" },
13+
],
14+
};

cli/tests/e2e/loading.test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 { runMeasurement, VIEWPORT_PRESETS } from "../../src/runner.js";
7+
import { loadingWorkflow } from "../../src/workflows/loading.js";
8+
import { RULES } from "../../src/decision-tree.js";
9+
10+
const HERE = dirname(fileURLToPath(import.meta.url));
11+
const FIXTURE_HTML = readFileSync(join(HERE, "../fixtures/index.html"), "utf8");
12+
13+
let server;
14+
let baseUrl;
15+
16+
beforeAll(
17+
() =>
18+
new Promise((resolve) => {
19+
server = createServer((_req, res) => {
20+
res.writeHead(200, { "Content-Type": "text/html" });
21+
res.end(FIXTURE_HTML);
22+
});
23+
server.listen(0, "127.0.0.1", () => {
24+
const { port } = server.address();
25+
baseUrl = `http://127.0.0.1:${port}`;
26+
resolve();
27+
});
28+
}),
29+
10000
30+
);
31+
32+
afterAll(
33+
() =>
34+
new Promise((resolve) => {
35+
server.close(resolve);
36+
})
37+
);
38+
39+
describe("loading workflow", () => {
40+
it(
41+
"runs all steps without errors",
42+
async () => {
43+
const { results } = await runMeasurement({
44+
url: baseUrl,
45+
workflow: loadingWorkflow,
46+
rules: RULES,
47+
waitMs: 500,
48+
viewport: VIEWPORT_PRESETS.mobile,
49+
});
50+
51+
const stepIds = loadingWorkflow.steps.map((s) => s.id);
52+
for (const id of stepIds) {
53+
const r = results.find((r) => r.id === id);
54+
expect(r, `step "${id}" missing from results`).toBeDefined();
55+
expect(r.status, `${id} threw: ${r?.error}`).not.toBe("error");
56+
}
57+
},
58+
30000
59+
);
60+
61+
it(
62+
"TTFB returns a numeric value with a rating",
63+
async () => {
64+
const { results } = await runMeasurement({
65+
url: baseUrl,
66+
workflow: loadingWorkflow,
67+
rules: RULES,
68+
waitMs: 500,
69+
viewport: VIEWPORT_PRESETS.mobile,
70+
});
71+
72+
const ttfb = results.find((r) => r.id === "TTFB");
73+
expect(ttfb.status).toBe("ok");
74+
expect(typeof ttfb.value).toBe("number");
75+
expect(ttfb.value).toBeGreaterThanOrEqual(0);
76+
expect(["good", "needs-improvement", "poor"]).toContain(ttfb.rating);
77+
expect(ttfb.unit).toBe("ms");
78+
},
79+
30000
80+
);
81+
82+
it(
83+
"FCP returns a numeric value with a rating",
84+
async () => {
85+
const { results } = await runMeasurement({
86+
url: baseUrl,
87+
workflow: loadingWorkflow,
88+
rules: RULES,
89+
waitMs: 500,
90+
viewport: VIEWPORT_PRESETS.mobile,
91+
});
92+
93+
const fcp = results.find((r) => r.id === "FCP");
94+
expect(fcp.status).toBe("ok");
95+
expect(typeof fcp.value).toBe("number");
96+
expect(fcp.value).toBeGreaterThanOrEqual(0);
97+
expect(["good", "needs-improvement", "poor"]).toContain(fcp.rating);
98+
expect(fcp.unit).toBe("ms");
99+
},
100+
30000
101+
);
102+
103+
it(
104+
"returns a single navMs — only one navigation",
105+
async () => {
106+
const { navMs } = await runMeasurement({
107+
url: baseUrl,
108+
workflow: loadingWorkflow,
109+
rules: RULES,
110+
waitMs: 500,
111+
viewport: VIEWPORT_PRESETS.mobile,
112+
});
113+
114+
expect(typeof navMs).toBe("number");
115+
expect(navMs).toBeGreaterThan(0);
116+
},
117+
30000
118+
);
119+
});

0 commit comments

Comments
 (0)