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
@@ -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
  ),