webperf-snippets 1.5.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.
- package/README.md +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/SCHEMA.md +7 -4
- package/snippets/_view/fields.js +37 -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 +40 -37
- package/src/budgets.js +73 -0
- package/src/mcp/server.js +1 -1
- 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/view.js +11 -0
- package/src/workflows/interaction.js +1 -0
package/src/reporters/human.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const cols =
|
|
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 =
|
|
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 (
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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/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
|
],
|