webperf-snippets 1.4.0 → 1.6.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 (64) hide show
  1. package/README.md +17 -9
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/INP.js +6 -4
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  5. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  6. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  7. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  8. package/snippets/Interaction/Input-Latency-Breakdown.js +5 -2
  9. package/snippets/Interaction/Interactions.js +10 -1
  10. package/snippets/Interaction/Scroll-Performance.js +2 -0
  11. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  12. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  13. package/snippets/Loading/Cache-Strategy-Analysis.js +37 -23
  14. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  15. package/snippets/Loading/Compression-Audit.js +3 -0
  16. package/snippets/Loading/Content-Visibility.js +2 -2
  17. package/snippets/Loading/Critical-CSS-Detection.js +8 -1
  18. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  19. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  20. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  21. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  22. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  23. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  24. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  25. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  26. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  27. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  28. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  29. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  30. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  31. package/snippets/Loading/Resource-Hints.js +7 -1
  32. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  33. package/snippets/Loading/Script-Loading.js +6 -2
  34. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  35. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  36. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  37. package/snippets/Loading/TTFB-Resources.js +1 -1
  38. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  39. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  40. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  41. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  42. package/snippets/Media/Image-Element-Audit.js +16 -13
  43. package/snippets/Media/Oversized-Images.js +4 -0
  44. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  45. package/snippets/Media/Video-Element-Audit.js +13 -2
  46. package/snippets/SCHEMA.md +431 -6
  47. package/snippets/_view/fields.js +37 -0
  48. package/snippets/_view/format.js +39 -0
  49. package/snippets/_view/index.js +304 -0
  50. package/snippets/_view/locales/en.js +39 -0
  51. package/snippets/_view/package.json +1 -0
  52. package/src/baseline.js +30 -6
  53. package/src/bin.js +40 -37
  54. package/src/blocking.js +1 -1
  55. package/src/budgets.js +73 -0
  56. package/src/mcp/documents.js +41 -21
  57. package/src/mcp/server.js +1 -1
  58. package/src/reporters/budgets-view.js +38 -0
  59. package/src/reporters/human.js +42 -20
  60. package/src/reporters/markdown.js +53 -1
  61. package/src/reporters/summary-view.js +12 -0
  62. package/src/runner.js +3 -3
  63. package/src/view.js +11 -0
  64. package/src/workflows/interaction.js +1 -0
