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.
- package/README.md +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Content-Visibility.js +36 -56
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
- package/snippets/Media/Image-Element-Audit.js +37 -1
- package/snippets/Media/Oversized-Images.js +33 -5
- package/snippets/SCHEMA.md +11 -6
- package/snippets/_view/fields.js +36 -0
- package/snippets/_view/format.js +39 -0
- package/snippets/_view/index.js +304 -0
- package/snippets/_view/locales/en.js +39 -0
- package/snippets/_view/package.json +1 -0
- package/src/bin.js +46 -39
- package/src/budgets.js +73 -0
- package/src/matrix.js +3 -10
- package/src/mcp/server.js +15 -7
- package/src/reporters/budgets-view.js +38 -0
- package/src/reporters/human.js +42 -20
- package/src/reporters/markdown.js +53 -1
- package/src/reporters/summary-view.js +12 -0
- package/src/runner.js +3 -3
- package/src/url.js +8 -0
- package/src/view.js +11 -0
- 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
|
-
|
|
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
|
|
20
|
-
// every POLL_MS
|
|
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 === "
|
|
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
|
],
|