webperf-snippets 1.5.0 → 1.7.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 (33) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/INP.js +6 -4
  4. package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
  5. package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
  6. package/snippets/Interaction/Interactions.js +8 -1
  7. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
  8. package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
  9. package/snippets/Loading/Content-Visibility.js +36 -56
  10. package/snippets/Loading/Critical-CSS-Detection.js +2 -1
  11. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
  12. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
  13. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
  14. package/snippets/Media/Image-Element-Audit.js +37 -1
  15. package/snippets/Media/Oversized-Images.js +33 -5
  16. package/snippets/SCHEMA.md +11 -6
  17. package/snippets/_view/fields.js +36 -0
  18. package/snippets/_view/format.js +39 -0
  19. package/snippets/_view/index.js +304 -0
  20. package/snippets/_view/locales/en.js +39 -0
  21. package/snippets/_view/package.json +1 -0
  22. package/src/bin.js +46 -39
  23. package/src/budgets.js +73 -0
  24. package/src/matrix.js +3 -10
  25. package/src/mcp/server.js +15 -7
  26. package/src/reporters/budgets-view.js +38 -0
  27. package/src/reporters/human.js +42 -20
  28. package/src/reporters/markdown.js +53 -1
  29. package/src/reporters/summary-view.js +12 -0
  30. package/src/runner.js +3 -3
  31. package/src/url.js +8 -0
  32. package/src/view.js +11 -0
  33. package/src/workflows/interaction.js +1 -0
@@ -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/url.js ADDED
@@ -0,0 +1,8 @@
1
+ // The pages the CLI measures: http and https only, local servers included. It loads a URL in a browser
2
+ // and returns what the snippets read from it, so a file:// URL would hand the files of the machine to
3
+ // whoever reads the result, an agent among them; javascript:, data: and chrome:// are not pages to
4
+ // measure either. The command line, the matrix file and the MCP server share this rule.
5
+ export function isHttpUrl(value) {
6
+ if (typeof value !== "string" || !URL.canParse(value)) return false;
7
+ return ["http:", "https:"].includes(new URL(value).protocol);
8
+ }
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
  ],