webperf-snippets 1.3.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 +65 -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 +68 -7
- package/src/blocking.js +3 -1
- package/src/conditions.js +7 -0
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +46 -6
- package/src/reporters/matrix-view.js +8 -2
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,7 +158,7 @@ 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
|
|
|
@@ -232,10 +238,62 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
|
232
238
|
|
|
233
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.
|
|
234
240
|
|
|
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 `
|
|
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.
|
|
236
242
|
|
|
237
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.
|
|
238
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
|
+
|
|
239
297
|
CI gating:
|
|
240
298
|
|
|
241
299
|
```bash
|
|
@@ -261,6 +319,9 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
261
319
|
| `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
|
|
262
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. |
|
|
263
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`. |
|
|
264
325
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
265
326
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
266
327
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -300,8 +361,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
300
361
|
| Code | Meaning |
|
|
301
362
|
| ---- | --------------------------------------------- |
|
|
302
363
|
| `0` | All checks passed. |
|
|
303
|
-
| `1` | Budget violation, a snippet errored,
|
|
304
|
-
| `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). |
|
|
305
366
|
|
|
306
367
|
## MCP server
|
|
307
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
|
+
}
|
package/src/bin.js
CHANGED
|
@@ -18,6 +18,9 @@ import { parseOwnDomains } from "./own-domains.js";
|
|
|
18
18
|
import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
|
|
19
19
|
import { loadMatrix } from "./matrix.js";
|
|
20
20
|
import { blockingErrors } from "./blocking.js";
|
|
21
|
+
import { measurementConditions } from "./conditions.js";
|
|
22
|
+
import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
|
|
23
|
+
import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
const USAGE = `webperf-snippets <url> [options]
|
|
@@ -63,6 +66,13 @@ Options:
|
|
|
63
66
|
variant it lists, and report a table of the medians
|
|
64
67
|
--median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
|
|
65
68
|
navMs (default: the first of LCP, FCP, TTFB, navMs measured)
|
|
69
|
+
--baseline <file> Compare the page, or each combination of a matrix, with the --json
|
|
70
|
+
output of an earlier run: which metrics got worse or better beyond
|
|
71
|
+
the noise, and which errors are new or fixed. It only informs,
|
|
72
|
+
unless --fail-on-regression
|
|
73
|
+
--threshold <metric>=<v> The threshold of a metric for --baseline, such as LCP=15%,
|
|
74
|
+
LCP=200ms or CLS=0.1. Repeat it for more metrics
|
|
75
|
+
--fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
|
|
66
76
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
67
77
|
Also read from the CRUX_API_KEY environment variable
|
|
68
78
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -86,6 +96,9 @@ Examples:
|
|
|
86
96
|
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
|
|
87
97
|
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
88
98
|
npx webperf-snippets --matrix matrix.json --runs 3 --budget-lcp 2500
|
|
99
|
+
npx webperf-snippets https://example.com --runs 5 --json > baseline.json
|
|
100
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
|
|
101
|
+
npx webperf-snippets --matrix matrix.json --baseline last-week.json
|
|
89
102
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
90
103
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
91
104
|
npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
|
|
@@ -166,6 +179,9 @@ async function main() {
|
|
|
166
179
|
matrix: { type: "string" },
|
|
167
180
|
runs: { type: "string" },
|
|
168
181
|
"median-by": { type: "string" },
|
|
182
|
+
baseline: { type: "string" },
|
|
183
|
+
threshold: { type: "string", multiple: true },
|
|
184
|
+
"fail-on-regression": { type: "boolean" },
|
|
169
185
|
"crux-key": { type: "string" },
|
|
170
186
|
"report-to": { type: "string" },
|
|
171
187
|
"api-key": { type: "string" },
|
|
@@ -242,6 +258,23 @@ async function main() {
|
|
|
242
258
|
}
|
|
243
259
|
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
244
260
|
|
|
261
|
+
// The baseline is read and checked before the browser opens: a wrong file fails at once
|
|
262
|
+
let baseline;
|
|
263
|
+
let thresholds;
|
|
264
|
+
if (values.baseline) {
|
|
265
|
+
try {
|
|
266
|
+
baseline = loadBaseline(values.baseline);
|
|
267
|
+
thresholds = thresholdsWith((values.threshold ?? []).map(parseThreshold));
|
|
268
|
+
} catch (err) {
|
|
269
|
+
fail(err.message);
|
|
270
|
+
}
|
|
271
|
+
if (!matrixPath && baseline.kind === "matrix") fail(`${values.baseline} is the baseline of a matrix: compare it with --matrix`);
|
|
272
|
+
if (matrixPath && baseline.kind === "page") fail(`${values.baseline} is the baseline of a page: compare it with a URL, not --matrix`);
|
|
273
|
+
} else {
|
|
274
|
+
if (values.threshold) fail("--threshold needs --baseline");
|
|
275
|
+
if (values["fail-on-regression"]) fail("--fail-on-regression needs --baseline");
|
|
276
|
+
}
|
|
277
|
+
|
|
245
278
|
// What each page load runs, a snippet or a workflow, under the conditions of the load
|
|
246
279
|
const items = values.snippet ? buildSnippetItem(values) : null;
|
|
247
280
|
const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
|
|
@@ -250,6 +283,9 @@ async function main() {
|
|
|
250
283
|
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
251
284
|
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
252
285
|
};
|
|
286
|
+
// What the JSON records of how each page was measured, for a later run to compare with
|
|
287
|
+
const conditionsFor = (viewport) =>
|
|
288
|
+
measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
253
289
|
|
|
254
290
|
if (matrixPath) {
|
|
255
291
|
let matrix;
|
|
@@ -258,7 +294,7 @@ async function main() {
|
|
|
258
294
|
} catch (err) {
|
|
259
295
|
fail(err.message);
|
|
260
296
|
}
|
|
261
|
-
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl });
|
|
297
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds });
|
|
262
298
|
return;
|
|
263
299
|
}
|
|
264
300
|
|
|
@@ -271,11 +307,17 @@ async function main() {
|
|
|
271
307
|
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
272
308
|
payload = { ...median.payload, runs: median.runs };
|
|
273
309
|
}
|
|
310
|
+
payload.conditions = conditionsFor(viewportName);
|
|
274
311
|
|
|
275
312
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
276
313
|
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
277
314
|
if (crux) payload.crux = crux;
|
|
278
315
|
|
|
316
|
+
if (baseline) {
|
|
317
|
+
payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
|
|
318
|
+
for (const warning of payload.baseline.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
319
|
+
}
|
|
320
|
+
|
|
279
321
|
let output;
|
|
280
322
|
if (values.markdown) {
|
|
281
323
|
output = reportMarkdown(payload);
|
|
@@ -315,6 +357,12 @@ async function main() {
|
|
|
315
357
|
}
|
|
316
358
|
process.exit(1);
|
|
317
359
|
}
|
|
360
|
+
// Without --fail-on-regression the comparison only informs, and the exit code is the one of today
|
|
361
|
+
const regressions = values["fail-on-regression"] ? describeRegressions(payload.baseline) : [];
|
|
362
|
+
if (regressions.length > 0) {
|
|
363
|
+
for (const r of regressions) process.stderr.write(`Regression against ${baseline.file}: ${r}\n`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
318
366
|
process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
|
|
319
367
|
}
|
|
320
368
|
|
|
@@ -337,7 +385,7 @@ async function measureRuns(measure, count, medianBy, progress) {
|
|
|
337
385
|
|
|
338
386
|
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
339
387
|
// 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 }) {
|
|
388
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds }) {
|
|
341
389
|
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
342
390
|
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
343
391
|
|
|
@@ -351,8 +399,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
351
399
|
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
352
400
|
combinations.push(
|
|
353
401
|
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 } }
|
|
402
|
+
? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c.viewport) }
|
|
403
|
+
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c.viewport) }
|
|
356
404
|
);
|
|
357
405
|
}
|
|
358
406
|
|
|
@@ -369,8 +417,18 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
369
417
|
}
|
|
370
418
|
}
|
|
371
419
|
|
|
372
|
-
|
|
373
|
-
|
|
420
|
+
// With --baseline, each combination is compared with the one of the same id in the baseline
|
|
421
|
+
let comparison;
|
|
422
|
+
if (baseline) {
|
|
423
|
+
const { root, combinations: byId } = compareMatrix(baseline.data, { combinations }, { file: baseline.file, thresholds });
|
|
424
|
+
for (const c of combinations) if (byId[c.id]) c.baseline = byId[c.id];
|
|
425
|
+
comparison = root;
|
|
426
|
+
for (const warning of matrixComparisonView(root, combinations).warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const withBaseline = comparison ? { baseline: comparison } : {};
|
|
430
|
+
const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose, ...withBaseline };
|
|
431
|
+
const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, ...withBaseline, combinations }) : reportMatrixHuman(report);
|
|
374
432
|
process.stdout.write(output + "\n");
|
|
375
433
|
|
|
376
434
|
// One report for each combination, with the body of one page and the device, variant and runs of
|
|
@@ -411,7 +469,10 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
411
469
|
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
412
470
|
}
|
|
413
471
|
const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
|
|
414
|
-
|
|
472
|
+
// Without --fail-on-regression the comparison only informs
|
|
473
|
+
const regressions = values["fail-on-regression"] ? describeMatrixRegressions(combinations) : [];
|
|
474
|
+
for (const r of regressions) process.stderr.write(`Regression against ${baseline.file} in ${r}\n`);
|
|
475
|
+
process.exit(violations.length > 0 || failed || regressions.length > 0 ? 1 : 0);
|
|
415
476
|
}
|
|
416
477
|
|
|
417
478
|
main().catch((err) => {
|
package/src/blocking.js
CHANGED
|
@@ -6,7 +6,9 @@ export function blockingErrors(results) {
|
|
|
6
6
|
for (const r of results) {
|
|
7
7
|
if (r.status === "error") errors.push({ id: r.id, message: r.error });
|
|
8
8
|
if (r.reason || !Array.isArray(r.issues)) continue;
|
|
9
|
-
for (const issue of r.issues)
|
|
9
|
+
for (const issue of r.issues) {
|
|
10
|
+
if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}) });
|
|
11
|
+
}
|
|
10
12
|
}
|
|
11
13
|
return errors;
|
|
12
14
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { cliVersion } from "./report-to.js";
|
|
2
|
+
|
|
3
|
+
// How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
|
|
4
|
+
// both were measured the same way. A snippet is named by its path, so an alias and its path match.
|
|
5
|
+
export function measurementConditions({ viewport, workflow, snippet, waitMs }) {
|
|
6
|
+
return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion };
|
|
7
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The comparison with a baseline as the human and markdown reports draw it: one row per metric with
|
|
2
|
+
// its two values, the change and the verdict, and why an unchanged one did not count. The values stay
|
|
3
|
+
// numbers with their unit, for each report to format.
|
|
4
|
+
|
|
5
|
+
const MINUS = "−";
|
|
6
|
+
const signed = (n, text) => (n > 0 ? `+${text}` : n < 0 ? `${MINUS}${text}` : text);
|
|
7
|
+
const arrow = (delta) => (delta > 0 ? "▲" : delta < 0 ? "▼" : "=");
|
|
8
|
+
const unitOf = (metric) => (metric === "CLS" ? "score" : "ms");
|
|
9
|
+
|
|
10
|
+
// "▲ +380ms (+18%)", "▼ −90ms (−15%)", "▼ −0.0020"
|
|
11
|
+
function describeChange(metric, { delta, deltaPercent }) {
|
|
12
|
+
if (metric === "CLS") return `${arrow(delta)} ${signed(delta, Math.abs(delta).toFixed(4))}`;
|
|
13
|
+
const ms = signed(delta, `${Math.abs(Math.round(delta))}ms`);
|
|
14
|
+
const percent = deltaPercent == null ? "" : ` (${signed(deltaPercent, `${Math.abs(Math.round(deltaPercent))}%`)})`;
|
|
15
|
+
return `${arrow(delta)} ${ms}${percent}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// An unchanged metric that moved stayed under its threshold, or beat it inside the range of the runs
|
|
19
|
+
// of the baseline: noise the baseline already had
|
|
20
|
+
function noteOf(metric, m) {
|
|
21
|
+
if (m.verdict !== "unchanged" || m.delta === 0) return null;
|
|
22
|
+
if (Math.abs(m.delta) > m.threshold) return "inside the range of the baseline";
|
|
23
|
+
return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function baselineView({ file, url, runs, metrics, errors, warnings }) {
|
|
27
|
+
const summary = `${url}, ${runs.count === 1 ? "1 run" : `${runs.count} runs, median by ${runs.medianBy}`}`;
|
|
28
|
+
const rows = Object.entries(metrics).map(([metric, m]) => ({
|
|
29
|
+
metric,
|
|
30
|
+
unit: unitOf(metric),
|
|
31
|
+
baseline: m.baseline,
|
|
32
|
+
current: m.current,
|
|
33
|
+
change: m.verdict === "not measured" ? null : describeChange(metric, m),
|
|
34
|
+
verdict: m.verdict,
|
|
35
|
+
note: m.verdict === "not measured" ? null : noteOf(metric, m),
|
|
36
|
+
}));
|
|
37
|
+
return { file, summary, rows, newErrors: errors.new, fixedErrors: errors.fixed, warnings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What got worse, one line each, for --fail-on-regression: the metrics that regressed and the errors
|
|
41
|
+
// that only this run has
|
|
42
|
+
export function describeRegressions({ metrics, errors }) {
|
|
43
|
+
return [
|
|
44
|
+
...Object.entries(metrics)
|
|
45
|
+
.filter(([, m]) => m.verdict === "regressed")
|
|
46
|
+
.map(([metric, m]) => `${metric} ${describeChange(metric, m)}`),
|
|
47
|
+
...errors.new.map((e) => `new error ${e.id}: ${e.message}`),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The change of a metric in a cell of the matrix table: the figure only when it counts, so the table
|
|
52
|
+
// reads at a glance. "▲ +380ms", "▼ −400ms", "=" for unchanged, "·" when one side did not measure it.
|
|
53
|
+
export function changeCell(metric, m) {
|
|
54
|
+
if (m.verdict === "not measured") return { text: "·", verdict: m.verdict };
|
|
55
|
+
if (m.verdict === "unchanged") return { text: "=", verdict: m.verdict };
|
|
56
|
+
const amount = metric === "CLS" ? Math.abs(m.delta).toFixed(4) : `${Math.abs(Math.round(m.delta))}ms`;
|
|
57
|
+
return { text: `${arrow(m.delta)} ${signed(m.delta, amount)}`, verdict: m.verdict };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The comparison of a matrix below its table. A warning that every compared combination has, such as
|
|
61
|
+
// a baseline of one run, shows once; the others name their combinations.
|
|
62
|
+
export function matrixComparisonView(root, combinations) {
|
|
63
|
+
const compared = combinations.filter((c) => c.baseline);
|
|
64
|
+
const idsByWarning = new Map();
|
|
65
|
+
for (const c of compared) {
|
|
66
|
+
for (const warning of c.baseline.warnings) idsByWarning.set(warning, [...(idsByWarning.get(warning) ?? []), c.id]);
|
|
67
|
+
}
|
|
68
|
+
const warnings = [...idsByWarning].map(([warning, ids]) => (ids.length === compared.length ? warning : `${ids.join(", ")}: ${warning}`));
|
|
69
|
+
const errorsOf = (kind) => compared.flatMap((c) => c.baseline.errors[kind].map((e) => `${c.id}: ${e.id}: ${e.message}`));
|
|
70
|
+
return {
|
|
71
|
+
file: root.file,
|
|
72
|
+
warnings: [...root.warnings, ...warnings],
|
|
73
|
+
newErrors: errorsOf("new"),
|
|
74
|
+
fixedErrors: errorsOf("fixed"),
|
|
75
|
+
added: root.added,
|
|
76
|
+
missing: root.missing,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The regressions of every combination, each named by its combination
|
|
81
|
+
export function describeMatrixRegressions(combinations) {
|
|
82
|
+
return combinations.filter((c) => c.baseline).flatMap((c) => describeRegressions(c.baseline).map((r) => `${c.id}: ${r}`));
|
|
83
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fieldDataView } from "../crux.js";
|
|
|
3
3
|
import { describeThrottling } from "../throttling.js";
|
|
4
4
|
import { describeRuns } from "../runs.js";
|
|
5
5
|
import { matrixView } from "./matrix-view.js";
|
|
6
|
+
import { baselineView } from "./baseline-view.js";
|
|
6
7
|
|
|
7
8
|
const RATING_ICON = {
|
|
8
9
|
good: "🟢",
|
|
@@ -215,7 +216,26 @@ function renderRuns(runs) {
|
|
|
215
216
|
return lines;
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", "not measured": "dim", new: "cyan" };
|
|
220
|
+
|
|
221
|
+
// The comparison with --baseline, after the report: its warnings first, since they say how far to
|
|
222
|
+
// trust the table, then a row per metric and the errors that appeared or went away
|
|
223
|
+
function renderBaseline(comparison) {
|
|
224
|
+
const view = baselineView(comparison);
|
|
225
|
+
const lines = ["", styleText("bold", `Compared with ${view.file} (${view.summary})`)];
|
|
226
|
+
for (const warning of view.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
227
|
+
lines.push(styleText("dim", ` ${pad("Metric", 9)}${pad("Baseline", 11)}${pad("Now", 10)}Change`));
|
|
228
|
+
for (const row of view.rows) {
|
|
229
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
230
|
+
const verdict = row.note ? `${row.verdict} (${row.note})` : row.verdict;
|
|
231
|
+
lines.push(` ${pad(row.metric, 9)}${pad(value(row.baseline), 11)}${pad(value(row.current), 10)}${pad(row.change ?? "", 20)}${styleText(VERDICT_STYLE[row.verdict], verdict)}`);
|
|
232
|
+
}
|
|
233
|
+
for (const e of view.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e.id}: ${e.message}`);
|
|
234
|
+
for (const e of view.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e.id}: ${e.message}`);
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, baseline, title = `WebPerf Snippets — ${url}` }) {
|
|
219
239
|
const lines = [];
|
|
220
240
|
lines.push(styleText(["bold"], title));
|
|
221
241
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
@@ -232,13 +252,14 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
232
252
|
lines.push(styleText("yellow", "Page errors during run:"));
|
|
233
253
|
for (const err of pageErrors) lines.push(` ${styleText("dim", err)}`);
|
|
234
254
|
}
|
|
255
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
235
256
|
return lines.join("\n");
|
|
236
257
|
}
|
|
237
258
|
|
|
238
259
|
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
239
260
|
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
240
261
|
export function reportMatrixHuman(report) {
|
|
241
|
-
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
262
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
242
263
|
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
243
264
|
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
244
265
|
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
@@ -246,14 +267,19 @@ export function reportMatrixHuman(report) {
|
|
|
246
267
|
const lines = [];
|
|
247
268
|
lines.push(styleText(["bold"], `WebPerf Snippets — ${file} (${summary})`));
|
|
248
269
|
lines.push("");
|
|
249
|
-
|
|
250
|
-
|
|
270
|
+
// With --baseline, a change column follows each metric
|
|
271
|
+
const headings = metrics.map((m) => pad(m, 14) + (comparison ? pad("Δ", 12) : "")).join("");
|
|
272
|
+
lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${headings.trimEnd()}`));
|
|
273
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
251
274
|
const row = ` ${identity(columns.map(([key]) => c[key]))}`;
|
|
252
275
|
if (!cells) {
|
|
253
276
|
lines.push(row + styleText("red", "✗ every run failed"));
|
|
254
277
|
continue;
|
|
255
278
|
}
|
|
256
|
-
const drawn = cells.map((cell) =>
|
|
279
|
+
const drawn = cells.map((cell, i) => {
|
|
280
|
+
const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14);
|
|
281
|
+
return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
|
|
282
|
+
});
|
|
257
283
|
lines.push(row + drawn.join("").trimEnd());
|
|
258
284
|
}
|
|
259
285
|
|
|
@@ -263,6 +289,18 @@ export function reportMatrixHuman(report) {
|
|
|
263
289
|
for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
|
|
264
290
|
}
|
|
265
291
|
|
|
292
|
+
if (comparison) {
|
|
293
|
+
lines.push("", styleText("bold", `Compared with ${comparison.file}`));
|
|
294
|
+
for (const warning of comparison.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
295
|
+
for (const e of comparison.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e}`);
|
|
296
|
+
for (const e of comparison.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e}`);
|
|
297
|
+
if (comparison.added.length) lines.push(` Added: ${comparison.added.join(", ")}`);
|
|
298
|
+
if (comparison.missing.length) lines.push(` Missing: ${comparison.missing.join(", ")}`);
|
|
299
|
+
if (!comparison.warnings.length && !comparison.newErrors.length && !comparison.fixedErrors.length && !comparison.added.length && !comparison.missing.length) {
|
|
300
|
+
lines.push(styleText("dim", " The same combinations, no new or fixed errors, and no warnings"));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
266
304
|
if (report.verbose) {
|
|
267
305
|
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
268
306
|
lines.push("", reportHuman({ ...c, verbose: true, title: `${c.id} — ${c.url}` }));
|
|
@@ -2,6 +2,7 @@ import { fieldDataView } from "../crux.js";
|
|
|
2
2
|
import { describeThrottling } from "../throttling.js";
|
|
3
3
|
import { describeRuns } from "../runs.js";
|
|
4
4
|
import { matrixView } from "./matrix-view.js";
|
|
5
|
+
import { baselineView } from "./baseline-view.js";
|
|
5
6
|
|
|
6
7
|
const RATING_ICON = {
|
|
7
8
|
good: "✅",
|
|
@@ -83,8 +84,27 @@ function renderRuns(runs) {
|
|
|
83
84
|
return lines;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
const VERDICT_ICON = { regressed: "❌", improved: "✅", unchanged: "➖", "not measured": "·" };
|
|
88
|
+
|
|
89
|
+
// The comparison with --baseline. The warnings go in the comment itself: whoever reads the pull
|
|
90
|
+
// request does not see the stderr of the CI.
|
|
91
|
+
function renderBaseline(comparison) {
|
|
92
|
+
const view = baselineView(comparison);
|
|
93
|
+
const lines = ["", `### Compared with \`${view.file}\``, `> ${view.summary}`];
|
|
94
|
+
for (const warning of view.warnings) lines.push(">", `> ⚠️ ${warning}`);
|
|
95
|
+
lines.push("", "| Metric | Baseline | Now | Change | Verdict |", "|--------|----------|-----|--------|---------|");
|
|
96
|
+
for (const row of view.rows) {
|
|
97
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
98
|
+
const verdict = `${VERDICT_ICON[row.verdict]} ${row.verdict}${row.note ? ` (${row.note})` : ""}`;
|
|
99
|
+
lines.push(`| ${row.metric} | ${value(row.baseline)} | ${value(row.current)} | ${row.change ?? "·"} | ${verdict} |`);
|
|
100
|
+
}
|
|
101
|
+
if (view.newErrors.length) lines.push("", "#### New errors", ...view.newErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
102
|
+
if (view.fixedErrors.length) lines.push("", "#### Fixed errors", ...view.fixedErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
// 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}` }) {
|
|
107
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, baseline, title = `WebPerf Results — ${url}` }) {
|
|
88
108
|
const lines = [];
|
|
89
109
|
if (title) lines.push(`## ${title}`);
|
|
90
110
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
@@ -120,6 +140,8 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
120
140
|
for (const err of pageErrors) lines.push(`- ${err}`);
|
|
121
141
|
}
|
|
122
142
|
|
|
143
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
144
|
+
|
|
123
145
|
return lines.join("\n");
|
|
124
146
|
}
|
|
125
147
|
|
|
@@ -127,17 +149,22 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
127
149
|
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
128
150
|
// comment readable.
|
|
129
151
|
export function reportMatrixMarkdown(report) {
|
|
130
|
-
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
131
|
-
|
|
152
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
153
|
+
// With --baseline, a change column follows each metric
|
|
154
|
+
const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
|
|
155
|
+
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
|
|
132
156
|
const row = (parts) => `|${parts.map((part) => (part ? ` ${part} ` : " ")).join("|")}|`;
|
|
133
157
|
|
|
134
158
|
const lines = [`## WebPerf Results — ${file}`, `> ${summary}`, ""];
|
|
135
159
|
lines.push(row(headings));
|
|
136
160
|
lines.push(`|${headings.map((h) => "-".repeat(h.length + 2)).join("|")}|`);
|
|
137
|
-
for (const { combination: c, cells } of rows) {
|
|
161
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
138
162
|
const drawn = cells
|
|
139
|
-
? cells.
|
|
140
|
-
|
|
163
|
+
? cells.flatMap((cell, i) => {
|
|
164
|
+
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
|
|
165
|
+
return changes ? [value, changes[i].text] : [value];
|
|
166
|
+
})
|
|
167
|
+
: ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
|
|
141
168
|
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
142
169
|
}
|
|
143
170
|
|
|
@@ -145,6 +172,19 @@ export function reportMatrixMarkdown(report) {
|
|
|
145
172
|
lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
if (comparison) {
|
|
176
|
+
lines.push("", `### Compared with \`${comparison.file}\``);
|
|
177
|
+
if (comparison.warnings.length) lines.push(...comparison.warnings.flatMap((w, i) => [...(i ? [">"] : []), `> ⚠️ ${w}`]), "");
|
|
178
|
+
const ids = (list) => list.map((id) => `\`${id}\``).join(", ");
|
|
179
|
+
const items = [
|
|
180
|
+
...comparison.newErrors.map((e) => `- New error: ${e}`),
|
|
181
|
+
...comparison.fixedErrors.map((e) => `- Fixed: ${e}`),
|
|
182
|
+
...(comparison.added.length ? [`- Added: ${ids(comparison.added)}`] : []),
|
|
183
|
+
...(comparison.missing.length ? [`- Missing: ${ids(comparison.missing)}`] : []),
|
|
184
|
+
];
|
|
185
|
+
lines.push(...(items.length ? items : ["The same combinations, no new or fixed errors."]));
|
|
186
|
+
}
|
|
187
|
+
|
|
148
188
|
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
149
189
|
lines.push("", `<details><summary>${c.id}</summary>`, "", reportMarkdown({ ...c, title: null }), "", "</details>");
|
|
150
190
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { rate } from "../ratings.js";
|
|
3
3
|
import { blockingErrors } from "../blocking.js";
|
|
4
|
+
import { changeCell, matrixComparisonView } from "./baseline-view.js";
|
|
4
5
|
|
|
5
6
|
// The order of the metric columns of the matrix table
|
|
6
7
|
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
7
8
|
|
|
8
9
|
// What the human and markdown reports of a matrix show, so they only differ in how they draw it:
|
|
9
10
|
// 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
|
-
|
|
11
|
+
// the errors that decide the exit code. With --baseline, each row also has the change of each metric
|
|
12
|
+
// ("new" for a combination the baseline did not have), and the comparison goes below the table.
|
|
13
|
+
export function matrixView({ matrix, runs, combinations, baseline }) {
|
|
12
14
|
const measured = combinations.filter((c) => !c.error);
|
|
13
15
|
const medianBys = [...new Set(measured.map((c) => c.runs.medianBy))];
|
|
14
16
|
const metrics = METRICS.filter((metric) => measured.some((c) => c.runs.summary[metric]));
|
|
@@ -22,6 +24,9 @@ export function matrixView({ matrix, runs, combinations }) {
|
|
|
22
24
|
metrics,
|
|
23
25
|
rows: combinations.map((c) => ({
|
|
24
26
|
combination: c,
|
|
27
|
+
...(baseline
|
|
28
|
+
? { changes: metrics.map((metric) => (!c.baseline ? { text: "new", verdict: "new" } : c.baseline.metrics[metric] ? changeCell(metric, c.baseline.metrics[metric]) : { text: "·", verdict: "not measured" })) }
|
|
29
|
+
: {}),
|
|
25
30
|
cells: c.error
|
|
26
31
|
? null
|
|
27
32
|
: metrics.map((metric) => {
|
|
@@ -29,6 +34,7 @@ export function matrixView({ matrix, runs, combinations }) {
|
|
|
29
34
|
return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
|
|
30
35
|
}),
|
|
31
36
|
})),
|
|
37
|
+
...(baseline ? { comparison: matrixComparisonView(baseline, combinations) } : {}),
|
|
32
38
|
errors: combinations.flatMap((c) =>
|
|
33
39
|
c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
|
|
34
40
|
),
|