webperf-snippets 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,14 @@ Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings,
44
44
  npx webperf-snippets https://example.com --workflow loading
45
45
  ```
46
46
 
47
+ The steps that tell first-party from third-party resources count as first party the root domain of the page and its subdomains (`cdn.example.com` for `example.com`). A site that serves its own assets from another domain, such as its CDN, lists it with `--own-domains`, comma-separated or repeated:
48
+
49
+ ```bash
50
+ npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net,static.example.org
51
+ ```
52
+
53
+ Each domain counts with its root domain, so `cdn.example.net` makes every host under `example.net` first party. The CLI writes the list in the `OWN_DOMAINS` constant of each snippet, the same one you edit at the top of the snippet in the console. An empty domain or one with spaces exits with code `2`.
54
+
47
55
  Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
48
56
 
49
57
  ```bash
@@ -97,6 +105,16 @@ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
97
105
 
98
106
  The file holds live session cookies. Keep it out of version control (add it to `.gitignore`) and use a throwaway account when you run it in CI. The CLI exits with code `2` if the path does not exist.
99
107
 
108
+ Pages behind an access proxy that reads a token from every request, such as the service tokens of Cloudflare Access, take the token as request headers with `--header`, once per header:
109
+
110
+ ```bash
111
+ npx webperf-snippets https://review.example.com \
112
+ --header "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
113
+ --header "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
114
+ ```
115
+
116
+ The headers go with every request to the origin of the URL and never to another origin, so third parties do not receive the token and their cross-origin requests need no CORS preflight. Each request to that origin is paused in the browser to add them, which adds a few milliseconds to its timing; when the proxy accepts a session cookie, `--storage-state` measures without that cost. A header without a name or a colon exits with code `2`. Read the values from environment variables, as above, to keep them out of the shell history.
117
+
100
118
  Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
101
119
 
102
120
  ```bash
@@ -134,16 +152,90 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
134
152
  "timestamp": "2026-05-06T10:00:00.000Z",
135
153
  "navMs": 1240,
136
154
  "results": [],
137
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.1.0" }
155
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.3.0" }
138
156
  }
139
157
  ```
140
158
 
141
- For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
159
+ For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. With `--cpu` or `--network`, `meta` carries the conditions in `throttling`, and with `--runs`, the summary of the runs in `runs`. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
142
160
 
143
161
  - The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
144
162
  - `--api-key` is sent as `Authorization: Bearer <key>`. Prefer the `PERF_REVIEWS_API_KEY` environment variable, because a flag ends up in shell history and CI logs. The key is never printed.
145
163
  - The exit code never depends on the report. A failed POST (error status, unreachable endpoint, or no answer within 5 seconds) prints a warning and the exit code stays the one the budgets and snippets decide. If the endpoint answers `{ "ok": false, "regressions": [...] }`, the regressions are printed as a warning, again without changing the exit code.
146
164
 
165
+ Measure under the conditions of a slower device and network with `--cpu` and `--network`. Without them the CLI runs at the speed of the machine, which on a laptop or a CI runner is faster than most phones:
166
+
167
+ ```bash
168
+ npx webperf-snippets https://example.com --cpu 4 --network slow-4g
169
+ ```
170
+
171
+ `--cpu <factor>` slows the main thread down by that factor. `--network` takes the presets of DevTools, with the same values:
172
+
173
+ | Preset | Download | Upload | Latency |
174
+ | --------- | ----------- | ----------- | --------- |
175
+ | `fast-4g` | 8.1 Mbps | 1.35 Mbps | 165 ms |
176
+ | `slow-4g` | 1.44 Mbps | 675 kbps | 562.5 ms |
177
+ | `3g` | 400 kbps | 400 kbps | 2000 ms |
178
+
179
+ Chrome adds the latency to the body of each response, not to its headers. The values read from `responseStart`, such as the TTFB and its sub-parts, do not include it; the download of each resource, FCP, LCP and the load event do. The report names the conditions under its header, and the JSON output carries them in `throttling`, such as `{ "cpu": 4, "network": "slow-4g" }`. A CPU factor below `1` or an unknown preset exits with code `2`.
180
+
181
+ One page load is one sample, and lab values vary from one load to the next. `--runs <n>` measures the page n times, from 1 to 9, one after the other, and reports the median run, as WebPageTest does: every result comes from that run, so they agree with each other (the sub-parts of the LCP add up to its LCP):
182
+
183
+ ```bash
184
+ npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
185
+ ```
186
+
187
+ The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and the JSON output carries them in `runs`:
188
+
189
+ ```json
190
+ "runs": {
191
+ "count": 5,
192
+ "failed": 0,
193
+ "medianRun": 3,
194
+ "medianBy": "LCP",
195
+ "summary": {
196
+ "LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
197
+ }
198
+ }
199
+ ```
200
+
201
+ `medianRun` counts from 1, and `values` keeps the order of the runs. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
202
+
203
+ Measure several pages, on several devices and in several variants, in one command with `--matrix <file>`. The file lists them, and the CLI measures every URL on every device and in every variant, each combination `runs` times, one load after the other:
204
+
205
+ ```json
206
+ {
207
+ "urls": {
208
+ "home": "https://example.com/",
209
+ "article": "https://example.com/news/some-article"
210
+ },
211
+ "devices": {
212
+ "mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" },
213
+ "high-end": { "viewport": "mobile", "cpu": 1, "network": { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 } },
214
+ "desktop": { "viewport": "desktop" }
215
+ },
216
+ "variants": {
217
+ "no-consent": {},
218
+ "consent": { "storageState": "states/consent.json" }
219
+ },
220
+ "runs": 3
221
+ }
222
+ ```
223
+
224
+ ```bash
225
+ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
226
+ ```
227
+
228
+ - `urls`: names and URLs. An array of URLs also works, and names each one by its path and query (`/`, `/news/`), with the host when the URLs have more than one.
229
+ - `devices`: the `viewport` preset, the `cpu` factor and the `network`, a preset (`fast-4g`, `slow-4g`, `3g`) or a network of its own in kilobits per second and milliseconds, for the ones DevTools has no preset for, such as 5G. An array of viewport presets (`["mobile", "desktop"]`) is a shortcut for devices without throttling. Without `devices`, the matrix measures a mid-range phone, with CPU 4x and Slow 4G, as Lighthouse does.
230
+ - `variants`: the conditions of each variant, `storageState` (relative to the file), `headers` and `ownDomains`. Without `variants`, the combinations are the pages on the devices: the report has no Variant column, and the `id` of a combination is its page and device (`/news/ · mobile`).
231
+ - `runs` (1 to 9) and `medianBy`, which `--runs` and `--median-by` replace.
232
+
233
+ The options of the command line apply to every combination: `--workflow`, `--snippet`, `--wait`, `--interact-script`, `--header`, `--own-domains`, `--storage-state`, `--cpu`, `--network` and the budgets. The file replaces `cpu`, `network` and `storageState` where it sets them, and its `headers` and `ownDomains` add to the ones of the command line, the variant winning on the same header. A matrix file is meant to be versioned, so pass secrets, such as the token of an access proxy, with `--header` and an environment variable; a header of the file whose name looks like a secret raises a warning. A key the CLI does not know is an error, so a typo does not measure without its condition.
234
+
235
+ The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants) and `runs`. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
236
+
237
+ With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
238
+
147
239
  CI gating:
148
240
 
149
241
  ```bash
@@ -160,8 +252,15 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
160
252
  | `--markdown` | Output GitHub-renderable markdown (for PR comments). |
161
253
  | `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