@@ -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 }) => {
package/src/mcp/server.js CHANGED
@@ -350,7 +350,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
350
350
  {
351
351
  title: "Read the tracking snippets",
352
352
  description:
353
- "Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet answers with status error, which is a result, not a failure.",
353
+ "Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again.",
354
354
  inputSchema: z.object({ sessionId: SessionId }),
355
355
  outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
356
356
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -0,0 +1,38 @@
1
+ // The budgets as the reports show them, each report with its own format of a value: a row per budget,
2
+ // passed or not, with by how much it was exceeded or why its metric was not measured.
3
+
4
+ const difference = (budget) => {
5
+ const over = budget.value - budget.budget;
6
+ return budget.unit === "ms" ? Math.round(over) : Math.round(over * 10000) / 10000;
7
+ };
8
+
9
+ export function budgetRows(budgets, format) {
10
+ return budgets.map((b) => ({
11
+ metric: b.metric,
12
+ value: b.value == null ? "·" : format(b.value, b.unit),
13
+ budget: format(b.budget, b.unit),
14
+ passed: b.verdict === "passed",
15
+ verdict:
16
+ b.verdict === "exceeded"
17
+ ? `exceeded by ${format(difference(b), b.unit)}`
18
+ : b.verdict === "not measured"
19
+ ? `not measured${b.note ? `: ${b.note}` : ""}`
20
+ : "passed",
21
+ }));
22
+ }
23
+
24
+ // The failed budgets of a matrix, each named with its combination, or null when no combination had
25
+ // budgets to check
26
+ export function matrixBudgetFailures(combinations, format) {
27
+ const checked = combinations.filter((c) => Array.isArray(c.budgets) && c.budgets.length > 0);
28
+ if (checked.length === 0) return null;
29
+ return checked.flatMap((c) =>
30
+ c.budgets
31
+ .filter((b) => b.verdict !== "passed")
32
+ .map((b) =>
33
+ b.verdict === "exceeded"
34
+ ? `${c.id}: ${b.metric} ${format(b.value, b.unit)} exceeds budget ${format(b.budget, b.unit)}`
35
+ : `${c.id}: ${b.metric} was not measured${b.note ? ` (${b.note})` : ""}`
36
+ )
37
+ );
38
+ }
@@ -1,9 +1,12 @@
1
1
  import { styleText } from "node:util";
2
+ import { describeResult } from "../view.js";
3
+ import { isSummaryResult, summaryNumbers } from "./summary-view.js";
2
4
  import { fieldDataView } from "../crux.js";
3
5
  import { describeThrottling } from "../throttling.js";
4
6
  import { describeRuns } from "../runs.js";
5
7
  import { matrixView } from "./matrix-view.js";
6
8
  import { baselineView } from "./baseline-view.js";
9
+ import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
7
10
 
8
11
  const RATING_ICON = {
9
12
  good: "🟢",
@@ -102,12 +105,12 @@ function renderAuditResult(r, verbose) {
102
105
  const items = r.items ?? [];
103
106
  if (items.length > 0 && (!isGreen || verbose)) {
104
107
  lines.push("");
105
- const shown = items.slice(0, MAX_ITEMS);
106
- for (const item of shown) {
107
- const name = item.shortName ?? item.resource ?? item.url ?? item.src ?? item.selector ?? item.filename ?? item.target ?? "";
108
- const tag = item.type ?? item.tag ?? item.strategy ?? item.media ?? item.kind ?? "";
109
- const timing = item.responseEndMs != null ? `${item.responseEndMs}ms` : item.durationMs != null ? `${item.durationMs}ms` : "";
110
- const cols = [tag, name, item.detail, timing].filter(Boolean).join(" ");
108
+ // The cells of each row as the view gives them: the file of a URL, the values in their units
109
+ const { rows, constants } = describeResult(r);
110
+ // The values that are the same in every row, once above them, as in the extension
111
+ if (constants.length) lines.push(` ${styleText("dim", constants.map((c) => `${c.label} ${c.text}`).join(" · "))}`);
112
+ for (const row of rows.slice(0, MAX_ITEMS)) {
113
+ const cols = row.cells.map((c) => c.text).filter(Boolean).join(" ");
111
114
  if (cols) lines.push(` ${styleText("dim", `· ${cols}`)}`);
112
115
  }
113
116
  if (items.length > MAX_ITEMS) {
@@ -125,10 +128,7 @@ function renderAuditResult(r, verbose) {
125
128
  // A result with a count and details but no value and no issues, such as the long tasks: the count,
126
129
  // then the first numbers of its details.
127
130
  function renderSummaryResult(r) {
128
- const numbers = Object.entries(r.details ?? {})
129
- .filter(([, value]) => typeof value === "number")
130
- .slice(0, 3)
131
- .map(([key, value]) => `${key} ${value}`);
131
+ const numbers = summaryNumbers(r);
132
132
  const summary = [r.count !== undefined ? String(r.count) : null, ...numbers].filter(Boolean).join(" · ");
133
133
  return ` ${RATING_ICON[r.rating] ?? "·"} ${pad(r.id, 16)} ${styleText("dim", summary)}`;
134
134
  }
@@ -142,6 +142,11 @@ function renderResult(r, verbose) {
142
142
  return ` ${styleText("red", "✗")} ${pad(r.id, 16)} ${styleText("dim", r.error)}`;
143
143
  }
144
144
 
145
+ // A tracking snippet with nothing recorded, such as INP without interactions, measured nothing
146
+ if (r.status === "tracking") {
147
+ return ` ${styleText("dim", "·")} ${pad(r.id, 16)} ${styleText("dim", `not measured: ${r.message ?? "nothing recorded"}`)}`;
148
+ }
149
+
145
150
  if (r.script === "Fonts-Preloaded-Loaded-and-used-above-the-fold") {
146
151
  return renderFontsResult(r);
147
152
  }
@@ -150,7 +155,7 @@ function renderResult(r, verbose) {
150
155
  return renderAuditResult(r, verbose);
151
156
  }
152
157
 
153
- if (r.value === undefined && (r.count !== undefined || r.details)) {
158
+ if (isSummaryResult(r)) {
154
159
  return renderSummaryResult(r);
155
160
  }
156
161
 
@@ -163,12 +168,10 @@ function renderResult(r, verbose) {
163
168
 
164
169
  if (r.metric === "LCP" && r.details?.subParts) {
165
170
  const sp = r.details.subParts;
166
- const rows = [
167
- ["TTFB", sp.ttfb],
168
- ["Resource Load Delay", sp.resourceLoadDelay],
169
- ["Resource Load Time", sp.resourceLoadTime],
170
- ["Element Render Delay", sp.elementRenderDelay],
171
- ];
171
+ const view = describeResult(r);
172
+ // The phases with the labels of the view, and the slowest one by its label, not its key
173
+ const phases = view.details.find((d) => d.key === "subParts" && d.format === "phases");
174
+ const rows = (phases?.parts ?? []).map((p) => [p.label, sp[p.key]]);
172
175
  out += "\n";
173
176
  out += rows
174
177
  .map(([name, info]) => {
@@ -180,8 +183,9 @@ function renderResult(r, verbose) {
180
183
  : `${row}\n ${styleText("dim", `${info.waiting}ms waiting for the server, ${info.download}ms downloading`)}`;
181
184
  })
182
185
  .join("\n");
183
- if (r.details.slowestPhase) {
184
- out += `\n ${styleText("blue", "→ Slowest:")} ${r.details.slowestPhase}`;
186
+ const slowest = view.details.find((d) => d.key === "slowestPhase");
187
+ if (slowest) {
188
+ out += `\n ${styleText("blue", "→ Slowest:")} ${slowest.text}`;
185
189
  }
186
190
  }
187
191
 
@@ -218,6 +222,16 @@ function renderRuns(runs) {
218
222
 
219
223
  const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", "not measured": "dim", new: "cyan" };
220
224
 
225
+ // Every budget the run was given, passed or not, so the report says which one decided the exit code
226
+ function renderBudgets(budgets) {
227
+ const lines = ["", styleText("bold", "Budgets")];
228
+ for (const row of budgetRows(budgets, formatValue)) {
229
+ const mark = row.passed ? styleText("green", "✓") : styleText("red", "✗");
230
+ lines.push(` ${mark} ${pad(row.metric, 6)}${pad(row.value, 10)}${pad(`budget ${row.budget}`, 18)}${styleText(row.passed ? "green" : "red", row.verdict)}`);
231
+ }
232
+ return lines;
233
+ }
234
+
221
235
  // The comparison with --baseline, after the report: its warnings first, since they say how far to
222
236
  // trust the table, then a row per metric and the errors that appeared or went away
223
237
  function renderBaseline(comparison) {
@@ -235,7 +249,7 @@ function renderBaseline(comparison) {
235
249
  return lines;
236
250
  }
237
251
 
238
- export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, baseline, title = `WebPerf Snippets — ${url}` }) {
252
+ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, baseline, budgets, title = `WebPerf Snippets — ${url}` }) {
239
253
  const lines = [];
240
254
  lines.push(styleText(["bold"], title));
241
255
  lines.push(styleText("dim", `Navigated in ${navMs}ms`));
@@ -252,6 +266,7 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
252
266
  lines.push(styleText("yellow", "Page errors during run:"));
253
267
  for (const err of pageErrors) lines.push(` ${styleText("dim", err)}`);
254
268
  }
269
+ if (budgets?.length) lines.push(...renderBudgets(budgets));
255
270
  if (baseline) lines.push(...renderBaseline(baseline));
256
271
  return lines.join("\n");
257
272
  }
@@ -289,6 +304,13 @@ export function reportMatrixHuman(report) {
289
304
  for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
290
305
  }
291
306
 
307
+ const budgetFailures = matrixBudgetFailures(report.combinations, formatValue);
308
+ if (budgetFailures) {
309
+ lines.push("", styleText("bold", "Budgets"));
310
+ for (const f of budgetFailures) lines.push(` ${styleText("red", "✗")} ${f}`);
311
+ if (!budgetFailures.length) lines.push(` ${styleText("green", "✓ Every budget passed")}`);
312
+ }
313
+
292
314
  if (comparison) {
293
315
  lines.push("", styleText("bold", `Compared with ${comparison.file}`));
294
316
  for (const warning of comparison.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
@@ -1,8 +1,11 @@
1
1
  import { fieldDataView } from "../crux.js";
2
+ import { describeResult } from "../view.js";
3
+ import { isSummaryResult, summaryNumbers } from "./summary-view.js";
2
4
  import { describeThrottling } from "../throttling.js";
3
5
  import { describeRuns } from "../runs.js";
4
6
  import { matrixView } from "./matrix-view.js";
5
7
  import { baselineView } from "./baseline-view.js";
8
+ import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
6
9
 
7
10
  const RATING_ICON = {
8
11
  good: "✅",
@@ -39,10 +42,22 @@ function renderSkippedRow(r) {
39
42
  return `| ${r.id} | ⏭ skipped | ${r.reason} |`;
40
43
  }
41
44
 
45
+ // A tracking snippet with nothing recorded, such as INP without interactions, measured nothing
46
+ function renderNotMeasuredRow(r) {
47
+ return `| ${r.id} | · not measured | ${r.message ?? "nothing recorded"} |`;
48
+ }
49
+
50
+ // A result with a count and no value: its count, and its first numbers as the human report gives them
51
+ function renderSummaryRow(r) {
52
+ return `| ${r.id} | · ${r.count ?? ""} | ${summaryNumbers(r).join(" · ")} |`;
53
+ }
54
+
42
55
  function renderRow(r) {
43
56
  if (r.status === "skipped") return renderSkippedRow(r);
44
57
  if (r.status === "error") return renderErrorRow(r);
58
+ if (r.status === "tracking") return renderNotMeasuredRow(r);
45
59
  if (Array.isArray(r.issues)) return renderAuditRow(r);
60
+ if (isSummaryResult(r)) return renderSummaryRow(r);
46
61
  return renderMetricRow(r);
47
62
  }
48
63
 
@@ -53,11 +68,36 @@ function sectionHeader(results) {
53
68
  return ["| Metric | Value | Status |", "|--------|-------|--------|"];
54
69
  }
55
70
 
71
+ // Text from the page in a list item: the characters that markdown reads as emphasis or code, escaped
72
+ const md = (text) => String(text).replace(/[\\`*_[\]]/g, "\\$&");
73
+
74
+ // A field of the view as a list item, with the labels and formats of the extension: a nested table is
75
+ // a line per row, and the fields of an object are items under it
76
+ function detailLines(field, indent = "") {
77
+ if (field.format === "table") {
78
+ return [`${indent}- **${md(field.label)}**:`, ...field.rows.map((row) => `${indent} - ${row.cells.map((c) => md(c.text)).filter(Boolean).join(" · ")}`)];
79
+ }
80
+ if (field.format === "fields") {
81
+ return [`${indent}- **${md(field.label)}**:`, ...field.fields.flatMap((f) => detailLines(f, `${indent} `))];
82
+ }
83
+ return [`${indent}- **${md(field.label)}**: ${md(field.text)}`];
84
+ }
85
+
86
+ // The details of each result, folded under its id, so the comment of a pull request stays short
87
+ function renderDetails(results) {
88
+ return results.flatMap((r) => {
89
+ const { details } = describeResult(r);
90
+ if (details.length === 0) return [];
91
+ return ["", `<details><summary>${r.id}</summary>`, "", ...details.flatMap((f) => detailLines(f)), "", "</details>"];
92
+ });
93
+ }
94
+
56
95
  function renderSection(title, results) {
57
96
  return [
58
97
  `### ${title}`,
59
98
  ...sectionHeader(results),
60
99
  ...results.map(renderRow),
100
+ ...renderDetails(results),
61
101
  ].join("\n");
62
102
  }
63
103
 
@@ -104,7 +144,13 @@ function renderBaseline(comparison) {
104
144
  }
105
145
 
106
146
  // With a null title, the report has no heading, as inside the details block of a matrix
107
- export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, baseline, title = `WebPerf Results — ${url}` }) {
147
+ // Every budget the run was given, passed or not: the comment of a pull request says which one failed
148
+ function renderBudgets(budgets) {
149
+ const rows = budgetRows(budgets, formatValue).map((row) => `| ${row.metric} | ${row.value} | ${row.budget} | ${row.passed ? "✅" : "❌"} ${row.verdict} |`);
150
+ return ["", "### Budgets", "| Metric | Value | Budget | Verdict |", "|--------|-------|--------|---------|", ...rows];
151
+ }
152
+
153
+ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, baseline, budgets, title = `WebPerf Results — ${url}` }) {
108
154
  const lines = [];
109
155
  if (title) lines.push(`## ${title}`);
110
156
  lines.push(`> Navigated in ${navMs}ms`);
@@ -140,6 +186,7 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
140
186
  for (const err of pageErrors) lines.push(`- ${err}`);
141
187
  }
142
188
 
189
+ if (budgets?.length) lines.push(...renderBudgets(budgets));
143
190
  if (baseline) lines.push(...renderBaseline(baseline));
144
191
 
145
192
  return lines.join("\n");
@@ -172,6 +219,11 @@ export function reportMatrixMarkdown(report) {
172
219
  lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
173
220
  }
174
221
 
222
+ const budgetFailures = matrixBudgetFailures(report.combinations, formatValue);
223
+ if (budgetFailures) {
224
+ lines.push("", "### Budgets", ...(budgetFailures.length ? budgetFailures.map((f) => `- ❌ ${f}`) : ["✅ Every budget passed"]));
225
+ }
226
+
175
227
  if (comparison) {
176
228
  lines.push("", `### Compared with \`${comparison.file}\``);
177
229
  if (comparison.warnings.length) lines.push(...comparison.warnings.flatMap((w, i) => [...(i ? [">"] : []), `> ⚠️ ${w}`]), "");
@@ -0,0 +1,12 @@
1
+ import { describeResult } from "../view.js";
2
+
3
+ // A result with a count and details but no value and no issues, such as the long tasks: whether it is
4
+ // one, and its first numbers with the labels and formats of the view, which both reports show
5
+ export const isSummaryResult = (r) => r.value === undefined && !Array.isArray(r.issues) && (r.count !== undefined || r.details !== undefined);
6
+
7
+ export function summaryNumbers(r) {
8
+ return describeResult(r)
9
+ .details.filter((d) => typeof d.value === "number")
10
+ .slice(0, 3)
11
+ .map((d) => `${d.label} ${d.text}`);
12
+ }
package/src/runner.js CHANGED
@@ -16,8 +16,8 @@ export const VIEWPORT_PRESETS = {
16
16
  const DEFAULT_NAV_TIMEOUT = 30000;
17
17
 
18
18
  // On a loaded machine the browser can deliver the timing of a click later than the frames `settle`
19
- // waits for. After a click or a key press, a tracking snippet that has no data yet is read again,
20
- // every POLL_MS, until it has some or LATE_ENTRIES_MS have passed.
19
+ // waits for. After a click or a key press, a tracking snippet that has no data yet, and so is still
20
+ // tracking, is read again every POLL_MS until it has some or LATE_ENTRIES_MS have passed.
21
21
  const LATE_ENTRIES_MS = 2000;
22
22
  const POLL_MS = 100;
23
23
 
@@ -79,7 +79,7 @@ export async function collectTrackingData(page, result, hasInput) {
79
79
  const read = () => page.evaluate(`(async () => await ${result.getDataFn}())()`);
80
80
  let data = await read();
81
81
  const deadline = Date.now() + LATE_ENTRIES_MS;
82
- while (hasInput && data?.status === "error" && Date.now() < deadline) {
82
+ while (hasInput && data?.status === "tracking" && Date.now() < deadline) {
83
83
  await page.waitForTimeout(POLL_MS);
84
84
  data = await read();
85
85
  }
package/src/view.js ADDED
@@ -0,0 +1,11 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { SNIPPET_DIRS } from "./load-snippet.js";
5
+
6
+ // The view of a result (snippets/_view/): the labels, formats and order the extension shows, so the
7
+ // reports say the same. It comes with the snippets, in the package or at the root of the repository.
8
+ const dir = SNIPPET_DIRS.find((d) => existsSync(join(d, "_view", "index.js")));
9
+ if (!dir) throw new Error("The view of the results is missing: snippets/_view/index.js");
10
+
11
+ export const { describeResult } = await import(pathToFileURL(join(dir, "_view", "index.js")).href);
@@ -24,6 +24,7 @@ export const interactionWorkflow = {
24
24
  { id: "scroll", path: "Interaction/Scroll-Performance" },
25
25
  { id: "layout-shifts", path: "Interaction/Layout-Shift-Loading-and-Interaction" },
26
26
  { id: "forced-layout", path: "Interaction/Forced-Synchronous-Layout" },
27
+ { id: "INP", path: "CoreWebVitals/INP", needsInput: true },
27
28
  { id: "interactions", path: "Interaction/Interactions", needsInput: true },
28
29
  { id: "input-latency", path: "Interaction/Input-Latency-Breakdown", needsInput: true },
29
30
  ],