webperf-snippets 1.3.0 → 1.5.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 +67 -6
- package/package.json +3 -2
- package/snippets/CoreWebVitals/CLS.js +3 -2
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
- package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
- 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 +2 -0
- package/snippets/Interaction/Interactions.js +2 -0
- 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 +12 -0
- 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 +6 -0
- 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 +425 -3
- package/src/baseline.js +225 -0
- package/src/bin.js +68 -7
- package/src/blocking.js +3 -1
- package/src/conditions.js +7 -0
- package/src/mcp/documents.js +41 -21
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +43 -5
- package/src/reporters/markdown.js +46 -6
- package/src/reporters/matrix-view.js +8 -2
package/src/reporters/human.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fieldDataView } from "../crux.js";
|
|
|
3
3
|
import { describeThrottling } from "../throttling.js";
|
|
4
4
|
import { describeRuns } from "../runs.js";
|
|
5
5
|
import { matrixView } from "./matrix-view.js";
|
|
6
|
+
import { baselineView } from "./baseline-view.js";
|
|
6
7
|
|
|
7
8
|
const RATING_ICON = {
|
|
8
9
|
good: "🟢",
|
|
@@ -215,7 +216,26 @@ function renderRuns(runs) {
|
|
|
215
216
|
return lines;
|
|
216
217
|
}
|
|
217
218
|
|
|
218
|
-
|
|
219
|
+
const VERDICT_STYLE = { regressed: "red", improved: "green", unchanged: "dim", "not measured": "dim", new: "cyan" };
|
|
220
|
+
|
|
221
|
+
// The comparison with --baseline, after the report: its warnings first, since they say how far to
|
|
222
|
+
// trust the table, then a row per metric and the errors that appeared or went away
|
|
223
|
+
function renderBaseline(comparison) {
|
|
224
|
+
const view = baselineView(comparison);
|
|
225
|
+
const lines = ["", styleText("bold", `Compared with ${view.file} (${view.summary})`)];
|
|
226
|
+
for (const warning of view.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
227
|
+
lines.push(styleText("dim", ` ${pad("Metric", 9)}${pad("Baseline", 11)}${pad("Now", 10)}Change`));
|
|
228
|
+
for (const row of view.rows) {
|
|
229
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
230
|
+
const verdict = row.note ? `${row.verdict} (${row.note})` : row.verdict;
|
|
231
|
+
lines.push(` ${pad(row.metric, 9)}${pad(value(row.baseline), 11)}${pad(value(row.current), 10)}${pad(row.change ?? "", 20)}${styleText(VERDICT_STYLE[row.verdict], verdict)}`);
|
|
232
|
+
}
|
|
233
|
+
for (const e of view.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e.id}: ${e.message}`);
|
|
234
|
+
for (const e of view.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e.id}: ${e.message}`);
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, baseline, title = `WebPerf Snippets — ${url}` }) {
|
|
219
239
|
const lines = [];
|
|
220
240
|
lines.push(styleText(["bold"], title));
|
|
221
241
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
@@ -232,13 +252,14 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
232
252
|
lines.push(styleText("yellow", "Page errors during run:"));
|
|
233
253
|
for (const err of pageErrors) lines.push(` ${styleText("dim", err)}`);
|
|
234
254
|
}
|
|
255
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
235
256
|
return lines.join("\n");
|
|
236
257
|
}
|
|
237
258
|
|
|
238
259
|
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
239
260
|
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
240
261
|
export function reportMatrixHuman(report) {
|
|
241
|
-
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
262
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
242
263
|
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
243
264
|
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
244
265
|
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
@@ -246,14 +267,19 @@ export function reportMatrixHuman(report) {
|
|
|
246
267
|
const lines = [];
|
|
247
268
|
lines.push(styleText(["bold"], `WebPerf Snippets — ${file} (${summary})`));
|
|
248
269
|
lines.push("");
|
|
249
|
-
|
|
250
|
-
|
|
270
|
+
// With --baseline, a change column follows each metric
|
|
271
|
+
const headings = metrics.map((m) => pad(m, 14) + (comparison ? pad("Δ", 12) : "")).join("");
|
|
272
|
+
lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${headings.trimEnd()}`));
|
|
273
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
251
274
|
const row = ` ${identity(columns.map(([key]) => c[key]))}`;
|
|
252
275
|
if (!cells) {
|
|
253
276
|
lines.push(row + styleText("red", "✗ every run failed"));
|
|
254
277
|
continue;
|
|
255
278
|
}
|
|
256
|
-
const drawn = cells.map((cell) =>
|
|
279
|
+
const drawn = cells.map((cell, i) => {
|
|
280
|
+
const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14);
|
|
281
|
+
return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
|
|
282
|
+
});
|
|
257
283
|
lines.push(row + drawn.join("").trimEnd());
|
|
258
284
|
}
|
|
259
285
|
|
|
@@ -263,6 +289,18 @@ export function reportMatrixHuman(report) {
|
|
|
263
289
|
for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
|
|
264
290
|
}
|
|
265
291
|
|
|
292
|
+
if (comparison) {
|
|
293
|
+
lines.push("", styleText("bold", `Compared with ${comparison.file}`));
|
|
294
|
+
for (const warning of comparison.warnings) lines.push(` ${styleText("yellow", `Warning: ${warning}`)}`);
|
|
295
|
+
for (const e of comparison.newErrors) lines.push(` ${styleText("red", "+ new error")} ${e}`);
|
|
296
|
+
for (const e of comparison.fixedErrors) lines.push(` ${styleText("green", "− fixed")} ${e}`);
|
|
297
|
+
if (comparison.added.length) lines.push(` Added: ${comparison.added.join(", ")}`);
|
|
298
|
+
if (comparison.missing.length) lines.push(` Missing: ${comparison.missing.join(", ")}`);
|
|
299
|
+
if (!comparison.warnings.length && !comparison.newErrors.length && !comparison.fixedErrors.length && !comparison.added.length && !comparison.missing.length) {
|
|
300
|
+
lines.push(styleText("dim", " The same combinations, no new or fixed errors, and no warnings"));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
266
304
|
if (report.verbose) {
|
|
267
305
|
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
268
306
|
lines.push("", reportHuman({ ...c, verbose: true, title: `${c.id} — ${c.url}` }));
|
|
@@ -2,6 +2,7 @@ import { fieldDataView } from "../crux.js";
|
|
|
2
2
|
import { describeThrottling } from "../throttling.js";
|
|
3
3
|
import { describeRuns } from "../runs.js";
|
|
4
4
|
import { matrixView } from "./matrix-view.js";
|
|
5
|
+
import { baselineView } from "./baseline-view.js";
|
|
5
6
|
|
|
6
7
|
const RATING_ICON = {
|
|
7
8
|
good: "✅",
|
|
@@ -83,8 +84,27 @@ function renderRuns(runs) {
|
|
|
83
84
|
return lines;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
const VERDICT_ICON = { regressed: "❌", improved: "✅", unchanged: "➖", "not measured": "·" };
|
|
88
|
+
|
|
89
|
+
// The comparison with --baseline. The warnings go in the comment itself: whoever reads the pull
|
|
90
|
+
// request does not see the stderr of the CI.
|
|
91
|
+
function renderBaseline(comparison) {
|
|
92
|
+
const view = baselineView(comparison);
|
|
93
|
+
const lines = ["", `### Compared with \`${view.file}\``, `> ${view.summary}`];
|
|
94
|
+
for (const warning of view.warnings) lines.push(">", `> ⚠️ ${warning}`);
|
|
95
|
+
lines.push("", "| Metric | Baseline | Now | Change | Verdict |", "|--------|----------|-----|--------|---------|");
|
|
96
|
+
for (const row of view.rows) {
|
|
97
|
+
const value = (v) => (v == null ? "·" : formatValue(v, row.unit));
|
|
98
|
+
const verdict = `${VERDICT_ICON[row.verdict]} ${row.verdict}${row.note ? ` (${row.note})` : ""}`;
|
|
99
|
+
lines.push(`| ${row.metric} | ${value(row.baseline)} | ${value(row.current)} | ${row.change ?? "·"} | ${verdict} |`);
|
|
100
|
+
}
|
|
101
|
+
if (view.newErrors.length) lines.push("", "#### New errors", ...view.newErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
102
|
+
if (view.fixedErrors.length) lines.push("", "#### Fixed errors", ...view.fixedErrors.map((e) => `- \`${e.id}\`: ${e.message}`));
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
// With a null title, the report has no heading, as inside the details block of a matrix
|
|
87
|
-
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, title = `WebPerf Results — ${url}` }) {
|
|
107
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, baseline, title = `WebPerf Results — ${url}` }) {
|
|
88
108
|
const lines = [];
|
|
89
109
|
if (title) lines.push(`## ${title}`);
|
|
90
110
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
@@ -120,6 +140,8 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
120
140
|
for (const err of pageErrors) lines.push(`- ${err}`);
|
|
121
141
|
}
|
|
122
142
|
|
|
143
|
+
if (baseline) lines.push(...renderBaseline(baseline));
|
|
144
|
+
|
|
123
145
|
return lines.join("\n");
|
|
124
146
|
}
|
|
125
147
|
|
|
@@ -127,17 +149,22 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
127
149
|
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
128
150
|
// comment readable.
|
|
129
151
|
export function reportMatrixMarkdown(report) {
|
|
130
|
-
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
131
|
-
|
|
152
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
153
|
+
// With --baseline, a change column follows each metric
|
|
154
|
+
const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
|
|
155
|
+
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
|
|
132
156
|
const row = (parts) => `|${parts.map((part) => (part ? ` ${part} ` : " ")).join("|")}|`;
|
|
133
157
|
|
|
134
158
|
const lines = [`## WebPerf Results — ${file}`, `> ${summary}`, ""];
|
|
135
159
|
lines.push(row(headings));
|
|
136
160
|
lines.push(`|${headings.map((h) => "-".repeat(h.length + 2)).join("|")}|`);
|
|
137
|
-
for (const { combination: c, cells } of rows) {
|
|
161
|
+
for (const { combination: c, cells, changes } of rows) {
|
|
138
162
|
const drawn = cells
|
|
139
|
-
? cells.
|
|
140
|
-
|
|
163
|
+
? cells.flatMap((cell, i) => {
|
|
164
|
+
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
|
|
165
|
+
return changes ? [value, changes[i].text] : [value];
|
|
166
|
+
})
|
|
167
|
+
: ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
|
|
141
168
|
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
142
169
|
}
|
|
143
170
|
|
|
@@ -145,6 +172,19 @@ export function reportMatrixMarkdown(report) {
|
|
|
145
172
|
lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
|
|
146
173
|
}
|
|
147
174
|
|
|
175
|
+
if (comparison) {
|
|
176
|
+
lines.push("", `### Compared with \`${comparison.file}\``);
|
|
177
|
+
if (comparison.warnings.length) lines.push(...comparison.warnings.flatMap((w, i) => [...(i ? [">"] : []), `> ⚠️ ${w}`]), "");
|
|
178
|
+
const ids = (list) => list.map((id) => `\`${id}\``).join(", ");
|
|
179
|
+
const items = [
|
|
180
|
+
...comparison.newErrors.map((e) => `- New error: ${e}`),
|
|
181
|
+
...comparison.fixedErrors.map((e) => `- Fixed: ${e}`),
|
|
182
|
+
...(comparison.added.length ? [`- Added: ${ids(comparison.added)}`] : []),
|
|
183
|
+
...(comparison.missing.length ? [`- Missing: ${ids(comparison.missing)}`] : []),
|
|
184
|
+
];
|
|
185
|
+
lines.push(...(items.length ? items : ["The same combinations, no new or fixed errors."]));
|
|
186
|
+
}
|
|
187
|
+
|
|
148
188
|
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
149
189
|
lines.push("", `<details><summary>${c.id}</summary>`, "", reportMarkdown({ ...c, title: null }), "", "</details>");
|
|
150
190
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { rate } from "../ratings.js";
|
|
3
3
|
import { blockingErrors } from "../blocking.js";
|
|
4
|
+
import { changeCell, matrixComparisonView } from "./baseline-view.js";
|
|
4
5
|
|
|
5
6
|
// The order of the metric columns of the matrix table
|
|
6
7
|
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
7
8
|
|
|
8
9
|
// What the human and markdown reports of a matrix show, so they only differ in how they draw it:
|
|
9
10
|
// the title, the metric columns, one row per combination with the rated median of each metric, and
|
|
10
|
-
// the errors that decide the exit code.
|
|
11
|
-
|
|
11
|
+
// the errors that decide the exit code. With --baseline, each row also has the change of each metric
|
|
12
|
+
// ("new" for a combination the baseline did not have), and the comparison goes below the table.
|
|
13
|
+
export function matrixView({ matrix, runs, combinations, baseline }) {
|
|
12
14
|
const measured = combinations.filter((c) => !c.error);
|
|
13
15
|
const medianBys = [...new Set(measured.map((c) => c.runs.medianBy))];
|
|
14
16
|
const metrics = METRICS.filter((metric) => measured.some((c) => c.runs.summary[metric]));
|
|
@@ -22,6 +24,9 @@ export function matrixView({ matrix, runs, combinations }) {
|
|
|
22
24
|
metrics,
|
|
23
25
|
rows: combinations.map((c) => ({
|
|
24
26
|
combination: c,
|
|
27
|
+
...(baseline
|
|
28
|
+
? { changes: metrics.map((metric) => (!c.baseline ? { text: "new", verdict: "new" } : c.baseline.metrics[metric] ? changeCell(metric, c.baseline.metrics[metric]) : { text: "·", verdict: "not measured" })) }
|
|
29
|
+
: {}),
|
|
25
30
|
cells: c.error
|
|
26
31
|
? null
|
|
27
32
|
: metrics.map((metric) => {
|
|
@@ -29,6 +34,7 @@ export function matrixView({ matrix, runs, combinations }) {
|
|
|
29
34
|
return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
|
|
30
35
|
}),
|
|
31
36
|
})),
|
|
37
|
+
...(baseline ? { comparison: matrixComparisonView(baseline, combinations) } : {}),
|
|
32
38
|
errors: combinations.flatMap((c) =>
|
|
33
39
|
c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
|
|
34
40
|
),
|