webperf-snippets 1.3.0 → 1.5.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.
Files changed (56) hide show
  1. package/README.md +67 -6
  2. package/package.json +3 -2
  3. package/snippets/CoreWebVitals/CLS.js +3 -2
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  5. package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
  6. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  7. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  8. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  9. package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
  10. package/snippets/Interaction/Interactions.js +2 -0
  11. package/snippets/Interaction/Scroll-Performance.js +2 -0
  12. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  13. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  14. package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
  15. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  16. package/snippets/Loading/Compression-Audit.js +3 -0
  17. package/snippets/Loading/Content-Visibility.js +2 -2
  18. package/snippets/Loading/Critical-CSS-Detection.js +6 -0
  19. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  20. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  21. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  22. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  23. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  24. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  25. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  26. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  27. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  28. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  29. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  30. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  31. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  32. package/snippets/Loading/Resource-Hints.js +7 -1
  33. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  34. package/snippets/Loading/Script-Loading.js +6 -2
  35. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  36. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  37. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  38. package/snippets/Loading/TTFB-Resources.js +1 -1
  39. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  40. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  41. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  42. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  43. package/snippets/Media/Image-Element-Audit.js +16 -13
  44. package/snippets/Media/Oversized-Images.js +4 -0
  45. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  46. package/snippets/Media/Video-Element-Audit.js +13 -2
  47. package/snippets/SCHEMA.md +425 -3
  48. package/src/baseline.js +225 -0
  49. package/src/bin.js +68 -7
  50. package/src/blocking.js +3 -1
  51. package/src/conditions.js +7 -0
  52. package/src/mcp/documents.js +41 -21
  53. package/src/reporters/baseline-view.js +83 -0
  54. package/src/reporters/human.js +43 -5
  55. package/src/reporters/markdown.js +46 -6
  56. package/src/reporters/matrix-view.js +8 -2