162
254
  | `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
255
+ | `--cpu <factor>` | Slow the main thread down by this factor, such as `4`. No throttling by default. |
256
+ | `--network <preset>` | Throttle the network with a preset of DevTools: `fast-4g`, `slow-4g`, `3g`. No throttling by default. |
163
257
  | `--interact-script <path>` | JSON file with interactions to run after the page loads (`scroll`, `click`, `hover`, `type`, `wait`). The Interaction snippets are installed first and read afterwards. |
164
258
  | `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
259
+ | `--header "Name: value"` | Request header for the origin of the URL only, such as the token of an access proxy. Repeat it for more headers. |
260
+ | `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
261
+ | `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
262
+ | `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB and `navMs` measured. |
263
+ | `--matrix <file>` | Measure every URL of a JSON file on every device and in every variant it lists, and report a table of the medians. |
165
264
  | `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
166
265
  | `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
167
266
  | `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
@@ -212,6 +311,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
212
311
  claude mcp add webperf -- npx -y webperf-snippets mcp
213
312
  ```
214
313
 
314
+ It needs the Chromium of Playwright (`npx playwright install chromium`); without it, the tools answer with that command. The [MCP server documentation](https://webperf-snippets.net/MCP) has the configuration of other clients (Cursor, VS Code, Claude Desktop, Gemini CLI). The server is listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.nucliweb/webperf-snippets`.
315
+
215
316
  | Tool | What it does |
216
317
  | --------------- | ---------------------------------------------------------------------------- |
217
318
  | `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "mcpName": "io.github.nucliweb/webperf-snippets",
5
5
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
6
6
  "type": "module",
package/src/bin.js CHANGED
@@ -6,15 +6,22 @@ import { resolveSnippetName } from "./snippet-names.js";
6
6
  import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
7
7
  import { WORKFLOWS } from "./workflows/index.js";
8
8
  import { RULES } from "./decision-tree.js";
9
- import { reportHuman } from "./reporters/human.js";
9
+ import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
10
10
  import { reportJson } from "./reporters/json.js";
11
- import { reportMarkdown } from "./reporters/markdown.js";
11
+ import { reportMarkdown, reportMatrixMarkdown } from "./reporters/markdown.js";
12
12
  import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
13
13
  import { fetchCrux, formFactorFor } from "./crux.js";
14
14
  import { MissingBrowserError } from "./browser.js";
15
+ import { parseHeader } from "./headers.js";
16
+ import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
17
+ import { parseOwnDomains } from "./own-domains.js";
18
+ import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
19
+ import { loadMatrix } from "./matrix.js";
20
+ import { blockingErrors } from "./blocking.js";
15
21
 
16
22
 
17
23
  const USAGE = `webperf-snippets <url> [options]
24
+ webperf-snippets --matrix <file> [options]
18
25
  webperf-snippets mcp
19
26
 
20
27
  Run curated WebPerf Snippets headlessly via Playwright.
@@ -37,6 +44,8 @@ Options:
37
44
  --markdown Output GitHub-renderable markdown (for PR comments)
38
45
  --viewport <preset> Viewport preset: mobile (default), tablet, desktop
39
46
  --wait <ms> Post-load wait before evaluating (default: 3000)
47
+ --cpu <factor> Slow the CPU down by this factor, such as 4 (no throttling by default)
48
+ --network <preset> Throttle the network as DevTools does: fast-4g, slow-4g, 3g
40
49
  --budget-lcp <ms> Exit 1 if LCP exceeds this value
41
50
  --budget-cls <score> Exit 1 if CLS exceeds this value
42
51
  --interact-script <path> JSON file with interactions to run after the load; the
@@ -44,6 +53,16 @@ Options:
44
53
  Actions: scroll, click, hover, type, wait
45
54
  --storage-state <path> Playwright storage state (cookies + localStorage) to
46
55
  measure pages that require authentication
56
+ --header "Name: value" Request header for the origin of the URL only, such as
57
+ the token of an access proxy. Repeat it for more headers
58
+ --own-domains <list> Domains of the site to count as first party, such as its
59
+ own CDN, comma-separated or repeated
60
+ --runs <n> Measure the page n times (1 to 9), one after the other, and
61
+ report the median run
62
+ --matrix <file> Measure every URL of a JSON file on every device and in every
63
+ variant it lists, and report a table of the medians
64
+ --median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
65
+ navMs (default: the first of LCP, FCP, TTFB, navMs measured)
47
66
  --crux-key <key> Add CrUX field data (p75) next to the measured values.
48
67
  Also read from the CRUX_API_KEY environment variable
49
68
  --report-to <url> POST the results to this https URL after the run
@@ -63,8 +82,13 @@ Examples:
63
82
  npx webperf-snippets https://example.com --snippet fonts
64
83
  npx webperf-snippets https://example.com --snippet Compression-Audit
65
84
  npx webperf-snippets https://example.com --budget-lcp 2500
85
+ npx webperf-snippets https://example.com --cpu 4 --network slow-4g
86
+ npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
87
+ npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
88
+ npx webperf-snippets --matrix matrix.json --runs 3 --budget-lcp 2500
66
89
  npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
67
90
  npx webperf-snippets https://example.com/dashboard --storage-state auth.json
