webperf-snippets 1.2.0 → 1.4.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 +126 -4
- package/package.json +3 -2
- package/snippets/CoreWebVitals/CLS.js +3 -2
- package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
- package/src/baseline.js +201 -0
- package/src/bin.js +229 -20
- package/src/blocking.js +14 -0
- package/src/conditions.js +7 -0
- package/src/crux.js +6 -6
- package/src/matrix.js +227 -0
- package/src/ratings.js +16 -0
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +89 -2
- package/src/reporters/markdown.js +84 -2
- package/src/reporters/matrix-view.js +42 -0
- package/src/runs.js +65 -0
- package/src/throttling.js +41 -4
package/README.md
CHANGED
|
@@ -70,6 +70,12 @@ JSON output (for piping into `jq` or CI):
|
|
|
70
70
|
npx webperf-snippets https://example.com --json
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.4.0" }
|
|
77
|
+
```
|
|
78
|
+
|
|
73
79
|
Single snippet:
|
|
74
80
|
|
|
75
81
|
```bash
|
|
@@ -152,11 +158,11 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
152
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
153
159
|
"navMs": 1240,
|
|
154
160
|
"results": [],
|
|
155
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.4.0" }
|
|
156
162
|
}
|
|
157
163
|
```
|
|
158
164
|
|
|
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.
|
|
165
|
+
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
166
|
|
|
161
167
|
- The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
|
|
162
168
|
- `--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 +184,116 @@ npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
|
178
184
|
|
|
179
185
|
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
186
|
|
|
187
|
+
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):
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
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`:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
"runs": {
|
|
197
|
+
"count": 5,
|
|
198
|
+
"failed": 0,
|
|
199
|
+
"medianRun": 3,
|
|
200
|
+
"medianBy": "LCP",
|
|
201
|
+
"summary": {
|
|
202
|
+
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`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`.
|
|
208
|
+
|
|
209
|
+
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:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"urls": {
|
|
214
|
+
"home": "https://example.com/",
|
|
215
|
+
"article": "https://example.com/news/some-article"
|
|
216
|
+
},
|
|
217
|
+
"devices": {
|
|
218
|
+
"mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" },
|
|
219
|
+
"high-end": { "viewport": "mobile", "cpu": 1, "network": { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 } },
|
|
220
|
+
"desktop": { "viewport": "desktop" }
|
|
221
|
+
},
|
|
222
|
+
"variants": {
|
|
223
|
+
"no-consent": {},
|
|
224
|
+
"consent": { "storageState": "states/consent.json" }
|
|
225
|
+
},
|
|
226
|
+
"runs": 3
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- `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.
|
|
235
|
+
- `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.
|
|
236
|
+
- `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`).
|
|
237
|
+
- `runs` (1 to 9) and `medianBy`, which `--runs` and `--median-by` replace.
|
|
238
|
+
|
|
239
|
+
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.
|
|
240
|
+
|
|
241
|
+
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), `runs` and `conditions`, with the viewport of its device. 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.
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
Compare a run with an earlier one with `--baseline <file>`, the `--json` output of that run. The comparison says which metrics got worse or better beyond the noise of the lab, which errors are new or fixed, and what differs in how both runs were measured:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# On main, once: the baseline
|
|
249
|
+
npx webperf-snippets https://example.com --runs 5 --json > baseline.json
|
|
250
|
+
|
|
251
|
+
# On the pull request: compare and report
|
|
252
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --markdown
|
|
253
|
+
|
|
254
|
+
# Fail when the page gets worse
|
|
255
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is unchanged.
|
|
259
|
+
|
|
260
|
+
| Metric | Share of the baseline | Minimum |
|
|
261
|
+
| -------------- | --------------------- | ------- |
|
|
262
|
+
| LCP, FCP, TTFB | 10% | 100 ms |
|
|
263
|
+
| INP | 10% | 50 ms |
|
|
264
|
+
| CLS | none | 0.05 |
|
|
265
|
+
|
|
266
|
+
`--threshold` changes one part of a threshold and keeps the other: `--threshold LCP=15%` the share, `--threshold LCP=200ms` the minimum, and `--threshold CLS=0.1` the only part of CLS. Repeat it for more metrics.
|
|
267
|
+
|
|
268
|
+
The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share a code or, without one, when their messages differ only in their figures, so "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…" are one error. An error that only this run has is new, and one that only the baseline had is fixed. Warnings and information do not compare, since they change too easily.
|
|
269
|
+
|
|
270
|
+
The report adds the comparison after the usual one, and `--markdown` a table for the pull request comment. The JSON output carries it in `baseline`:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
"baseline": {
|
|
274
|
+
"file": "baseline.json",
|
|
275
|
+
"url": "https://example.com/",
|
|
276
|
+
"runs": { "count": 5, "medianBy": "LCP" },
|
|
277
|
+
"metrics": {
|
|
278
|
+
"LCP": { "baseline": 2100, "current": 2480, "delta": 380, "deltaPercent": 18.1, "threshold": 210, "verdict": "regressed" }
|
|
279
|
+
},
|
|
280
|
+
"errors": { "new": [{ "id": "render-blocking", "message": "1 render-blocking resource(s) delay rendering until 900ms" }], "fixed": [] },
|
|
281
|
+
"warnings": []
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
|
|
286
|
+
|
|
287
|
+
The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
|
|
288
|
+
|
|
289
|
+
A matrix compares with the `--json` output of an earlier matrix, each combination with the one of the same `id`:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
npx webperf-snippets --matrix matrix.json --runs 3 --baseline last-week.json
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The table adds a change column after each metric: the change when it counts (`▲ +380ms`, `▼ −90ms`), `=` when it is unchanged, and `new` for a combination the baseline did not have. Below the table come the warnings, once when every combination has them, the new and fixed errors of each combination, and the combinations that only one side has. `--markdown` gives the same, and the `<details>` block of each combination carries its own comparison. In the JSON output each combination carries its `baseline`, and the root `baseline` names the file and the `added` and `missing` combinations. `--fail-on-regression` exits with `1` when any combination regressed or has a new error.
|
|
296
|
+
|
|
181
297
|
CI gating:
|
|
182
298
|
|
|
183
299
|
```bash
|
|
@@ -200,6 +316,12 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
200
316
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
201
317
|
| `--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
318
|
| `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
|
|
319
|
+
| `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
|
|
320
|
+
| `--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. |
|
|
321
|
+
| `--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. |
|
|
322
|
+
| `--baseline <file>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run: which metrics got worse or better beyond the noise, and which errors are new or fixed. |
|
|
323
|
+
| `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
|
|
324
|
+
| `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
|
|
203
325
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
204
326
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
205
327
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -239,8 +361,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
239
361
|
| Code | Meaning |
|
|
240
362
|
| ---- | --------------------------------------------- |
|
|
241
363
|
| `0` | All checks passed. |
|
|
242
|
-
| `1` | Budget violation, a snippet errored,
|
|
243
|
-
| `2` | Usage error (missing URL, unknown workflow).
|
|
364
|
+
| `1` | Budget violation, a snippet errored, a step of the workflow reported an `error` issue, or, with `--fail-on-regression`, a regression against the baseline. |
|
|
365
|
+
| `2` | Usage error (missing URL, unknown workflow, a baseline or threshold that is not valid). |
|
|
244
366
|
|
|
245
367
|
## MCP server
|
|
246
368
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
|
-
"vitest": "^5.0.5"
|
|
24
|
+
"vitest": "^5.0.5",
|
|
25
|
+
"web-vitals": "^6.2.3"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=20.12.0"
|
|
@@ -53,10 +53,11 @@
|
|
|
53
53
|
|
|
54
54
|
observer.observe({ type: "layout-shift", buffered: true });
|
|
55
55
|
|
|
56
|
-
// Update on visibility change (final CLS)
|
|
56
|
+
// Update on visibility change (final CLS). The shifts that the observer has not delivered yet are
|
|
57
|
+
// in takeRecords(): without them, the last shift before the page hides is lost.
|
|
57
58
|
document.addEventListener("visibilitychange", () => {
|
|
58
59
|
if (document.visibilityState === "hidden") {
|
|
59
|
-
observer.takeRecords();
|
|
60
|
+
for (const entry of observer.takeRecords()) addShift(entry);
|
|
60
61
|
console.log("%c📊 Final CLS (on page hide):", "font-weight: bold;");
|
|
61
62
|
logCLS();
|
|
62
63
|
}
|
|
@@ -76,6 +76,23 @@
|
|
|
76
76
|
return { waiting: firstByte - requestStart, download: responseEnd - firstByte };
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
// The times that split the LCP into its subparts, as web-vitals computes them. The resource is the
|
|
80
|
+
// last request for the LCP URL that started before the render: an earlier download of the same URL
|
|
81
|
+
// did not paint the element. Its end is capped at the LCP, since a video keeps downloading after
|
|
82
|
+
// its first frame paints, so the four subparts always add up to the LCP.
|
|
83
|
+
const lcpSubpartTimes = (lcpEntry, navEntry) => {
|
|
84
|
+
const activationStart = navEntry.activationStart || 0;
|
|
85
|
+
const renderTime = Math.max(0, lcpEntry.startTime - activationStart);
|
|
86
|
+
const resEntry = performance
|
|
87
|
+
.getEntriesByType("resource")
|
|
88
|
+
.filter((e) => !isOwnRequest(e))
|
|
89
|
+
.findLast((e) => e.name === lcpEntry.url && (e.requestStart || e.startTime) <= lcpEntry.startTime);
|
|
90
|
+
const ttfb = Math.max(0, navEntry.responseStart - activationStart);
|
|
91
|
+
const requestStart = Math.max(ttfb, resEntry ? (resEntry.requestStart || resEntry.startTime) - activationStart : 0);
|
|
92
|
+
const responseEnd = Math.min(renderTime, Math.max(requestStart, resEntry ? resEntry.responseEnd - activationStart : 0));
|
|
93
|
+
return { resEntry, activationStart, ttfb, requestStart, responseEnd, renderTime };
|
|
94
|
+
};
|
|
95
|
+
|
|
79
96
|
const getNavigationEntry = () => {
|
|
80
97
|
const navEntry = performance.getEntriesByType("navigation")[0];
|
|
81
98
|
if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
|
|
@@ -91,31 +108,14 @@
|
|
|
91
108
|
const navEntry = getNavigationEntry();
|
|
92
109
|
if (!navEntry) return;
|
|
93
110
|
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const activationStart = navEntry.activationStart || 0;
|
|
99
|
-
|
|
100
|
-
// Calculate sub-parts
|
|
101
|
-
const ttfb = Math.max(0, navEntry.responseStart - activationStart);
|
|
102
|
-
|
|
103
|
-
const lcpRequestStart = Math.max(
|
|
111
|
+
const {
|
|
112
|
+
resEntry: lcpResEntry,
|
|
113
|
+
activationStart,
|
|
104
114
|
ttfb,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
const lcpResponseEnd = Math.max(
|
|
111
|
-
lcpRequestStart,
|
|
112
|
-
lcpResEntry ? lcpResEntry.responseEnd - activationStart : 0
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const lcpRenderTime = Math.max(
|
|
116
|
-
lcpResponseEnd,
|
|
117
|
-
lcpEntry.startTime - activationStart
|
|
118
|
-
);
|
|
115
|
+
requestStart: lcpRequestStart,
|
|
116
|
+
responseEnd: lcpResponseEnd,
|
|
117
|
+
renderTime: lcpRenderTime,
|
|
118
|
+
} = lcpSubpartTimes(lcpEntry, navEntry);
|
|
119
119
|
|
|
120
120
|
const loadSplit = splitLoadTime(lcpResEntry, lcpRequestStart, lcpResponseEnd, activationStart);
|
|
121
121
|
|
|
@@ -278,17 +278,14 @@
|
|
|
278
278
|
if (!navEntrySync) {
|
|
279
279
|
return { script: "LCP-Subparts", status: "error", error: "No navigation entry" };
|
|
280
280
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
lcpResEntrySync ? lcpResEntrySync.responseEnd - activationStartSync : 0
|
|
290
|
-
);
|
|
291
|
-
const lcpRenderTimeSync = Math.max(lcpResponseEndSync, lcpEntry.startTime - activationStartSync);
|
|
281
|
+
const {
|
|
282
|
+
resEntry: lcpResEntrySync,
|
|
283
|
+
activationStart: activationStartSync,
|
|
284
|
+
ttfb: ttfbSync,
|
|
285
|
+
requestStart: lcpRequestStartSync,
|
|
286
|
+
responseEnd: lcpResponseEndSync,
|
|
287
|
+
renderTime: lcpRenderTimeSync,
|
|
288
|
+
} = lcpSubpartTimes(lcpEntry, navEntrySync);
|
|
292
289
|
const totalSync = Math.round(lcpRenderTimeSync);
|
|
293
290
|
const ratingSync = valueToRating(totalSync);
|
|
294
291
|
const ttfbVal = Math.round(ttfbSync);
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { blockingErrors } from "./blocking.js";
|
|
3
|
+
import { describeThrottling } from "./throttling.js";
|
|
4
|
+
|
|
5
|
+
// A measurement compared with an earlier one, its baseline: which metrics got worse or better beyond
|
|
6
|
+
// the noise of the lab, which errors appeared or went away, and what differs in how both were
|
|
7
|
+
// measured. Pure functions over the JSON the CLI prints with --json, so a baseline is any earlier
|
|
8
|
+
// output saved with --json > baseline.json.
|
|
9
|
+
|
|
10
|
+
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
11
|
+
|
|
12
|
+
// A change counts when it beats the larger of a share of the baseline and a minimum: 10% of a 3 s LCP
|
|
13
|
+
// is 300 ms, and 10% of a 200 ms TTFB would be noise. CLS is a score, with no relative part.
|
|
14
|
+
export const DEFAULT_THRESHOLDS = {
|
|
15
|
+
LCP: { relative: 0.1, absolute: 100 },
|
|
16
|
+
FCP: { relative: 0.1, absolute: 100 },
|
|
17
|
+
TTFB: { relative: 0.1, absolute: 100 },
|
|
18
|
+
INP: { relative: 0.1, absolute: 50 },
|
|
19
|
+
CLS: { absolute: 0.05 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The baseline in a file, as { kind: "page" | "matrix", file, data }. Throws with what to do when
|
|
23
|
+
// the file is not an output of the CLI.
|
|
24
|
+
export function readBaseline(text, file) {
|
|
25
|
+
const howTo = `save one with webperf-snippets <url> --json > ${file}`;
|
|
26
|
+
let data;
|
|
27
|
+
try {
|
|
28
|
+
data = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`${file} is not JSON: ${howTo}`);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(data?.combinations)) return { kind: "matrix", file, data };
|
|
33
|
+
if (typeof data?.url === "string" && Array.isArray(data.results)) return { kind: "page", file, data };
|
|
34
|
+
throw new Error(`${file} is not an output of webperf-snippets --json: it has neither the results of a page nor the combinations of a matrix; ${howTo}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadBaseline(path) {
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = readFileSync(path, "utf8");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(`Cannot read the baseline ${path}: ${err.code === "ENOENT" ? "the file does not exist" : err.message}`);
|
|
43
|
+
}
|
|
44
|
+
return readBaseline(text, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// One part of the threshold of a metric, from --threshold: LCP=15% (relative), LCP=200ms
|
|
48
|
+
// (absolute), CLS=0.1 (CLS has only an absolute part, with no unit)
|
|
49
|
+
export function parseThreshold(raw) {
|
|
50
|
+
const match = /^([A-Za-z]+)=(\d+(?:\.\d+)?)(%|ms)?$/.exec(raw.trim());
|
|
51
|
+
if (!match) throw new Error(`Invalid --threshold "${raw}": give METRIC=VALUE with its unit, such as LCP=15%, LCP=200ms or CLS=0.1`);
|
|
52
|
+
const [, name, number, unit] = match;
|
|
53
|
+
const metric = name.toUpperCase();
|
|
54
|
+
if (!METRICS.includes(metric)) throw new Error(`Unknown metric "${name}" in --threshold "${raw}". Choose from: ${METRICS.join(", ")}`);
|
|
55
|
+
const value = Number(number);
|
|
56
|
+
if (metric === "CLS") {
|
|
57
|
+
if (unit) throw new Error(`Invalid --threshold "${raw}": CLS is a score, with no unit and no relative part, such as CLS=0.1`);
|
|
58
|
+
return { metric, absolute: value };
|
|
59
|
+
}
|
|
60
|
+
if (unit === "%") return { metric, relative: value / 100 };
|
|
61
|
+
if (unit === "ms") return { metric, absolute: value };
|
|
62
|
+
throw new Error(`Invalid --threshold "${raw}": give the unit, such as ${metric}=15% or ${metric}=200ms`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The default thresholds with the parts that --threshold changes
|
|
66
|
+
export function thresholdsWith(overrides) {
|
|
67
|
+
const thresholds = Object.fromEntries(Object.entries(DEFAULT_THRESHOLDS).map(([metric, parts]) => [metric, { ...parts }]));
|
|
68
|
+
for (const { metric, ...part } of overrides) Object.assign(thresholds[metric], part);
|
|
69
|
+
return thresholds;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const round = (n) => Math.round(n * 10000) / 10000;
|
|
73
|
+
|
|
74
|
+
// The value of a metric in a page: the median of its runs, with their range when there are two or
|
|
75
|
+
// more, or its one result, as runs.js picks it
|
|
76
|
+
function measured(payload, metric) {
|
|
77
|
+
const summary = payload.runs?.summary?.[metric];
|
|
78
|
+
if (summary) {
|
|
79
|
+
const runs = summary.values.filter((value) => value !== null).length;
|
|
80
|
+
return { value: summary.median, ...(runs >= 2 ? { range: { min: summary.min, max: summary.max } } : {}) };
|
|
81
|
+
}
|
|
82
|
+
const result = payload.results?.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
|
|
83
|
+
return result ? { value: result.value } : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A regression must beat the threshold of its metric and, when the baseline has several runs, the
|
|
87
|
+
// worst of them: a median inside the range of the baseline is noise the baseline already had.
|
|
88
|
+
function verdict(base, current, threshold) {
|
|
89
|
+
if (current.value - base.value > threshold && (!base.range || current.value > base.range.max)) return "regressed";
|
|
90
|
+
if (base.value - current.value > threshold && (!base.range || current.value < base.range.min)) return "improved";
|
|
91
|
+
return "unchanged";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compareMetrics(base, current, thresholds) {
|
|
95
|
+
const metrics = {};
|
|
96
|
+
for (const metric of METRICS) {
|
|
97
|
+
const b = measured(base, metric);
|
|
98
|
+
const c = measured(current, metric);
|
|
99
|
+
if (!b && !c) continue;
|
|
100
|
+
if (!b || !c) {
|
|
101
|
+
metrics[metric] = { ...(b ? { baseline: b.value } : { current: c.value }), verdict: "not measured" };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const { relative = 0, absolute } = thresholds[metric];
|
|
105
|
+
const threshold = round(Math.max(relative * b.value, absolute));
|
|
106
|
+
const delta = c.value - b.value;
|
|
107
|
+
metrics[metric] = {
|
|
108
|
+
baseline: b.value,
|
|
109
|
+
current: c.value,
|
|
110
|
+
delta: round(delta),
|
|
111
|
+
deltaPercent: b.value === 0 ? null : Math.round((delta / b.value) * 1000) / 10,
|
|
112
|
+
threshold,
|
|
113
|
+
verdict: verdict(b, c, threshold),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return metrics;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Two errors are the same when they share a code or, without one, when their messages differ only in
|
|
120
|
+
// their figures: "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
|
|
121
|
+
const keyOf = (error) => `${error.id}\u0000${error.code ?? error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
|
|
122
|
+
|
|
123
|
+
function compareErrors(base, current) {
|
|
124
|
+
const byKey = (payload) => new Map(blockingErrors(payload.results ?? []).map((error) => [keyOf(error), error]));
|
|
125
|
+
const b = byKey(base);
|
|
126
|
+
const c = byKey(current);
|
|
127
|
+
return {
|
|
128
|
+
new: [...c].filter(([key]) => !b.has(key)).map(([, error]) => error),
|
|
129
|
+
fixed: [...b].filter(([key]) => !c.has(key)).map(([, error]) => error),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const describeWork = (conditions) => (conditions.snippet ? `snippet ${conditions.snippet}` : `workflow ${conditions.workflow}`);
|
|
134
|
+
const describeLoad = (throttling) => (throttling ? `with ${describeThrottling(throttling)}` : "without throttling");
|
|
135
|
+
const runCount = (payload) => payload.runs?.count ?? 1;
|
|
136
|
+
const pathOf = (url) => {
|
|
137
|
+
try {
|
|
138
|
+
const { pathname, search } = new URL(url);
|
|
139
|
+
return pathname + search;
|
|
140
|
+
} catch {
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
|
|
146
|
+
function warningsOf(base, current) {
|
|
147
|
+
const warnings = [];
|
|
148
|
+
const b = base.conditions;
|
|
149
|
+
const c = current.conditions;
|
|
150
|
+
if (!b) {
|
|
151
|
+
warnings.push("the baseline comes from a CLI without conditions (1.3.0 or earlier); it cannot say how it was measured");
|
|
152
|
+
} else if (c) {
|
|
153
|
+
if (describeWork(b) !== describeWork(c)) warnings.push(`the baseline was measured with ${describeWork(b)}, and this run with ${describeWork(c)}`);
|
|
154
|
+
if (b.viewport !== c.viewport) warnings.push(`the baseline was measured on ${b.viewport}, and this run on ${c.viewport}`);
|
|
155
|
+
if (b.waitMs !== c.waitMs) warnings.push(`the baseline waited ${b.waitMs} ms after the load, and this run ${c.waitMs} ms`);
|
|
156
|
+
}
|
|
157
|
+
if (describeLoad(base.throttling) !== describeLoad(current.throttling)) {
|
|
158
|
+
warnings.push(`the baseline was measured ${describeLoad(base.throttling)}, and this run ${describeLoad(current.throttling)}`);
|
|
159
|
+
}
|
|
160
|
+
if (runCount(base) < 2) warnings.push("the baseline has 1 run, so only the thresholds apply; measure both with --runs");
|
|
161
|
+
else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
|
|
162
|
+
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
163
|
+
warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
|
|
164
|
+
}
|
|
165
|
+
// Another host is the review app of a pull request against production: the same page
|
|
166
|
+
if (pathOf(base.url) !== pathOf(current.url)) warnings.push(`the baseline measured ${pathOf(base.url)} and this run ${pathOf(current.url)}`);
|
|
167
|
+
return warnings;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
|
|
171
|
+
// much to trust its values, in the header of the reports.
|
|
172
|
+
export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
173
|
+
return {
|
|
174
|
+
file,
|
|
175
|
+
url: base.url,
|
|
176
|
+
runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
|
|
177
|
+
metrics: compareMetrics(base, current, thresholds),
|
|
178
|
+
errors: compareErrors(base, current),
|
|
179
|
+
warnings: warningsOf(base, current),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A matrix against a baseline matrix: each combination with the one of the same id, and the ids that
|
|
184
|
+
// only one side has
|
|
185
|
+
export function compareMatrix(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
186
|
+
const baseById = new Map(base.combinations.map((c) => [c.id, c]));
|
|
187
|
+
const currentIds = new Set(current.combinations.map((c) => c.id));
|
|
188
|
+
const combinations = {};
|
|
189
|
+
for (const c of current.combinations) {
|
|
190
|
+
if (baseById.has(c.id)) combinations[c.id] = compareRun(baseById.get(c.id), c, { file, thresholds });
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
root: {
|
|
194
|
+
file,
|
|
195
|
+
added: current.combinations.filter((c) => !baseById.has(c.id)).map((c) => c.id),
|
|
196
|
+
missing: base.combinations.filter((c) => !currentIds.has(c.id)).map((c) => c.id),
|
|
197
|
+
warnings: [],
|
|
198
|
+
},
|
|
199
|
+
combinations,
|
|
200
|
+
};
|
|
201
|
+
}
|