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 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.3.0" }
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 `runs`. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
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, or a step of the workflow reported an `error` issue. |
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.0",
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 lcpResEntry = performance
95
- .getEntriesByType("resource").filter((e) => !isOwnRequest(e))
96
- .find((e) => e.name === lcpEntry.url);
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
- lcpResEntry
106
- ? (lcpResEntry.requestStart || lcpResEntry.startTime) - activationStart
107
- : 0
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 lcpResEntrySync = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e))
282
- .find((e) => e.name === lcpEntry.url);
283
- const activationStartSync = navEntrySync.activationStart || 0;
284
- const ttfbSync = Math.max(0, navEntrySync.responseStart - activationStartSync);
285
- const lcpRequestStartSync = Math.max(ttfbSync,
286
- lcpResEntrySync ? (lcpResEntrySync.requestStart || lcpResEntrySync.startTime) - activationStartSync : 0
287
- );
288
- const lcpResponseEndSync = Math.max(lcpRequestStartSync,
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);
@@ -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
- const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose };
373
- const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, combinations }) : reportMatrixHuman(report);
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
- process.exit(violations.length > 0 || failed ? 1 : 0);
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) if (issue.severity === "error") errors.push({ id: r.id, message: issue.message });
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
+ }
@@ -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
- export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, title = `WebPerf Snippets — ${url}` }) {
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
- lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${metrics.map((m) => pad(m, 14)).join("").trimEnd()}`));
250
- for (const { combination: c, cells } of rows) {
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) => (cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14)));
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
- const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metrics];
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.map((cell) => (cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·"))
140
- : ["❌ every run failed", ...metrics.slice(1).map(() => "")];
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
- export function matrixView({ matrix, runs, combinations }) {
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
  ),