91
+ npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
68
92
  `;
69
93
 
70
94
  function fail(message, code = 2) {
@@ -82,11 +106,25 @@ function buildSnippetItem(values) {
82
106
  return [{ id: values.snippet, path, source: loadSnippet(path) }];
83
107
  }
84
108
 
85
- function checkBudgets(results, values) {
109
+ // The count given to --runs, or null when it is not a whole number from 1 to 9
110
+ function parseRunCount(raw) {
111
+ return /^[1-9]$/.test(raw.trim()) ? Number(raw) : null;
112
+ }
113
+
114
+ function checkBudgets({ results, runs }, values) {
86
115
  const violations = [];
87
116
  const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
88
117
  const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
89
118
 
119
+ // With several runs the budgets compare the median of each metric, not one sample
120
+ if (runs) {
121
+ const lcp = runs.summary.LCP?.median;
122
+ const cls = runs.summary.CLS?.median;
123
+ if (lcp != null && lcpBudget != null && lcp > lcpBudget) violations.push(`LCP ${lcp}ms exceeds budget ${lcpBudget}ms`);
124
+ if (cls != null && clsBudget != null && cls > clsBudget) violations.push(`CLS ${cls} exceeds budget ${clsBudget}`);
125
+ return violations;
126
+ }
127
+
90
128
  for (const r of results) {
91
129
  if (r.status !== "ok") continue;
92
130
  if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
@@ -119,8 +157,15 @@ async function main() {
119
157
  "budget-lcp": { type: "string" },
120
158
  "budget-cls": { type: "string" },
121
159
  viewport: { type: "string" },
160
+ cpu: { type: "string" },
161
+ network: { type: "string" },
122
162
  "interact-script": { type: "string" },
123
163
  "storage-state": { type: "string" },
164
+ header: { type: "string", multiple: true },
165
+ "own-domains": { type: "string", multiple: true },
166
+ matrix: { type: "string" },
167
+ runs: { type: "string" },
168
+ "median-by": { type: "string" },
124
169
  "crux-key": { type: "string" },
125
170
  "report-to": { type: "string" },
126
171
  "api-key": { type: "string" },
@@ -142,10 +187,13 @@ async function main() {
142
187
  }
143
188
 
144
189
  const url = positionals[0];
145
- if (!url) {
190
+ const matrixPath = values.matrix;
191
+ if (url && matrixPath) fail("Use a URL or --matrix, not both");
192
+ if (!url && !matrixPath) {
146
193
  process.stdout.write(USAGE);
147
194
  process.exit(2);
148
195
  }
196
+ if (matrixPath && values.viewport) fail("--viewport does not apply to --matrix: set the viewport of each device in the file");
149
197
 
150
198
  const waitMs = values.wait ? Number(values.wait) : 3000;
151
199
  const viewportName = values.viewport ?? "mobile";
@@ -154,6 +202,13 @@ async function main() {
154
202
  fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
155
203
  }
156
204
 
205
+ const cpu = values.cpu != null ? parseCpuFactor(values.cpu) : undefined;
206
+ if (cpu === null) fail(`Invalid CPU factor "${values.cpu}". Use a number of at least 1, such as 4`);
207
+ const network = values.network;
208
+ if (network != null && !NETWORK_PRESETS[network]) {
209
+ fail(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
210
+ }
211
+
157
212
  const reportUrl = values["report-to"];
158
213
  if (reportUrl) {
159
214
  const problem = validateReportUrl(reportUrl);
@@ -166,15 +221,55 @@ async function main() {
166
221
  fail(`Storage state file not found: ${storageState}`);
167
222
  }
168
223
 
224
+ const headers = {};
225
+ for (const raw of values.header ?? []) {
226
+ const header = parseHeader(raw);
227
+ if (!header) fail(`Invalid header "${raw}". Use --header "Name: value"`);
228
+ headers[header[0]] = header[1];
229
+ }
230
+
231
+ const invalidOwnDomains = (values["own-domains"] ?? []).find((raw) => !parseOwnDomains([raw]));
232
+ if (invalidOwnDomains != null) {
233
+ fail(`Invalid --own-domains "${invalidOwnDomains}". Use domains separated by commas, such as cdn.example.net,static.example.org`);
234
+ }
235
+ const ownDomains = values["own-domains"] && parseOwnDomains(values["own-domains"]);
236
+
237
+ const runCount = values.runs != null ? parseRunCount(values.runs) : undefined;
238
+ if (runCount === null) fail(`Invalid --runs "${values.runs}". Use a whole number from 1 to 9`);
239
+ const medianBy = values["median-by"];
240
+ if (medianBy != null && !MEDIAN_METRICS.includes(medianBy)) {
241
+ fail(`Unknown --median-by "${medianBy}". Choose from: ${MEDIAN_METRICS.join(", ")}`);
242
+ }
243
+ if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
244
+
245
+ // What each page load runs, a snippet or a workflow, under the conditions of the load
246
+ const items = values.snippet ? buildSnippetItem(values) : null;
247
+ const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
248
+ if (!values.snippet && !workflow) fail(`Unknown workflow: ${values.workflow}`);
249
+ const measureWith = (conditions) => {
250
+ const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
251
+ return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
252
+ };
253
+
254
+ if (matrixPath) {
255
+ let matrix;
256
+ try {
257
+ matrix = loadMatrix(matrixPath, { cpu, network, storageState, headers, ownDomains, runs: runCount, medianBy });
258
+ } catch (err) {
259
+ fail(err.message);
260
+ }
261
+ await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl });
262
+ return;
263
+ }
264
+
265
+ const conditions = { url, viewport, storageState, headers, cpu, network, ownDomains };
169
266
  let payload;
170
- if (values.snippet) {
171
- const items = buildSnippetItem(values);
172
- payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
267
+ if (runCount == null) {
268
+ payload = await measureWith(conditions);
173
269
  } else {
174
- const workflowName = values.workflow ?? "core-web-vitals";
175
- const workflow = WORKFLOWS[workflowName];
176
- if (!workflow) fail(`Unknown workflow: ${workflowName}`);
177
- payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
270
+ const median = await measureRuns(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
271
+ if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
272
+ payload = { ...median.payload, runs: median.runs };
178
273
  }
179
274
 
180
275
  const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
@@ -202,7 +297,7 @@ async function main() {
202
297
  timestamp: new Date().toISOString(),
203
298
  navMs: payload.navMs,
204
299
  results: payload.results,
205
- meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
300
+ meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
206
301
  },
207
302
  });
208
303
  if (!outcome.ok) {
@@ -213,21 +308,110 @@ async function main() {
213
308
  }
214
309
 
215
310
  // Exit codes.
216
- const violations = checkBudgets(payload.results, values);
311
+ const violations = checkBudgets(payload, values);
217
312
  if (violations.length > 0) {
218
313
  if (!values.json) {
219
314
  for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
220
315
  }
221
316
  process.exit(1);
222
317
  }
318
+ process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
319
+ }
320
+
321
+ // Measures `count` runs one after the other, never in parallel: they would compete for the CPU and
322
+ // skew the timings. Each run is reported on stderr as it starts, so stdout keeps only the report. A
323
+ // run that fails is kept as { error } and left out of the median.
324
+ async function measureRuns(measure, count, medianBy, progress) {
325
+ const runs = [];
326
+ for (let i = 1; i <= count; i++) {
327
+ process.stderr.write(`${progress(i)}\n`);
328
+ try {
329
+ runs.push(await measure());
330
+ } catch (err) {
331
+ if (err instanceof MissingBrowserError) throw err;
332
+ runs.push({ error: err.message });
333
+ }
334
+ }
335
+ return summarizeRuns(runs, { medianBy });
336
+ }
337
+
338
+ // Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
339
+ // its median run, and the exit code follows the same rules as one page, for every combination.
340
+ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl }) {
341
+ for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
342
+ const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
343
+
344
+ const total = matrix.combinations.length * matrix.runs;
345
+ const width = String(total).length;
346
+ let started = 0;
347
+ const combinations = [];
348
+ for (const c of matrix.combinations) {
349
+ const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
350
+ const median = await measureRuns(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
351
+ const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
352
+ combinations.push(
353
+ median.payload
354
+ ? { ...identity, ...median.payload, runs: median.runs }
355
+ : { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs } }
356
+ );
357
+ }
358
+
359
+ // The field data of a URL depends on the form factor, not on the variant: one query for each pair
360
+ const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
361
+ if (cruxKey) {
362
+ const queries = new Map();
363
+ for (const c of combinations.filter((c) => !c.error)) {
364
+ const formFactor = formFactorFor(viewportOf.get(c.id));
365
+ const key = `${c.url} ${formFactor}`;
366
+ if (!queries.has(key)) queries.set(key, fetchCrux({ url: c.url, apiKey: cruxKey, formFactor }));
367
+ const crux = await queries.get(key);
368
+ if (crux) c.crux = crux;
369
+ }
370
+ }
223
371
 
224
- const anyError = payload.results.some((r) => r.status === "error");
225
- // A follow-up (a result with a reason) only informs: its error issues show in the report and
226
- // leave the exit code to the budgets and to the steps the workflow runs itself.
227
- const anyAuditViolation = payload.results.some(
228
- (r) => !r.reason && Array.isArray(r.issues) && r.issues.some((i) => i.severity === "error"),
229
- );
230
- process.exit(anyError || anyAuditViolation ? 1 : 0);
372
+ const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose };
373
+ const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, combinations }) : reportMatrixHuman(report);
374
+ process.stdout.write(output + "\n");
375
+
376
+ // One report for each combination, with the body of one page and the device, variant and runs of
377
+ // the combination. The headers are conditions of the load, never part of the body.
378
+ if (reportUrl) {
379
+ for (const c of combinations.filter((c) => !c.error)) {
380
+ const outcome = await reportTo({
381
+ url: reportUrl,
382
+ apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
383
+ body: {
384
+ url: c.url,
385
+ workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
386
+ ...(values.snippet ? { snippet: values.snippet } : {}),
387
+ timestamp: new Date().toISOString(),
388
+ navMs: c.navMs,
389
+ results: c.results,
390
+ meta: {
391
+ viewport: viewportOf.get(c.id),
392
+ device: c.device,
393
+ ...(c.variant ? { variant: c.variant } : {}),
394
+ waitMs,
395
+ ...(c.throttling ? { throttling: c.throttling } : {}),
396
+ runs: c.runs,
397
+ cli_version: cliVersion,
398
+ },
399
+ },
400
+ });
401
+ if (!outcome.ok) {
402
+ process.stderr.write(`Warning: could not report ${c.id} to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
403
+ } else if (outcome.regressions.length > 0) {
404
+ process.stderr.write(`Regressions detected in ${c.id}:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
405
+ }
406
+ }
407
+ }
408
+
409
+ const violations = combinations.filter((c) => !c.error).flatMap((c) => checkBudgets(c, values).map((v) => `${v} (${c.id})`));
410
+ if (!values.json) {
411
+ for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
412
+ }
413
+ const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
414
+ process.exit(violations.length > 0 || failed ? 1 : 0);
231
415
  }
232
416
 
233
417
  main().catch((err) => {
@@ -0,0 +1,12 @@
1
+ // The results that make the command exit with 1: a snippet that failed, and an issue of severity
2
+ // error in a step the workflow runs itself. A follow-up (a result with a reason) only informs: its
3
+ // error issues show in the report and leave the exit code to the budgets and to the steps.
4
+ export function blockingErrors(results) {
5
+ const errors = [];
6
+ for (const r of results) {
7
+ if (r.status === "error") errors.push({ id: r.id, message: r.error });
8
+ if (r.reason || !Array.isArray(r.issues)) continue;
9
+ for (const issue of r.issues) if (issue.severity === "error") errors.push({ id: r.id, message: issue.message });
10
+ }
11
+ return errors;
12
+ }
package/src/crux.js CHANGED
@@ -1,16 +1,16 @@
1
+ import { rate } from "./ratings.js";
2
+
1
3
  const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
2
4
  const CRUX_TIMEOUT_MS = 10000;
3
5
 
4
- // Good / needs-improvement upper bounds from web.dev/articles/vitals.
5
6
  const METRICS = [
6
- { name: "LCP", key: "largest_contentful_paint", unit: "ms", good: 2500, poor: 4000 },
7
- { name: "CLS", key: "cumulative_layout_shift", unit: "score", good: 0.1, poor: 0.25 },
8
- { name: "INP", key: "interaction_to_next_paint", unit: "ms", good: 200, poor: 500 },
7
+ { name: "LCP", key: "largest_contentful_paint", unit: "ms" },
8
+ { name: "CLS", key: "cumulative_layout_shift", unit: "score" },
9
+ { name: "INP", key: "interaction_to_next_paint", unit: "ms" },
9
10
  ];
10
11
 
11
12
  const FORM_FACTOR_LABEL = { PHONE: "mobile", DESKTOP: "desktop", TABLET: "tablet" };
12
13
 
13
- const rate = (value, { good, poor }) => (value <= good ? "good" : value <= poor ? "needs-improvement" : "poor");
14
14
 
15
15
  // The viewport preset decides which population the field data describes.
16
16
  export function formFactorFor(viewportName) {
@@ -22,7 +22,7 @@ function parseMetrics(record) {
22
22
  for (const m of METRICS) {
23
23
  // CLS p75 comes as a string ("0.08"); LCP and INP as numbers.
24
24
  const p75 = Number(record?.metrics?.[m.key]?.percentiles?.p75);
25
- if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(p75, m), unit: m.unit };
25
+ if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(m.name, p75), unit: m.unit };
26
26
  }
27
27
  return metrics;
28
28
  }
package/src/headers.js ADDED
@@ -0,0 +1,35 @@
1
+ // Extra request headers, for pages behind an access proxy that reads a token from each request.
2
+ // They go only to the origin of the measured URL: sent to every origin, as Playwright's
3
+ // extraHTTPHeaders does, they would hand the token to third parties, and a custom header on a
4
+ // cross-origin fetch needs a CORS preflight that most third parties do not answer.
5
+ //
6
+ // The Fetch domain of CDP pauses only the requests that match the pattern, inside the browser,
7
+ // so third-party requests are not intercepted and keep their timings. Each request to the origin
8
+ // waits for the round trip to Node, which adds a few milliseconds to its timing.
9
+
10
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
11
+
12
+ // "Name: value" as given to --header, or null when it is not a valid header
13
+ export function parseHeader(raw) {
14
+ const colon = raw.indexOf(":");
15
+ if (colon === -1) return null;
16
+ const name = raw.slice(0, colon).trim();
17
+ if (!HEADER_NAME.test(name)) return null;
18
+ return [name, raw.slice(colon + 1).trim()];
19
+ }
20
+
21
+ export async function sendHeadersToOrigin(context, page, url, headers) {
22
+ if (!headers || Object.keys(headers).length === 0) return;
23
+ const extra = Object.entries(headers);
24
+ const replaced = new Set(extra.map(([name]) => name.toLowerCase()));
25
+ const cdp = await context.newCDPSession(page);
26
+ cdp.on("Fetch.requestPaused", ({ requestId, request }) => {
27
+ const kept = Object.entries(request.headers).filter(([name]) => !replaced.has(name.toLowerCase()));
28
+ const all = [...kept, ...extra].map(([name, value]) => ({ name, value }));
29
+ // The request is gone when the page navigates away while it is paused
30
+ cdp.send("Fetch.continueRequest", { requestId, headers: all }).catch(() => {});
31
+ });
32
+ await cdp.send("Fetch.enable", {
33
+ patterns: [{ urlPattern: `${new URL(url).origin}/*`, requestStage: "Request" }],
34
+ });
35
+ }
package/src/matrix.js ADDED
@@ -0,0 +1,227 @@
1
+ // A matrix file: several pages, measured on several devices and in several variants, each
2
+ // combination one or more times. This module reads, validates and expands the file into the
3
+ // combinations to measure, with the conditions of each one already combined with the flags of the
4
+ // command line. It opens no browser, so an invalid file fails before any page load.
5
+ //
6
+ // {
7
+ // "urls": { "home": "https://example.com/" }, or an array of URLs
8
+ // "devices": { "mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" } },
9
+ // "variants": { "consent": { "storageState": "consent.json", "headers": {}, "ownDomains": [] } },
10
+ // "runs": 3,
11
+ // "medianBy": "LCP"
12
+ // }
13
+ //
14
+ // The conditions of the file replace cpu, network and storageState of the command line where they
15
+ // set them; headers and own domains add to the ones of the command line, which carries the secrets.
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+ import { VIEWPORT_PRESETS } from "./runner.js";
19
+ import { resolveNetwork } from "./throttling.js";
20
+ import { parseHeader } from "./headers.js";
21
+ import { parseOwnDomains } from "./own-domains.js";
22
+ import { MEDIAN_METRICS } from "./runs.js";
23
+
24
+ const KEYS = {
25
+ matrix: ["urls", "devices", "variants", "runs", "medianBy"],
26
+ device: ["viewport", "cpu", "network"],
27
+ variant: ["storageState", "headers", "ownDomains"],
28
+ };
29
+
30
+ // Without devices, a mid-range phone, as Lighthouse measures by default
31
+ const DEFAULT_DEVICE = { viewport: "mobile", cpu: 4, network: "slow-4g" };
32
+
33
+ // A header whose name says it carries a secret, which a versioned file should not hold
34
+ const LOOKS_SECRET = /secret|token|authorization|key|cookie/i;
35
+
36
+ class MatrixError extends Error {
37
+ constructor(problem) {
38
+ super(`Invalid matrix: ${problem}`);
39
+ }
40
+ }
41
+
42
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
43
+
44
+ function checkKeys(object, known, at) {
45
+ for (const key of Object.keys(object)) {
46
+ if (!known.includes(key)) {
47
+ throw new MatrixError(`unknown key "${at ? `${at}.` : ""}${key}". Known keys: ${known.join(", ")}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ // The URLs by name. An array names each URL by its path and query, with the host when there are
53
+ // several, which is what a reader recognises in a table of 5 rows or of 50.
54
+ function readUrls(urls) {
55
+ if (urls === undefined) throw new MatrixError("urls is required");
56
+ const entries = Array.isArray(urls) ? urls.map((url, i) => [`[${i}]`, url]) : isObject(urls) ? Object.entries(urls).map(([name, url]) => [`.${name}`, url, name]) : null;
57
+ if (!entries) throw new MatrixError("urls must be an object of names and URLs, or an array of URLs");
58
+ if (entries.length === 0) throw new MatrixError("urls has no URL");
59
+
60
+ const parsed = entries.map(([at, url, name]) => {
61
+ let parsedUrl;
62
+ try {
63
+ parsedUrl = new URL(url);
64
+ } catch {
65
+ parsedUrl = null;
66
+ }
67
+ if (typeof url !== "string" || !parsedUrl || !["http:", "https:"].includes(parsedUrl.protocol)) {
68
+ throw new MatrixError(`urls${at} "${url}" is not an http or https URL`);
69
+ }
70
+ return { url, parsedUrl, name };
71
+ });
72
+ if (!Array.isArray(urls)) return parsed.map(({ name, url }) => ({ name, url }));
73
+
74
+ const severalHosts = new Set(parsed.map(({ parsedUrl }) => parsedUrl.host)).size > 1;
75
+ const named = parsed.map(({ url, parsedUrl }) => ({
76
+ name: `${severalHosts ? parsedUrl.host : ""}${parsedUrl.pathname}${parsedUrl.search}`,
77
+ url,
78
+ }));
79
+ const seen = new Set();
80
+ for (const { name, url } of named) {
81
+ if (seen.has(name)) {
82
+ const suggestion = name.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
83
+ throw new MatrixError(`two URLs of urls are named "${name}". Name them with an object, such as { "${suggestion}": "${url}" }`);
84
+ }
85
+ seen.add(name);
86
+ }
87
+ return named;
88
+ }
89
+
90
+ function checkViewport(viewport, at) {
91
+ if (!VIEWPORT_PRESETS[viewport]) {
92
+ throw new MatrixError(`${at} "${viewport}" is not a preset. Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
93
+ }
94
+ }
95
+
96
+ // The devices by name. An array of viewport presets is a shortcut for devices without throttling.
97
+ function readDevices(devices, flags) {
98
+ if (devices === undefined) {
99
+ return [{ name: "mobile", viewport: DEFAULT_DEVICE.viewport, cpu: flags.cpu ?? DEFAULT_DEVICE.cpu, network: flags.network ?? DEFAULT_DEVICE.network }];
100
+ }
101
+ if (Array.isArray(devices) ? devices.length === 0 : isObject(devices) && Object.keys(devices).length === 0) {
102
+ throw new MatrixError("devices has no device");
103
+ }
104
+ if (Array.isArray(devices)) {
105
+ devices.forEach((viewport, i) => checkViewport(viewport, `devices[${i}]`));
106
+ return devices.map((viewport) => ({ name: viewport, viewport, cpu: flags.cpu, network: flags.network }));
107
+ }
108
+ if (!isObject(devices)) throw new MatrixError("devices must be an object of names and devices, or an array of viewport presets");
109
+
110
+ return Object.entries(devices).map(([name, device]) => {
111
+ const at = `devices.${name}`;
112
+ if (!isObject(device)) throw new MatrixError(`${at} must be an object, such as { "viewport": "mobile", "cpu": 4, "network": "slow-4g" }`);
113
+ checkKeys(device, KEYS.device, at);
114
+ const viewport = device.viewport ?? "mobile";
115
+ checkViewport(viewport, `${at}.viewport`);
116
+ if (device.cpu !== undefined && !(typeof device.cpu === "number" && device.cpu >= 1)) {
117
+ throw new MatrixError(`${at}.cpu must be a number of at least 1, such as 4`);
118
+ }
119
+ if (device.network !== undefined) {
120
+ try {
121
+ resolveNetwork(device.network);
122
+ } catch (err) {
123
+ throw new MatrixError(`${at}.network: ${err.message}`);
124
+ }
125
+ }
126
+ return { name, viewport, cpu: device.cpu ?? flags.cpu, network: device.network ?? flags.network };
127
+ });
128
+ }
129
+
130
+ // The variants by name. Without variants, one variant with no name and no conditions of its own,
131
+ // so the combinations are the pages on the devices and the reports leave the variant out.
132
+ function readVariants(variants, base, warnings) {
133
+ if (variants === undefined) return [{ headers: {}, ownDomains: [] }];
134
+ if (!isObject(variants)) throw new MatrixError("variants must be an object of names and variants");
135
+ if (Object.keys(variants).length === 0) throw new MatrixError("variants has no variant");
136
+
137
+ return Object.entries(variants).map(([name, variant]) => {
138
+ const at = `variants.${name}`;
139
+ if (!isObject(variant)) throw new MatrixError(`${at} must be an object, such as { "storageState": "consent.json" }`);
140
+ checkKeys(variant, KEYS.variant, at);
141
+
142
+ let storageState;
143
+ if (variant.storageState !== undefined) {
144
+ storageState = resolve(base, String(variant.storageState));
145
+ if (!existsSync(storageState)) throw new MatrixError(`${at}.storageState "${variant.storageState}" not found at ${storageState}`);
146
+ }
147
+
148
+ const headers = {};
149
+ if (variant.headers !== undefined) {
150
+ if (!isObject(variant.headers)) throw new MatrixError(`${at}.headers must be an object of header names and values`);
151
+ for (const [header, value] of Object.entries(variant.headers)) {
152
+ if (!parseHeader(`${header}: `)) throw new MatrixError(`${at}.headers "${header}" is not a valid header`);
153
+ if (typeof value !== "string") throw new MatrixError(`${at}.headers "${header}" must be a string`);
154
+ if (LOOKS_SECRET.test(header)) {
155
+ warnings.push(`${at}.headers.${header} looks like a secret, and a matrix file is usually versioned. Pass it with --header "${header}: $VARIABLE" instead.`);
156
+ }
157
+ headers[header] = value;
158
+ }
159
+ }
160
+
161
+ let ownDomains = [];
162
+ if (variant.ownDomains !== undefined) {
163
+ const parsed = Array.isArray(variant.ownDomains) && variant.ownDomains.every((d) => typeof d === "string") ? parseOwnDomains(variant.ownDomains) : null;
164
+ if (!parsed) throw new MatrixError(`${at}.ownDomains must be a list of domains without spaces`);
165
+ ownDomains = parsed;
166
+ }
167
+
168
+ return { name, storageState, headers, ownDomains };
169
+ });
170
+ }
171
+
172
+ // The headers of the command line and of the variant; the variant wins on the same header, whatever its case
173
+ function mergeHeaders(fromFlags = {}, fromVariant) {
174
+ const names = new Set(Object.keys(fromVariant).map((name) => name.toLowerCase()));
175
+ const kept = Object.entries(fromFlags).filter(([name]) => !names.has(name.toLowerCase()));
176
+ return Object.fromEntries([...kept, ...Object.entries(fromVariant)]);
177
+ }
178
+
179
+ // The file at `path`, with the flags of the command line: cpu, network, storageState, headers,
180
+ // ownDomains, runs and medianBy. Returns the runs, the metric of the median, the combinations in the
181
+ // order they run (URL, then device, then variant) and the warnings to print.
182
+ export function loadMatrix(path, flags = {}) {
183
+ if (!existsSync(path)) throw new Error(`Matrix file not found: ${path}`);
184
+ let matrix;
185
+ try {
186
+ matrix = JSON.parse(readFileSync(path, "utf8"));
187
+ } catch (err) {
188
+ throw new MatrixError(`the file is not valid JSON (${err.message})`);
189
+ }
190
+ if (!isObject(matrix)) throw new MatrixError("the file must hold an object, such as { \"urls\": [\"https://example.com/\"] }");
191
+ checkKeys(matrix, KEYS.matrix);
192
+
193
+ if (matrix.runs !== undefined && !(Number.isInteger(matrix.runs) && matrix.runs >= 1 && matrix.runs <= 9)) {
194
+ throw new MatrixError("runs must be a whole number from 1 to 9");
195
+ }
196
+ if (matrix.medianBy !== undefined && !MEDIAN_METRICS.includes(matrix.medianBy)) {
197
+ throw new MatrixError(`medianBy "${matrix.medianBy}" is not a metric. Choose from: ${MEDIAN_METRICS.join(", ")}`);
198
+ }
199
+
200
+ const warnings = [];
201
+ const urls = readUrls(matrix.urls);
202
+ const devices = readDevices(matrix.devices, flags);
203
+ const variants = readVariants(matrix.variants, dirname(resolve(path)), warnings);
204
+
205
+ const combinations = [];
206
+ for (const { name, url } of urls) {
207
+ for (const device of devices) {
208
+ for (const variant of variants) {
209
+ combinations.push({
210
+ id: [name, device.name, variant.name].filter(Boolean).join(" · "),
211
+ name,
212
+ url,
213
+ device: device.name,
214
+ ...(variant.name ? { variant: variant.name } : {}),
215
+ viewport: device.viewport,
216
+ cpu: device.cpu,
217
+ network: device.network,
218
+ storageState: variant.storageState ?? flags.storageState,
219
+ headers: mergeHeaders(flags.headers, variant.headers),
220
+ ownDomains: [...(flags.ownDomains ?? []), ...variant.ownDomains],
221
+ });
222
+ }
223
+ }
224
+ }
225
+
226
+ return { runs: flags.runs ?? matrix.runs ?? 1, medianBy: flags.medianBy ?? matrix.medianBy, combinations, warnings };
227
+ }
@@ -0,0 +1,16 @@
1
+ // The domains of the site that differ from the root domain of the page, such as its own CDN,
2
+ // which the snippets that tell first from third parties count as first party. In the console
3
+ // they are written at the top of each snippet; the CLI writes them there before it runs it.
4
+ const DECLARATION = "const OWN_DOMAINS = [];";
5
+
6
+ export function withOwnDomains(source, ownDomains) {
7
+ if (!ownDomains?.length) return source;
8
+ return source.replace(DECLARATION, `const OWN_DOMAINS = ${JSON.stringify(ownDomains)};`);
9
+ }
10
+
11
+ // The values given to --own-domains, each one a domain or a comma-separated list, or null when
12
+ // one of the domains is empty or has spaces. The snippets accept URLs and any case.
13
+ export function parseOwnDomains(values) {
14
+ const domains = values.flatMap((value) => value.split(",").map((domain) => domain.trim()));
15
+ return domains.every((domain) => domain !== "" && !/\s/.test(domain)) ? domains : null;
16
+ }
package/src/ratings.js ADDED
@@ -0,0 +1,16 @@
1
+ // Good / needs-improvement upper bounds from web.dev/articles/vitals, and the ones web.dev gives for
2
+ // FCP and TTFB, the same the snippets rate with.
3
+ export const THRESHOLDS = {
4
+ LCP: { good: 2500, poor: 4000 },
5
+ FCP: { good: 1800, poor: 3000 },
6
+ TTFB: { good: 800, poor: 1800 },
7
+ CLS: { good: 0.1, poor: 0.25 },
8
+ INP: { good: 200, poor: 500 },
9
+ };
10
+
11
+ // "good", "needs-improvement" or "poor", or undefined for a value without thresholds
12
+ export function rate(metric, value) {
13
+ const thresholds = THRESHOLDS[metric];
14
+ if (!thresholds) return undefined;
15
+ return value <= thresholds.good ? "good" : value <= thresholds.poor ? "needs-improvement" : "poor";
16
+ }
@@ -1,5 +1,8 @@
1
1
  import { styleText } from "node:util";
2
2
  import { fieldDataView } from "../crux.js";
3
+ import { describeThrottling } from "../throttling.js";
4
+ import { describeRuns } from "../runs.js";
5
+ import { matrixView } from "./matrix-view.js";
3
6
 
4
7
  const RATING_ICON = {
5
8
  good: "🟢",
@@ -202,10 +205,22 @@ function renderFieldData(crux, results) {
202
205
  return lines;
203
206
  }
204
207
 
205
- export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
208
+ // The runs line, and the median, min and max of each metric across the runs
209
+ function renderRuns(runs) {
210
+ const lines = [styleText("dim", describeRuns(runs))];
211
+ for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
212
+ const cell = (label, value) => pad(`${label} ${formatValue(value, unit)}`, 16);
213
+ lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max).trimEnd()}`));
214
+ }
215
+ return lines;
216
+ }
217
+
218
+ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, title = `WebPerf Snippets — ${url}` }) {
206
219
  const lines = [];
207
- lines.push(styleText(["bold"], `WebPerf Snippets — ${url}`));
220
+ lines.push(styleText(["bold"], title));
208
221
  lines.push(styleText("dim", `Navigated in ${navMs}ms`));
222
+ if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
223
+ if (runs) lines.push(...renderRuns(runs));
209
224
  lines.push("");
210
225
  for (const r of results) lines.push(renderResult(r, verbose));
211
226
  if (crux) {
@@ -219,3 +234,39 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux })
219
234
  }
220
235
  return lines.join("\n");
221
236
  }
237
+
238
+ // One row per combination with the rated median of each metric, and below it the errors that decide
239
+ // the exit code. With verbose, the report of the median run of each combination follows.
240
+ export function reportMatrixHuman(report) {
241
+ const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
242
+ const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
243
+ const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
244
+ const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
245
+
246
+ const lines = [];
247
+ lines.push(styleText(["bold"], `WebPerf Snippets — ${file} (${summary})`));
248
+ lines.push("");
249
+ lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${metrics.map((m) => pad(m, 14)).join("").trimEnd()}`));
250
+ for (const { combination: c, cells } of rows) {
251
+ const row = ` ${identity(columns.map(([key]) => c[key]))}`;
252
+ if (!cells) {
253
+ lines.push(row + styleText("red", "✗ every run failed"));
254
+ continue;
255
+ }
256
+ const drawn = cells.map((cell) => (cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14)));
257
+ lines.push(row + drawn.join("").trimEnd());
258
+ }
259
+
260
+ if (errors.length) {
261
+ lines.push("");
262
+ lines.push(styleText("red", "Errors:"));
263
+ for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
264
+ }
265
+
266
+ if (report.verbose) {
267
+ for (const c of report.combinations.filter((c) => !c.error)) {
268
+ lines.push("", reportHuman({ ...c, verbose: true, title: `${c.id} — ${c.url}` }));
269
+ }
270
+ }
271
+ return lines.join("\n");
272
+ }
@@ -1,4 +1,7 @@
1
1
  import { fieldDataView } from "../crux.js";
2
+ import { describeThrottling } from "../throttling.js";
3
+ import { describeRuns } from "../runs.js";
4
+ import { matrixView } from "./matrix-view.js";
2
5
 
3
6
  const RATING_ICON = {
4
7
  good: "✅",
@@ -71,11 +74,24 @@ function renderFieldData(crux, results) {
71
74
  ];
72
75
  }
73
76
 
74
- export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
77
+ // The median, min and max of each metric across the runs
78
+ function renderRuns(runs) {
79
+ const lines = ["| Metric | Median | Min | Max |", "|--------|--------|-----|-----|"];
80
+ for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
81
+ lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} |`);
82
+ }
83
+ return lines;
84
+ }
85
+
86
+ // With a null title, the report has no heading, as inside the details block of a matrix
87
+ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, title = `WebPerf Results — ${url}` }) {
75
88
  const lines = [];
76
- lines.push(`## WebPerf Results — ${url}`);
89
+ if (title) lines.push(`## ${title}`);
77
90
  lines.push(`> Navigated in ${navMs}ms`);
91
+ if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
92
+ if (runs) lines.push(">", `> ${describeRuns(runs)}`);
78
93
  lines.push("");
94
+ if (runs) lines.push(...renderRuns(runs), "");
79
95
 
80
96
  const initial = results.filter((r) => !r.reason);
81
97
  if (initial.length > 0) {
@@ -106,3 +122,31 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
106
122
 
107
123
  return lines.join("\n");
108
124
  }
125
+
126
+ // A table with the rated median of each combination, the errors that decide the exit code, and the
127
+ // report of the median run of each combination folded into a details block, to keep a pull request
128
+ // comment readable.
129
+ export function reportMatrixMarkdown(report) {
130
+ const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
131
+ const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metrics];
132
+ const row = (parts) => `|${parts.map((part) => (part ? ` ${part} ` : " ")).join("|")}|`;
133
+
134
+ const lines = [`## WebPerf Results — ${file}`, `> ${summary}`, ""];
135
+ lines.push(row(headings));
136
+ lines.push(`|${headings.map((h) => "-".repeat(h.length + 2)).join("|")}|`);
137
+ for (const { combination: c, cells } of rows) {
138
+ const drawn = cells
139
+ ? cells.map((cell) => (cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·"))
140
+ : ["❌ every run failed", ...metrics.slice(1).map(() => "")];
141
+ lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
142
+ }
143
+
144
+ if (errors.length) {
145
+ lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
146
+ }
147
+
148
+ for (const c of report.combinations.filter((c) => !c.error)) {
149
+ lines.push("", `<details><summary>${c.id}</summary>`, "", reportMarkdown({ ...c, title: null }), "", "</details>");
150
+ }
151
+ return lines.join("\n");
152
+ }
@@ -0,0 +1,36 @@
1
+ import { basename } from "node:path";
2
+ import { rate } from "../ratings.js";
3
+ import { blockingErrors } from "../blocking.js";
4
+
5
+ // The order of the metric columns of the matrix table
6
+ const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
7
+
8
+ // What the human and markdown reports of a matrix show, so they only differ in how they draw it:
9
+ // the title, the metric columns, one row per combination with the rated median of each metric, and
10
+ // the errors that decide the exit code.
11
+ export function matrixView({ matrix, runs, combinations }) {
12
+ const measured = combinations.filter((c) => !c.error);
13
+ const medianBys = [...new Set(measured.map((c) => c.runs.medianBy))];
14
+ const metrics = METRICS.filter((metric) => measured.some((c) => c.runs.summary[metric]));
15
+ const median = medianBys.length ? `, median by ${medianBys.join(", ")}` : "";
16
+
17
+ return {
18
+ file: basename(matrix),
19
+ // A matrix without variants has no Variant column
20
+ hasVariants: combinations.some((c) => c.variant),
21
+ summary: `${combinations.length} combinations, ${runs} run${runs === 1 ? "" : "s"} each${median}`,
22
+ metrics,
23
+ rows: combinations.map((c) => ({
24
+ combination: c,
25
+ cells: c.error
26
+ ? null
27
+ : metrics.map((metric) => {
28
+ const value = c.runs.summary[metric];
29
+ return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
30
+ }),
31
+ })),
32
+ errors: combinations.flatMap((c) =>
33
+ c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
34
+ ),
35
+ };
36
+ }
package/src/runner.js CHANGED
@@ -3,6 +3,9 @@ import { loadSnippet } from "./load-snippet.js";
3
3
  import { runInteractions, loadInteractionSteps, isInputStep, settle } from "./interactions.js";
4
4
  import { isTrackingSnippet } from "./tracking.js";
5
5
  import { nextSteps } from "./decision-tree.js";
6
+ import { sendHeadersToOrigin } from "./headers.js";
7
+ import { withOwnDomains } from "./own-domains.js";
8
+ import { applyThrottling, throttlingOf } from "./throttling.js";
6
9
 
7
10
  export const VIEWPORT_PRESETS = {
8
11
  mobile: { width: 375, height: 812 },
@@ -97,6 +100,10 @@ export async function runSnippets({
97
100
  interactScript,
98
101
  interactions = interactScript,
99
102
  storageState,
103
+ headers,
104
+ cpu,
105
+ network,
106
+ ownDomains,
100
107
  // Called at each of the three stages of a run, for callers that report progress (the MCP server)
101
108
  onProgress = () => {},
102
109
  }) {
@@ -105,6 +112,8 @@ export async function runSnippets({
105
112
  try {
106
113
  const context = await browser.newContext({ viewport, storageState });
107
114
  const page = await context.newPage();
115
+ await sendHeadersToOrigin(context, page, url, headers);
116
+ await applyThrottling(context, page, { cpu, network });
108
117
  const pageErrors = [];
109
118
  page.on("pageerror", (err) => pageErrors.push(err.message));
110
119
  const navStart = Date.now();
@@ -112,9 +121,10 @@ export async function runSnippets({
112
121
  if (waitMs > 0) await page.waitForTimeout(waitMs);
113
122
  const navMs = Date.now() - navStart;
114
123
  await onProgress(2, "Page loaded, running the snippets");
115
- const results = await evaluateAroundInteractions(page, items, interactions);
124
+ const prepared = items.map((item) => ({ ...item, source: withOwnDomains(item.source, ownDomains) }));
125
+ const results = await evaluateAroundInteractions(page, prepared, interactions);
116
126
  await onProgress(3, "Snippets evaluated");
117
- return { url, navMs, results, pageErrors };
127
+ return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
118
128
  } finally {
119
129
  await browser.close();
120
130
  }
@@ -131,6 +141,10 @@ export async function runMeasurement({
131
141
  interactScript,
132
142
  interactions = interactScript,
133
143
  storageState,
144
+ headers,
145
+ cpu,
146
+ network,
147
+ ownDomains,
134
148
  onProgress = () => {},
135
149
  }) {
136
150
  await onProgress(1, "Opening Chromium");
@@ -138,6 +152,8 @@ export async function runMeasurement({
138
152
  try {
139
153
  const context = await browser.newContext({ viewport, storageState });
140
154
  const page = await context.newPage();
155
+ await sendHeadersToOrigin(context, page, url, headers);
156
+ await applyThrottling(context, page, { cpu, network });
141
157
  const pageErrors = [];
142
158
  page.on("pageerror", (err) => pageErrors.push(err.message));
143
159
 
@@ -158,7 +174,7 @@ export async function runMeasurement({
158
174
  .map((step) => ({
159
175
  id: step.id,
160
176
  path: step.path,
161
- source: loadSnippet(step.path),
177
+ source: withOwnDomains(loadSnippet(step.path), ownDomains),
162
178
  }));
163
179
  const measured = await evaluateAroundInteractions(page, items, steps);
164
180
  const initialResults = workflow.steps.map(
@@ -181,7 +197,7 @@ export async function runMeasurement({
181
197
  const followItems = followUps.map((f) => ({
182
198
  id: f.id,
183
199
  path: f.path,
184
- source: loadSnippet(f.path),
200
+ source: withOwnDomains(loadSnippet(f.path), ownDomains),
185
201
  }));
186
202
  const raw = await evaluateItems(page, followItems);
187
203
  // A tracking snippet answers through its data function, which is read right away here
@@ -194,7 +210,7 @@ export async function runMeasurement({
194
210
  }
195
211
 
196
212
  await onProgress(3, "Workflow and follow-ups evaluated");
197
- return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors };
213
+ return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
198
214
  } finally {
199
215
  await browser.close();
200
216
  }
package/src/runs.js ADDED
@@ -0,0 +1,65 @@
1
+ // Several runs of the same measurement, reduced to one: the median run, as WebPageTest reports it.
2
+ // Every result of the report comes from that run, so they agree with each other (the sub-parts of
3
+ // the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
4
+
5
+ // The metrics the snippets report, and navMs, which every run has
6
+ export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
7
+
8
+ // Without --median-by, the median follows the first of these that the runs measured
9
+ const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "navMs"];
10
+
11
+ const SUMMARY_METRICS = MEDIAN_METRICS.filter((metric) => metric !== "navMs");
12
+
13
+ // The value of a metric in a run: its first result with that metric, as LCP comes before its
14
+ // follow-up LCP-Subparts, which reports the same metric
15
+ function valueOf(run, metric) {
16
+ if (metric === "navMs") return run.navMs;
17
+ const result = run.results.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
18
+ return result?.value;
19
+ }
20
+
21
+ const measures = (runs, metric) => runs.some((run) => typeof valueOf(run, metric) === "number");
22
+
23
+ // The middle element of numbers sorted from low to high. With an even count it takes the lower of
24
+ // the two middle ones, so the median is always a run that happened, never an average of two.
25
+ const middle = (sorted) => sorted[Math.floor((sorted.length - 1) / 2)];
26
+
27
+ // "3 runs, median by LCP: run 2 (1 failed)", for the header of the reports
28
+ export function describeRuns({ count, failed, medianRun, medianBy, medianByRequested }) {
29
+ const by = medianByRequested ? `${medianBy} (no run measured ${medianByRequested})` : medianBy;
30
+ return `${count} run${count === 1 ? "" : "s"}, median by ${by}: run ${medianRun}${failed ? ` (${failed} failed)` : ""}`;
31
+ }
32
+
33
+ // The runs as the CLI made them, in order, a run that failed being { error }. Returns the payload of
34
+ // the median run and the `runs` field of the report, or nulls and the errors when every run failed.
35
+ export function summarizeRuns(runs, { medianBy: requested } = {}) {
36
+ const completed = runs.filter((run) => !run.error);
37
+ if (completed.length === 0) return { payload: null, runs: null, errors: runs.map((run) => run.error) };
38
+
39
+ const medianBy = requested && measures(completed, requested) ? requested : DEFAULT_ORDER.find((metric) => measures(completed, metric));
40
+ const ranked = completed
41
+ .filter((run) => typeof valueOf(run, medianBy) === "number")
42
+ .sort((a, b) => valueOf(a, medianBy) - valueOf(b, medianBy));
43
+ const payload = middle(ranked);
44
+
45
+ const summary = {};
46
+ for (const metric of SUMMARY_METRICS) {
47
+ if (!measures(completed, metric)) continue;
48
+ const values = runs.map((run) => (run.error ? null : (valueOf(run, metric) ?? null)));
49
+ const sorted = values.filter((value) => value !== null).sort((a, b) => a - b);
50
+ const unit = completed.flatMap((run) => run.results).find((r) => r.metric === metric && r.unit)?.unit;
51
+ summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit };
52
+ }
53
+
54
+ return {
55
+ payload,
56
+ runs: {
57
+ count: runs.length,
58
+ failed: runs.length - completed.length,
59
+ medianRun: runs.indexOf(payload) + 1,
60
+ medianBy,
61
+ ...(requested && requested !== medianBy ? { medianByRequested: requested } : {}),
62
+ summary,
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,80 @@
1
+ // CPU and network throttling through CDP, as the Performance panel of DevTools applies it. The
2
+ // network presets copy the values of DevTools, so a measurement of the CLI compares with one made
3
+ // there. Throughputs are in bytes per second and latencies in milliseconds.
4
+ //
5
+ // A network can also be given by its own values, for the ones DevTools has no preset for, such as
6
+ // 5G or fibre: { downloadKbps, uploadKbps, latencyMs }, in kilobits per second and milliseconds.
7
+ export const NETWORK_PRESETS = {
8
+ "fast-4g": { title: "Fast 4G", download: (9 * 1000 * 1000) / 8 * 0.9, upload: (1.5 * 1000 * 1000) / 8 * 0.9, latency: 60 * 2.75 },
9
+ "slow-4g": { title: "Slow 4G", download: (1.6 * 1000 * 1000) / 8 * 0.9, upload: (750 * 1000) / 8 * 0.9, latency: 150 * 3.75 },
10
+ "3g": { title: "3G", download: (500 * 1000) / 8 * 0.8, upload: (500 * 1000) / 8 * 0.8, latency: 400 * 5 },
11
+ };
12
+
13
+ // The factor given to --cpu, or null when it is not a number of at least 1
14
+ export function parseCpuFactor(raw) {
15
+ const factor = Number(raw);
16
+ return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
17
+ }
18
+
19
+ const NETWORK_VALUES = ["downloadKbps", "uploadKbps", "latencyMs"];
20
+
21
+ // The throughputs and latency of a preset name or of a network given by its values
22
+ export function resolveNetwork(network) {
23
+ if (typeof network === "string") {
24
+ const preset = NETWORK_PRESETS[network];
25
+ if (!preset) throw new Error(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
26
+ return preset;
27
+ }
28
+ for (const key of NETWORK_VALUES) {
29
+ const value = network?.[key];
30
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
31
+ throw new Error(`Invalid network: ${key} must be a number of at least 0, such as { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 }`);
32
+ }
33
+ }
34
+ return {
35
+ download: (network.downloadKbps * 1000) / 8,
36
+ upload: (network.uploadKbps * 1000) / 8,
37
+ latency: network.latencyMs,
38
+ };
39
+ }
40
+
41
+ // 400 kbps, 1.44 Mbps, 50 Mbps
42
+ function formatKbps(kbps) {
43
+ return kbps < 1000 ? `${kbps} kbps` : `${Number((kbps / 1000).toFixed(2))} Mbps`;
44
+ }
45
+
46
+ function describeNetwork(network) {
47
+ if (typeof network === "string") return NETWORK_PRESETS[network]?.title ?? network;
48
+ return `${formatKbps(network.downloadKbps)}, ${network.latencyMs} ms`;
49
+ }
50
+
51
+ // The conditions of a run, as the payload reports them, or undefined without throttling
52
+ export function throttlingOf({ cpu, network }) {
53
+ if (cpu == null && network == null) return undefined;
54
+ return { ...(cpu != null ? { cpu } : {}), ...(network != null ? { network } : {}) };
55
+ }
56
+
57
+ // "CPU 4x, Slow 4G" or "CPU 1x, 50 Mbps, 20 ms", for the header of the reports
58
+ export function describeThrottling({ cpu, network }) {
59
+ return [cpu != null ? `CPU ${cpu}x` : null, network != null ? describeNetwork(network) : null]
60
+ .filter(Boolean)
61
+ .join(", ");
62
+ }
63
+
64
+ export async function applyThrottling(context, page, { cpu, network }) {
65
+ if (cpu == null && network == null) return;
66
+ // An invalid network fails before anything is applied to the page
67
+ const conditions = network != null ? resolveNetwork(network) : null;
68
+ const cdp = await context.newCDPSession(page);
69
+ if (cpu != null) await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
70
+ if (conditions) {
71
+ const { download, upload, latency } = conditions;
72
+ await cdp.send("Network.enable");
73
+ await cdp.send("Network.emulateNetworkConditions", {
74
+ offline: false,
75
+ latency,
76
+ downloadThroughput: download,
77
+ uploadThroughput: upload,
78
+ });
79
+ }
80
+ }