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.
- package/README.md +17 -9
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
- package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +5 -2
- package/snippets/Interaction/Interactions.js +10 -1
- package/snippets/Interaction/Scroll-Performance.js +2 -0
- package/snippets/Loading/Back-Forward-Cache.js +7 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
- package/snippets/Loading/Cache-Strategy-Analysis.js +37 -23
- package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
- package/snippets/Loading/Compression-Audit.js +3 -0
- package/snippets/Loading/Content-Visibility.js +2 -2
- package/snippets/Loading/Critical-CSS-Detection.js +8 -1
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
- package/snippets/Loading/Find-render-blocking-resources.js +1 -1
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
- package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
- package/snippets/Loading/Priority-Hints-Audit.js +10 -0
- package/snippets/Loading/Resource-Hints-Validation.js +3 -3
- package/snippets/Loading/Resource-Hints.js +7 -1
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
- package/snippets/Loading/Script-Loading.js +6 -2
- package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
- package/snippets/Loading/Service-Worker-Analysis.js +12 -2
- package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
- package/snippets/Loading/TTFB-Resources.js +1 -1
- package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
- package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
- package/snippets/Media/Image-Element-Audit.js +16 -13
- package/snippets/Media/Oversized-Images.js +4 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
- package/snippets/Media/Video-Element-Audit.js +13 -2
- package/snippets/SCHEMA.md +431 -6
- 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/baseline.js +30 -6
- package/src/bin.js +40 -37
- package/src/blocking.js +1 -1
- package/src/budgets.js +73 -0
- package/src/mcp/documents.js +41 -21
- 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/mcp/documents.js
CHANGED
|
@@ -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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
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
|
],
|