@@ -0,0 +1,225 @@
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
+ // A resource of the page itself by its path and query, so the review app of a PR, on another host,
120
+ // matches main; any other target as it is
121
+ function targetOf(target, pageUrl) {
122
+ if (!target) return "";
123
+ try {
124
+ const url = new URL(target);
125
+ return url.origin === new URL(pageUrl).origin ? url.pathname + url.search : url.href;
126
+ } catch {
127
+ return target;
128
+ }
129
+ }
130
+
131
+ // Two errors are the same when they share a code and a target or, when a snippet has no codes on one
132
+ // side (a baseline of 1.4.0), when their messages differ only in their figures: "Render-blocking CSS
133
+ // (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
134
+ const byMessage = (error) => `${error.id}\u0000${error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
135
+
136
+ function compareErrors(base, current) {
137
+ const baseErrors = blockingErrors(base.results ?? []);
138
+ const currentErrors = blockingErrors(current.results ?? []);
139
+ // The snippets whose errors on one side all lack a code pair by message on both
140
+ const uncoded = (errors) => {
141
+ const ids = new Set(errors.map((e) => e.id));
142
+ for (const e of errors) if (e.code) ids.delete(e.id);
143
+ return ids;
144
+ };
145
+ const byMessageOnly = new Set([...uncoded(baseErrors), ...uncoded(currentErrors)]);
146
+ const keyOf = (error, pageUrl) =>
147
+ error.code && !byMessageOnly.has(error.id) ? `${error.id}\u0000${error.code}\u0000${targetOf(error.target, pageUrl)}` : byMessage(error);
148
+ const byKey = (errors, pageUrl) => new Map(errors.map((error) => [keyOf(error, pageUrl), error]));
149
+ const b = byKey(baseErrors, base.url);
150
+ const c = byKey(currentErrors, current.url);
151
+ return {
152
+ new: [...c].filter(([key]) => !b.has(key)).map(([, error]) => error),
153
+ fixed: [...b].filter(([key]) => !c.has(key)).map(([, error]) => error),
154
+ };
155
+ }
156
+
157
+ const describeWork = (conditions) => (conditions.snippet ? `snippet ${conditions.snippet}` : `workflow ${conditions.workflow}`);
158
+ const describeLoad = (throttling) => (throttling ? `with ${describeThrottling(throttling)}` : "without throttling");
159
+ const runCount = (payload) => payload.runs?.count ?? 1;
160
+ const pathOf = (url) => {
161
+ try {
162
+ const { pathname, search } = new URL(url);
163
+ return pathname + search;
164
+ } catch {
165
+ return url;
166
+ }
167
+ };
168
+
169
+ // What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
170
+ function warningsOf(base, current) {
171
+ const warnings = [];
172
+ const b = base.conditions;
173
+ const c = current.conditions;
174
+ if (!b) {
175
+ warnings.push("the baseline comes from a CLI without conditions (1.3.0 or earlier); it cannot say how it was measured");
176
+ } else if (c) {
177
+ if (describeWork(b) !== describeWork(c)) warnings.push(`the baseline was measured with ${describeWork(b)}, and this run with ${describeWork(c)}`);
178
+ if (b.viewport !== c.viewport) warnings.push(`the baseline was measured on ${b.viewport}, and this run on ${c.viewport}`);
179
+ if (b.waitMs !== c.waitMs) warnings.push(`the baseline waited ${b.waitMs} ms after the load, and this run ${c.waitMs} ms`);
180
+ }
181
+ if (describeLoad(base.throttling) !== describeLoad(current.throttling)) {
182
+ warnings.push(`the baseline was measured ${describeLoad(base.throttling)}, and this run ${describeLoad(current.throttling)}`);
183
+ }
184
+ if (runCount(base) < 2) warnings.push("the baseline has 1 run, so only the thresholds apply; measure both with --runs");
185
+ else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
186
+ if (b && c && b.cliVersion !== c.cliVersion) {
187
+ warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
188
+ }
189
+ // Another host is the review app of a pull request against production: the same page
190
+ if (pathOf(base.url) !== pathOf(current.url)) warnings.push(`the baseline measured ${pathOf(base.url)} and this run ${pathOf(current.url)}`);
191
+ return warnings;
192
+ }
193
+
194
+ // A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
195
+ // much to trust its values, in the header of the reports.
196
+ export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
197
+ return {
198
+ file,
199
+ url: base.url,
200
+ runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
201
+ metrics: compareMetrics(base, current, thresholds),
202
+ errors: compareErrors(base, current),
203
+ warnings: warningsOf(base, current),
204
+ };
205
+ }
206
+
207
+ // A matrix against a baseline matrix: each combination with the one of the same id, and the ids that
208
+ // only one side has
209
+ export function compareMatrix(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
210
+ const baseById = new Map(base.combinations.map((c) => [c.id, c]));
211
+ const currentIds = new Set(current.combinations.map((c) => c.id));
212
+ const combinations = {};
213
+ for (const c of current.combinations) {
214
+ if (baseById.has(c.id)) combinations[c.id] = compareRun(baseById.get(c.id), c, { file, thresholds });
215
+ }
216
+ return {
217
+ root: {
218
+ file,
219
+ added: current.combinations.filter((c) => !baseById.has(c.id)).map((c) => c.id),
220
+ missing: base.combinations.filter((c) => !currentIds.has(c.id)).map((c) => c.id),
221
+ warnings: [],
222
+ },
223
+ combinations,
224
+ };
225
+ }
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 } : {}), ...(issue.target ? { target: issue.target } : {}) });
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
+ }
@@ -19,30 +19,50 @@ function readDocument(relativePath) {
19
19
  }
20
20
 
21
21
  // Splits SCHEMA.md into the general contract and the section of each snippet ("#### LCP" up to the next
22
- // heading of that level or above). The general contract is everything outside "## Script-Specific Schemas".
22
+ // heading of that level or above). The general contract is everything outside "## Script-Specific Schemas"
23
+ // and outside the tables of "## Issue codes", whose rules it keeps. The table of a snippet ("### LCP"
24
+ // under Issue codes) goes with its section, or makes one when the snippet has none.
23
25
  export function splitSchema(markdown) {
24
26
  const lines = markdown.split("\n");
25
- const start = lines.findIndex((l) => l.startsWith("## Script-Specific Schemas"));
26
- const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
27
- const general = [...lines.slice(0, start), ...lines.slice(end)].join("\n");
27
+ const sectionOf = (heading) => {
28
+ const start = lines.findIndex((l) => l.startsWith(heading));
29
+ if (start === -1) return [lines.length, lines.length];
30
+ const next = lines.findIndex((l, i) => i > start && l.startsWith("## "));
31
+ return [start, next === -1 ? lines.length : next];
32
+ };
33
+ const [schemasStart, schemasEnd] = sectionOf("## Script-Specific Schemas");
34
+ const [codesStart, codesEnd] = sectionOf("## Issue codes");
35
+ const firstTable = lines.findIndex((l, i) => i > codesStart && i < codesEnd && l.startsWith("### "));
36
+ const tablesStart = firstTable === -1 ? codesEnd : firstTable;
37
+ const general = lines
38
+ .filter((_, i) => !(i >= schemasStart && i < schemasEnd) && !(i >= tablesStart && i < codesEnd))
39
+ .join("\n");
28
40
 
29
41
  const sections = new Map();
30
- let name = null;
31
- let current = [];
32
- const flush = () => name && sections.set(name, current.join("\n").trim());
33
- for (const line of lines.slice(start, end)) {
34
- if (line.startsWith("#### ")) {
35
- flush();
36
- name = line.slice(5).split(" ")[0];
37
- current = [line];
38
- } else if (/^#{1,3} /.test(line)) {
39
- flush();
40
- name = null;
41
- } else if (name) {
42
- current.push(line);
42
+ const collect = (from, to, level, add) => {
43
+ let name = null;
44
+ let current = [];
45
+ const flush = () => name && add(name, current.join("\n").trim());
46
+ for (const line of lines.slice(from, to)) {
47
+ if (line.startsWith(`${level} `)) {
48
+ flush();
49
+ name = line.slice(level.length + 1).split(" ")[0];
50
+ current = [line];
51
+ } else if (/^#{1,4} /.test(line)) {
52
+ flush();
53
+ name = null;
54
+ } else if (name) {
55
+ current.push(line);
56
+ }
43
57
  }
44
- }
45
- flush();
58
+ flush();
59
+ };
60
+ collect(schemasStart, schemasEnd, "####", (name, text) => sections.set(name, text));
61
+ collect(tablesStart, codesEnd, "###", (name, text) => {
62
+ // The last table runs into the rule that closes the section
63
+ const table = `Issue codes:\n${text.split("\n").slice(1).join("\n").replace(/\n+---$/, "")}`;
64
+ sections.set(name, sections.has(name) ? `${sections.get(name)}\n\n${table}` : `#### ${name}\n\n${table}`);
65
+ });
46
66
  return { general, sections };
47
67
  }
48
68
 
@@ -65,7 +85,7 @@ export function registerResources(server) {
65
85
  "webperf://schema",
66
86
  {
67
87
  title: "Snippet result contract",
68
- description: "What every snippet returns: the base shape, the statuses, the execution patterns and how to read a result. The fields of each snippet are in webperf://schema/{snippet}.",
88
+ description: "What every snippet returns: the base shape, the statuses, the execution patterns, how to read a result and the rules of the issue codes. The fields and the issue codes of each snippet are in webperf://schema/{snippet}.",
69
89
  mimeType: MARKDOWN,
70
90
  },
71
91
  async (uri) => markdown(uri, schemaSection())
@@ -80,7 +100,7 @@ export function registerResources(server) {
80
100
  }),
81
101
  {
82
102
  title: "Result of one snippet",
83
- description: "The fields one snippet returns, with an example, from snippets/SCHEMA.md.",
103
+ description: "The fields one snippet returns, with an example, and the codes of its issues, from snippets/SCHEMA.md.",
84
104
  mimeType: MARKDOWN,
85
105
  },
86
106
  async (uri, { snippet }) => {
@@ -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
+ }