webperf-snippets 1.2.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 +63 -2
- package/package.json +1 -1
- package/src/bin.js +168 -20
- package/src/blocking.js +12 -0
- package/src/crux.js +6 -6
- package/src/matrix.js +227 -0
- package/src/ratings.js +16 -0
- package/src/reporters/human.js +51 -2
- package/src/reporters/markdown.js +44 -2
- package/src/reporters/matrix-view.js +36 -0
- package/src/runs.js +65 -0
- package/src/throttling.js +41 -4
package/README.md
CHANGED
|
@@ -152,11 +152,11 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
152
152
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
153
153
|
"navMs": 1240,
|
|
154
154
|
"results": [],
|
|
155
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
155
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.3.0" }
|
|
156
156
|
}
|
|
157
157
|
```
|
|
158
158
|
|
|
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`. `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.
|
|
160
160
|
|
|
161
161
|
- The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
|
|
162
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.
|
|
@@ -178,6 +178,64 @@ npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
|
178
178
|
|
|
179
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
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
|
+
|
|
181
239
|
CI gating:
|
|
182
240
|
|
|
183
241
|
```bash
|
|
@@ -200,6 +258,9 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
200
258
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
201
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. |
|
|
202
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. |
|
|
203
264
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
204
265
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
205
266
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
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,18 +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
15
|
import { parseHeader } from "./headers.js";
|
|
16
16
|
import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
|
|
17
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";
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
const USAGE = `webperf-snippets <url> [options]
|
|
24
|
+
webperf-snippets --matrix <file> [options]
|
|
21
25
|
webperf-snippets mcp
|
|
22
26
|
|
|
23
27
|
Run curated WebPerf Snippets headlessly via Playwright.
|
|
@@ -53,6 +57,12 @@ Options:
|
|
|
53
57
|
the token of an access proxy. Repeat it for more headers
|
|
54
58
|
--own-domains <list> Domains of the site to count as first party, such as its
|
|
55
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)
|
|
56
66
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
57
67
|
Also read from the CRUX_API_KEY environment variable
|
|
58
68
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -74,6 +84,8 @@ Examples:
|
|
|
74
84
|
npx webperf-snippets https://example.com --budget-lcp 2500
|
|
75
85
|
npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
76
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
|
|
77
89
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
78
90
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
79
91
|
npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
|
|
@@ -94,11 +106,25 @@ function buildSnippetItem(values) {
|
|
|
94
106
|
return [{ id: values.snippet, path, source: loadSnippet(path) }];
|
|
95
107
|
}
|
|
96
108
|
|
|
97
|
-
|
|
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) {
|
|
98
115
|
const violations = [];
|
|
99
116
|
const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
|
|
100
117
|
const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
|
|
101
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
|
+
|
|
102
128
|
for (const r of results) {
|
|
103
129
|
if (r.status !== "ok") continue;
|
|
104
130
|
if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
|
|
@@ -137,6 +163,9 @@ async function main() {
|
|
|
137
163
|
"storage-state": { type: "string" },
|
|
138
164
|
header: { type: "string", multiple: true },
|
|
139
165
|
"own-domains": { type: "string", multiple: true },
|
|
166
|
+
matrix: { type: "string" },
|
|
167
|
+
runs: { type: "string" },
|
|
168
|
+
"median-by": { type: "string" },
|
|
140
169
|
"crux-key": { type: "string" },
|
|
141
170
|
"report-to": { type: "string" },
|
|
142
171
|
"api-key": { type: "string" },
|
|
@@ -158,10 +187,13 @@ async function main() {
|
|
|
158
187
|
}
|
|
159
188
|
|
|
160
189
|
const url = positionals[0];
|
|
161
|
-
|
|
190
|
+
const matrixPath = values.matrix;
|
|
191
|
+
if (url && matrixPath) fail("Use a URL or --matrix, not both");
|
|
192
|
+
if (!url && !matrixPath) {
|
|
162
193
|
process.stdout.write(USAGE);
|
|
163
194
|
process.exit(2);
|
|
164
195
|
}
|
|
196
|
+
if (matrixPath && values.viewport) fail("--viewport does not apply to --matrix: set the viewport of each device in the file");
|
|
165
197
|
|
|
166
198
|
const waitMs = values.wait ? Number(values.wait) : 3000;
|
|
167
199
|
const viewportName = values.viewport ?? "mobile";
|
|
@@ -202,15 +234,42 @@ async function main() {
|
|
|
202
234
|
}
|
|
203
235
|
const ownDomains = values["own-domains"] && parseOwnDomains(values["own-domains"]);
|
|
204
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 };
|
|
205
266
|
let payload;
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
|
|
267
|
+
if (runCount == null) {
|
|
268
|
+
payload = await measureWith(conditions);
|
|
209
269
|
} else {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
|
|
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 };
|
|
214
273
|
}
|
|
215
274
|
|
|
216
275
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
@@ -238,7 +297,7 @@ async function main() {
|
|
|
238
297
|
timestamp: new Date().toISOString(),
|
|
239
298
|
navMs: payload.navMs,
|
|
240
299
|
results: payload.results,
|
|
241
|
-
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), cli_version: cliVersion },
|
|
300
|
+
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
|
|
242
301
|
},
|
|
243
302
|
});
|
|
244
303
|
if (!outcome.ok) {
|
|
@@ -249,21 +308,110 @@ async function main() {
|
|
|
249
308
|
}
|
|
250
309
|
|
|
251
310
|
// Exit codes.
|
|
252
|
-
const violations = checkBudgets(payload
|
|
311
|
+
const violations = checkBudgets(payload, values);
|
|
253
312
|
if (violations.length > 0) {
|
|
254
313
|
if (!values.json) {
|
|
255
314
|
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
256
315
|
}
|
|
257
316
|
process.exit(1);
|
|
258
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
|
+
}
|
|
259
371
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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);
|
|
267
415
|
}
|
|
268
416
|
|
|
269
417
|
main().catch((err) => {
|
package/src/blocking.js
ADDED
|
@@ -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"
|
|
7
|
-
{ name: "CLS", key: "cumulative_layout_shift", unit: "score"
|
|
8
|
-
{ name: "INP", key: "interaction_to_next_paint", unit: "ms"
|
|
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(
|
|
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/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
|
+
}
|
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
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { fieldDataView } from "../crux.js";
|
|
3
3
|
import { describeThrottling } from "../throttling.js";
|
|
4
|
+
import { describeRuns } from "../runs.js";
|
|
5
|
+
import { matrixView } from "./matrix-view.js";
|
|
4
6
|
|
|
5
7
|
const RATING_ICON = {
|
|
6
8
|
good: "🟢",
|
|
@@ -203,11 +205,22 @@ function renderFieldData(crux, results) {
|
|
|
203
205
|
return lines;
|
|
204
206
|
}
|
|
205
207
|
|
|
206
|
-
|
|
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}` }) {
|
|
207
219
|
const lines = [];
|
|
208
|
-
lines.push(styleText(["bold"],
|
|
220
|
+
lines.push(styleText(["bold"], title));
|
|
209
221
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
210
222
|
if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
|
|
223
|
+
if (runs) lines.push(...renderRuns(runs));
|
|
211
224
|
lines.push("");
|
|
212
225
|
for (const r of results) lines.push(renderResult(r, verbose));
|
|
213
226
|
if (crux) {
|
|
@@ -221,3 +234,39 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
221
234
|
}
|
|
222
235
|
return lines.join("\n");
|
|
223
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,5 +1,7 @@
|
|
|
1
1
|
import { fieldDataView } from "../crux.js";
|
|
2
2
|
import { describeThrottling } from "../throttling.js";
|
|
3
|
+
import { describeRuns } from "../runs.js";
|
|
4
|
+
import { matrixView } from "./matrix-view.js";
|
|
3
5
|
|
|
4
6
|
const RATING_ICON = {
|
|
5
7
|
good: "✅",
|
|
@@ -72,12 +74,24 @@ function renderFieldData(crux, results) {
|
|
|
72
74
|
];
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
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}` }) {
|
|
76
88
|
const lines = [];
|
|
77
|
-
lines.push(`##
|
|
89
|
+
if (title) lines.push(`## ${title}`);
|
|
78
90
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
79
91
|
if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
|
|
92
|
+
if (runs) lines.push(">", `> ${describeRuns(runs)}`);
|
|
80
93
|
lines.push("");
|
|
94
|
+
if (runs) lines.push(...renderRuns(runs), "");
|
|
81
95
|
|
|
82
96
|
const initial = results.filter((r) => !r.reason);
|
|
83
97
|
if (initial.length > 0) {
|
|
@@ -108,3 +122,31 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
108
122
|
|
|
109
123
|
return lines.join("\n");
|
|
110
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/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
|
+
}
|
package/src/throttling.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// CPU and network throttling through CDP, as the Performance panel of DevTools applies it. The
|
|
2
2
|
// network presets copy the values of DevTools, so a measurement of the CLI compares with one made
|
|
3
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.
|
|
4
7
|
export const NETWORK_PRESETS = {
|
|
5
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 },
|
|
6
9
|
"slow-4g": { title: "Slow 4G", download: (1.6 * 1000 * 1000) / 8 * 0.9, upload: (750 * 1000) / 8 * 0.9, latency: 150 * 3.75 },
|
|
@@ -13,25 +16,59 @@ export function parseCpuFactor(raw) {
|
|
|
13
16
|
return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
|
|
14
17
|
}
|
|
15
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
|
+
|
|
16
51
|
// The conditions of a run, as the payload reports them, or undefined without throttling
|
|
17
52
|
export function throttlingOf({ cpu, network }) {
|
|
18
53
|
if (cpu == null && network == null) return undefined;
|
|
19
54
|
return { ...(cpu != null ? { cpu } : {}), ...(network != null ? { network } : {}) };
|
|
20
55
|
}
|
|
21
56
|
|
|
22
|
-
// "CPU 4x, Slow 4G", for the header of the reports
|
|
57
|
+
// "CPU 4x, Slow 4G" or "CPU 1x, 50 Mbps, 20 ms", for the header of the reports
|
|
23
58
|
export function describeThrottling({ cpu, network }) {
|
|
24
|
-
return [cpu != null ? `CPU ${cpu}x` : null, network != null ? (
|
|
59
|
+
return [cpu != null ? `CPU ${cpu}x` : null, network != null ? describeNetwork(network) : null]
|
|
25
60
|
.filter(Boolean)
|
|
26
61
|
.join(", ");
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
export async function applyThrottling(context, page, { cpu, network }) {
|
|
30
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;
|
|
31
68
|
const cdp = await context.newCDPSession(page);
|
|
32
69
|
if (cpu != null) await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
|
|
33
|
-
if (
|
|
34
|
-
const { download, upload, latency } =
|
|
70
|
+
if (conditions) {
|
|
71
|
+
const { download, upload, latency } = conditions;
|
|
35
72
|
await cdp.send("Network.enable");
|
|
36
73
|
await cdp.send("Network.emulateNetworkConditions", {
|
|
37
74
|
offline: false,
|