Skip to content

Commit 3ab1435

Browse files
committed
feat: add webperf-snippets CLI (MVP)
Adds a headless CLI that runs curated WebPerf Snippets via Playwright, enabling Core Web Vitals diagnosis and CI budget gating without manual DevTools copy-paste. - npx webperf-snippets <url> runs LCP + CLS with human-readable output - --json, --snippet, --budget-lcp/cls, --headed, --wait flags - Decision tree: LCP > 2.5s auto-enqueues LCP-Sub-Parts - Runner shims performance.getEntriesByType for LCP/layout-shift entries (Chrome does not expose these via the API without a buffered observer) - Zero non-peer deps, requires Node >= 20.12, Playwright as peer dep - Root package renamed to webperf-snippets-docs; workspace added at cli/
1 parent af5e93b commit 3ab1435

12 files changed

Lines changed: 638 additions & 4 deletions

File tree

cli/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
*.log

cli/README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# webperf-snippets CLI
2+
3+
Run curated [WebPerf Snippets](https://webperf-snippets.nucliweb.net) headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes and gate CI on real performance budgets.
4+
5+
> **Status:** v0.1 (MVP). Core Web Vitals workflow only. See [Roadmap](#roadmap) for what's next.
6+
7+
## Why
8+
9+
Lighthouse gives you a score. The DevTools snippets give you the *diagnosis* — TTFB / Resource Load Delay / Element Render Delay sub-parts, LoAF script attribution, render-blocking resources, etc. This CLI runs the same curated snippets in a headless browser so you can:
10+
11+
- Diagnose LCP regressions in CI without copy-pasting into DevTools.
12+
- Gate pull requests on real performance budgets.
13+
- Automate the snippets you already run by hand.
14+
15+
## Install
16+
17+
Playwright is a peer dependency. Install both, plus the chromium browser:
18+
19+
```bash
20+
npm install --save-dev webperf-snippets playwright
21+
npx playwright install chromium
22+
```
23+
24+
## Usage
25+
26+
```bash
27+
npx webperf-snippets <url> [options]
28+
```
29+
30+
### Examples
31+
32+
Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Sub-Parts if LCP > 2.5s):
33+
34+
```bash
35+
npx webperf-snippets https://web.dev
36+
```
37+
38+
JSON output (for piping into `jq` or CI):
39+
40+
```bash
41+
npx webperf-snippets https://web.dev --json
42+
```
43+
44+
Single snippet:
45+
46+
```bash
47+
npx webperf-snippets https://example.com --snippet LCP-Sub-Parts
48+
```
49+
50+
CI gating:
51+
52+
```bash
53+
npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
54+
```
55+
56+
### Options
57+
58+
| Option | Description |
59+
| --------------------- | ---------------------------------------------------------- |
60+
| `--workflow <name>` | Workflow to run. Default: `core-web-vitals`. |
61+
| `--snippet <name>` | Run a single snippet (`LCP`, `CLS`, or `Category/Name`). |
62+
| `--json` | Output JSON instead of formatted text. |
63+
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`.|
64+
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
65+
| `--budget-cls <score>`| Exit `1` if CLS exceeds this value. |
66+
| `--headed` | Show the browser window (debug). |
67+
| `-h, --help` | Show help. |
68+
69+
### Exit codes
70+
71+
| Code | Meaning |
72+
| ---- | --------------------------------------------- |
73+
| `0` | All checks passed. |
74+
| `1` | Budget violation, or a snippet errored. |
75+
| `2` | Usage error (missing URL, unknown workflow). |
76+
77+
## CI example
78+
79+
GitHub Actions, fail the PR if LCP exceeds 2.5s:
80+
81+
```yaml
82+
- run: |
83+
npm install --no-save webperf-snippets playwright
84+
npx playwright install --with-deps chromium
85+
npx webperf-snippets https://staging.example.com --budget-lcp 2500 --budget-cls 0.1
86+
```
87+
88+
## Known limitations (v0.1)
89+
90+
- **No INP**: INP requires real user interactions. v0.2 will support synthetic interaction scripts.
91+
- **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
92+
- **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
93+
- **Decision-tree follow-ups re-navigate**: when a follow-up snippet fires (e.g. LCP-Sub-Parts), the page is loaded again. v0.2 will share a single page session.
94+
95+
## Roadmap
96+
97+
- v0.2: Loading workflow (TTFB, FCP, render-blocking, scripts, fonts), shared page session, synthetic interactions for INP.
98+
- v0.3: Markdown reporter for PR comments, GitHub Action wrapper.
99+
- v0.4: Auth flows (login + measure logged-in pages), CrUX field-data enrichment.
100+
101+
## How it works
102+
103+
1. Launches headless chromium via Playwright.
104+
2. Pre-registers `PerformanceObserver`s for LCP and layout-shift before navigation (Chrome doesn't expose these via `getEntriesByType` without a buffered observer, so the runner shims it).
105+
3. Navigates, waits for the page to settle.
106+
4. Evaluates each snippet's IIFE in the page context, capturing the returned object.
107+
5. Applies the workflow's decision tree to enqueue follow-up snippets.
108+
6. Renders results (human or JSON) and exits with an appropriate code.
109+
110+
## License
111+
112+
MIT — see [LICENSE](../LICENSE).

cli/package.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "webperf-snippets",
3+
"version": "0.1.0",
4+
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
5+
"type": "module",
6+
"bin": {
7+
"webperf-snippets": "./src/bin.js"
8+
},
9+
"files": [
10+
"src",
11+
"snippets",
12+
"README.md"
13+
],
14+
"engines": {
15+
"node": ">=20.12.0"
16+
},
17+
"keywords": [
18+
"webperf",
19+
"performance",
20+
"core-web-vitals",
21+
"lcp",
22+
"cls",
23+
"inp",
24+
"playwright",
25+
"cli",
26+
"ci"
27+
],
28+
"author": "Joan Leon | @nucliweb",
29+
"license": "MIT",
30+
"repository": {
31+
"type": "git",
32+
"url": "git+https://github.com/nucliweb/webperf-snippets.git",
33+
"directory": "cli"
34+
},
35+
"homepage": "https://webperf-snippets.nucliweb.net",
36+
"peerDependencies": {
37+
"playwright": ">=1.40.0"
38+
},
39+
"peerDependenciesMeta": {
40+
"playwright": {
41+
"optional": false
42+
}
43+
}
44+
}

cli/src/bin.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
import { parseArgs } from "node:util";
3+
import { loadSnippet } from "./load-snippet.js";
4+
import { runSnippets } from "./runner.js";
5+
import { cwvWorkflow } from "./workflows/cwv.js";
6+
import { nextSteps } from "./decision-tree.js";
7+
import { reportHuman } from "./reporters/human.js";
8+
import { reportJson } from "./reporters/json.js";
9+
10+
const WORKFLOWS = {
11+
"core-web-vitals": cwvWorkflow,
12+
};
13+
14+
const SNIPPET_ALIASES = {
15+
LCP: "CoreWebVitals/LCP",
16+
CLS: "CoreWebVitals/CLS",
17+
"LCP-Sub-Parts": "CoreWebVitals/LCP-Sub-Parts",
18+
};
19+
20+
const USAGE = `webperf-snippets <url> [options]
21+
22+
Run curated WebPerf Snippets headlessly via Playwright.
23+
24+
Options:
25+
--workflow <name> Workflow to run (default: core-web-vitals)
26+
--snippet <name> Run a single snippet (e.g. LCP, CLS, or Category/Name)
27+
--json Output JSON instead of formatted text
28+
--wait <ms> Post-load wait before evaluating (default: 3000)
29+
--budget-lcp <ms> Exit 1 if LCP exceeds this value
30+
--budget-cls <score> Exit 1 if CLS exceeds this value
31+
--headed Show the browser window (debug)
32+
-h, --help Show this help
33+
34+
Examples:
35+
npx webperf-snippets https://web.dev
36+
npx webperf-snippets https://example.com --json
37+
npx webperf-snippets https://example.com --snippet LCP-Sub-Parts
38+
npx webperf-snippets https://example.com --budget-lcp 2500
39+
`;
40+
41+
function fail(message, code = 2) {
42+
process.stderr.write(`${message}\n`);
43+
process.exit(code);
44+
}
45+
46+
function resolveSnippetPath(name) {
47+
return SNIPPET_ALIASES[name] ?? name;
48+
}
49+
50+
function buildItems(values) {
51+
if (values.snippet) {
52+
const path = resolveSnippetPath(values.snippet);
53+
return [{ id: values.snippet, path, source: loadSnippet(path) }];
54+
}
55+
const workflowName = values.workflow ?? "core-web-vitals";
56+
const workflow = WORKFLOWS[workflowName];
57+
if (!workflow) fail(`Unknown workflow: ${workflowName}`);
58+
return workflow.steps.map((step) => ({
59+
id: step.id,
60+
path: step.path,
61+
source: loadSnippet(step.path),
62+
}));
63+
}
64+
65+
function checkBudgets(results, values) {
66+
const violations = [];
67+
const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
68+
const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
69+
70+
for (const r of results) {
71+
if (r.status !== "ok") continue;
72+
if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
73+
violations.push(`LCP ${r.value}ms exceeds budget ${lcpBudget}ms`);
74+
}
75+
if (r.metric === "CLS" && clsBudget != null && r.value > clsBudget) {
76+
violations.push(`CLS ${r.value} exceeds budget ${clsBudget}`);
77+
}
78+
}
79+
return violations;
80+
}
81+
82+
async function main() {
83+
let parsed;
84+
try {
85+
parsed = parseArgs({
86+
options: {
87+
workflow: { type: "string" },
88+
snippet: { type: "string" },
89+
json: { type: "boolean" },
90+
wait: { type: "string" },
91+
"budget-lcp": { type: "string" },
92+
"budget-cls": { type: "string" },
93+
headed: { type: "boolean" },
94+
help: { type: "boolean", short: "h" },
95+
},
96+
allowPositionals: true,
97+
});
98+
} catch (err) {
99+
fail(err.message);
100+
}
101+
102+
const { values, positionals } = parsed;
103+
104+
if (values.help) {
105+
process.stdout.write(USAGE);
106+
return;
107+
}
108+
109+
const url = positionals[0];
110+
if (!url) {
111+
process.stdout.write(USAGE);
112+
process.exit(2);
113+
}
114+
115+
const items = buildItems(values);
116+
const waitMs = values.wait ? Number(values.wait) : 3000;
117+
118+
const initial = await runSnippets({
119+
url,
120+
items,
121+
waitMs,
122+
headless: !values.headed,
123+
});
124+
125+
// Apply decision tree to spawn follow-up steps.
126+
const followUps = nextSteps(initial.results);
127+
let followUpRun = { results: [], pageErrors: [] };
128+
if (followUps.length > 0) {
129+
const followItems = followUps.map((f) => ({
130+
id: f.id,
131+
path: f.path,
132+
source: loadSnippet(f.path),
133+
reason: f.reason,
134+
}));
135+
// v0.1 limitation: follow-ups re-navigate. Acceptable for now; consolidate
136+
// into a single page session in v0.2 if perf becomes a problem.
137+
followUpRun = await runSnippets({
138+
url,
139+
items: followItems,
140+
waitMs,
141+
headless: !values.headed,
142+
});
143+
// Carry forward the human-readable reason so reporters can show it.
144+
followUpRun.results = followUpRun.results.map((r) => {
145+
const f = followUps.find((x) => x.id === r.id);
146+
return f ? { ...r, reason: f.reason } : r;
147+
});
148+
}
149+
150+
const payload = {
151+
url,
152+
navMs: initial.navMs,
153+
results: [...initial.results, ...followUpRun.results],
154+
pageErrors: [...initial.pageErrors, ...(followUpRun.pageErrors ?? [])],
155+
};
156+
157+
const output = values.json ? reportJson(payload) : reportHuman(payload);
158+
process.stdout.write(output + "\n");
159+
160+
// Exit codes.
161+
const violations = checkBudgets(payload.results, values);
162+
if (violations.length > 0) {
163+
if (!values.json) {
164+
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
165+
}
166+
process.exit(1);
167+
}
168+
169+
const anyError = payload.results.some((r) => r.status === "error");
170+
process.exit(anyError ? 1 : 0);
171+
}
172+
173+
main().catch((err) => {
174+
process.stderr.write(`Error: ${err.stack ?? err.message}\n`);
175+
process.exit(1);
176+
});

cli/src/decision-tree.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Declarative follow-ups: if a result matches the predicate, append the step.
2+
// Mirrors the WORKFLOWS.md decision trees from the snippet catalogue.
3+
const RULES = [
4+
{
5+
when: (r) => r.id === "LCP" && r.status === "ok" && r.value > 2500,
6+
append: { id: "LCP-Sub-Parts", path: "CoreWebVitals/LCP-Sub-Parts" },
7+
reason: "LCP > 2.5s — drilling into sub-parts",
8+
},
9+
];
10+
11+
export function nextSteps(results) {
12+
const followUps = [];
13+
for (const result of results) {
14+
for (const rule of RULES) {
15+
if (rule.when(result)) followUps.push({ ...rule.append, reason: rule.reason });
16+
}
17+
}
18+
return followUps;
19+
}

cli/src/load-snippet.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { readFileSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import { dirname, join, resolve } from "node:path";
4+
5+
const HERE = dirname(fileURLToPath(import.meta.url));
6+
const PKG_ROOT = resolve(HERE, "..");
7+
8+
// Resolution order: bundled (published package) → workspace root (dev).
9+
const SNIPPET_DIRS = [
10+
join(PKG_ROOT, "snippets"),
11+
resolve(PKG_ROOT, "..", "snippets"),
12+
];
13+
14+
export function loadSnippet(relativePath) {
15+
for (const dir of SNIPPET_DIRS) {
16+
try {
17+
return readFileSync(join(dir, `${relativePath}.js`), "utf8");
18+
} catch {
19+
// try next directory
20+
}
21+
}
22+
throw new Error(`Snippet not found: ${relativePath}`);
23+
}

0 commit comments

Comments
 (0)