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/baseline.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { blockingErrors } from "./blocking.js";
|
|
3
|
+
import { describeThrottling } from "./throttling.js";
|
|
4
|
+
|
|
5
|
+
// A measurement compared with an earlier one, its baseline: which metrics got worse or better beyond
|
|
6
|
+
// the noise of the lab, which errors appeared or went away, and what differs in how both were
|
|
7
|
+
// measured. Pure functions over the JSON the CLI prints with --json, so a baseline is any earlier
|
|
8
|
+
// output saved with --json > baseline.json.
|
|
9
|
+
|
|
10
|
+
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
11
|
+
|
|
12
|
+
// A change counts when it beats the larger of a share of the baseline and a minimum: 10% of a 3 s LCP
|
|
13
|
+
// is 300 ms, and 10% of a 200 ms TTFB would be noise. CLS is a score, with no relative part.
|
|
14
|
+
export const DEFAULT_THRESHOLDS = {
|
|
15
|
+
LCP: { relative: 0.1, absolute: 100 },
|
|
16
|
+
FCP: { relative: 0.1, absolute: 100 },
|
|
17
|
+
TTFB: { relative: 0.1, absolute: 100 },
|
|
18
|
+
INP: { relative: 0.1, absolute: 50 },
|
|
19
|
+
CLS: { absolute: 0.05 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The baseline in a file, as { kind: "page" | "matrix", file, data }. Throws with what to do when
|
|
23
|
+
// the file is not an output of the CLI.
|
|
24
|
+
export function readBaseline(text, file) {
|
|
25
|
+
const howTo = `save one with webperf-snippets <url> --json > ${file}`;
|
|
26
|
+
let data;
|
|
27
|
+
try {
|
|
28
|
+
data = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`${file} is not JSON: ${howTo}`);
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(data?.combinations)) return { kind: "matrix", file, data };
|
|
33
|
+
if (typeof data?.url === "string" && Array.isArray(data.results)) return { kind: "page", file, data };
|
|
34
|
+
throw new Error(`${file} is not an output of webperf-snippets --json: it has neither the results of a page nor the combinations of a matrix; ${howTo}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadBaseline(path) {
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = readFileSync(path, "utf8");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(`Cannot read the baseline ${path}: ${err.code === "ENOENT" ? "the file does not exist" : err.message}`);
|
|
43
|
+
}
|
|
44
|
+
return readBaseline(text, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// One part of the threshold of a metric, from --threshold: LCP=15% (relative), LCP=200ms
|
|
48
|
+
// (absolute), CLS=0.1 (CLS has only an absolute part, with no unit)
|
|
49
|
+
export function parseThreshold(raw) {
|
|
50
|
+
const match = /^([A-Za-z]+)=(\d+(?:\.\d+)?)(%|ms)?$/.exec(raw.trim());
|
|
51
|
+
if (!match) throw new Error(`Invalid --threshold "${raw}": give METRIC=VALUE with its unit, such as LCP=15%, LCP=200ms or CLS=0.1`);
|
|
52
|
+
const [, name, number, unit] = match;
|
|
53
|
+
const metric = name.toUpperCase();
|
|
54
|
+
if (!METRICS.includes(metric)) throw new Error(`Unknown metric "${name}" in --threshold "${raw}". Choose from: ${METRICS.join(", ")}`);
|
|
55
|
+
const value = Number(number);
|
|
56
|
+
if (metric === "CLS") {
|
|
57
|
+
if (unit) throw new Error(`Invalid --threshold "${raw}": CLS is a score, with no unit and no relative part, such as CLS=0.1`);
|
|
58
|
+
return { metric, absolute: value };
|
|
59
|
+
}
|
|
60
|
+
if (unit === "%") return { metric, relative: value / 100 };
|
|
61
|
+
if (unit === "ms") return { metric, absolute: value };
|
|
62
|
+
throw new Error(`Invalid --threshold "${raw}": give the unit, such as ${metric}=15% or ${metric}=200ms`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The default thresholds with the parts that --threshold changes
|
|
66
|
+
export function thresholdsWith(overrides) {
|
|
67
|
+
const thresholds = Object.fromEntries(Object.entries(DEFAULT_THRESHOLDS).map(([metric, parts]) => [metric, { ...parts }]));
|
|
68
|
+
for (const { metric, ...part } of overrides) Object.assign(thresholds[metric], part);
|
|
69
|
+
return thresholds;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const round = (n) => Math.round(n * 10000) / 10000;
|
|
73
|
+
|
|
74
|
+
// The value of a metric in a page: the median of its runs, with their range when there are two or
|
|
75
|
+
// more, or its one result, as runs.js picks it
|
|
76
|
+
function measured(payload, metric) {
|
|
77
|
+
const summary = payload.runs?.summary?.[metric];
|
|
78
|
+
if (summary) {
|
|
79
|
+
const runs = summary.values.filter((value) => value !== null).length;
|
|
80
|
+
return { value: summary.median, ...(runs >= 2 ? { range: { min: summary.min, max: summary.max } } : {}) };
|
|
81
|
+
}
|
|
82
|
+
const result = payload.results?.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
|
|
83
|
+
return result ? { value: result.value } : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A regression must beat the threshold of its metric and, when the baseline has several runs, the
|
|
87
|
+
// worst of them: a median inside the range of the baseline is noise the baseline already had.
|
|
88
|
+
function verdict(base, current, threshold) {
|
|
89
|
+
if (current.value - base.value > threshold && (!base.range || current.value > base.range.max)) return "regressed";
|
|
90
|
+
if (base.value - current.value > threshold && (!base.range || current.value < base.range.min)) return "improved";
|
|
91
|
+
return "unchanged";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compareMetrics(base, current, thresholds) {
|
|
95
|
+
const metrics = {};
|
|
96
|
+
for (const metric of METRICS) {
|
|
97
|
+
const b = measured(base, metric);
|
|
98
|
+
const c = measured(current, metric);
|
|
99
|
+
if (!b && !c) continue;
|
|
100
|
+
if (!b || !c) {
|
|
101
|
+
metrics[metric] = { ...(b ? { baseline: b.value } : { current: c.value }), verdict: "not measured" };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const { relative = 0, absolute } = thresholds[metric];
|
|
105
|
+
const threshold = round(Math.max(relative * b.value, absolute));
|
|
106
|
+
const delta = c.value - b.value;
|
|
107
|
+
metrics[metric] = {
|
|
108
|
+
baseline: b.value,
|
|
109
|
+
current: c.value,
|
|
110
|
+
delta: round(delta),
|
|
111
|
+
deltaPercent: b.value === 0 ? null : Math.round((delta / b.value) * 1000) / 10,
|
|
112
|
+
threshold,
|
|
113
|
+
verdict: verdict(b, c, threshold),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return metrics;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A resource of the page itself by its path and query, so the review app of a PR, on another host,
|
|
120
|
+
// matches main; any other target as it is
|
|
121
|
+
function targetOf(target, pageUrl) {
|
|
122
|
+
if (!target) return "";
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(target);
|
|
125
|
+
return url.origin === new URL(pageUrl).origin ? url.pathname + url.search : url.href;
|
|
126
|
+
} catch {
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Two errors are the same when they share a code and a target or, when a snippet has no codes on one
|
|
132
|
+
// side (a baseline of 1.4.0), when their messages differ only in their figures: "Render-blocking CSS
|
|
133
|
+
// (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
|
|
134
|
+
const byMessage = (error) => `${error.id}\u0000${error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
|
|
135
|
+
|
|
136
|
+
function compareErrors(base, current) {
|
|
137
|
+
const baseErrors = blockingErrors(base.results ?? []);
|
|
138
|
+
const currentErrors = blockingErrors(current.results ?? []);
|
|
139
|
+
// The snippets whose errors on one side all lack a code pair by message on both
|
|
140
|
+
const uncoded = (errors) => {
|
|
141
|
+
const ids = new Set(errors.map((e) => e.id));
|
|
142
|
+
for (const e of errors) if (e.code) ids.delete(e.id);
|
|
143
|
+
return ids;
|
|
144
|
+
};
|
|
145
|
+
const byMessageOnly = new Set([...uncoded(baseErrors), ...uncoded(currentErrors)]);
|
|
146
|
+
const keyOf = (error, pageUrl) =>
|
|
147
|
+
error.code && !byMessageOnly.has(error.id) ? `${error.id}\u0000${error.code}\u0000${targetOf(error.target, pageUrl)}` : byMessage(error);
|
|
148
|
+
const byKey = (errors, pageUrl) => new Map(errors.map((error) => [keyOf(error, pageUrl), error]));
|
|
149
|
+
const b = byKey(baseErrors, base.url);
|
|
150
|
+
const c = byKey(currentErrors, current.url);
|
|
151
|
+
return {
|
|
152
|
+
new: [...c].filter(([key]) => !b.has(key)).map(([, error]) => error),
|
|
153
|
+
fixed: [...b].filter(([key]) => !c.has(key)).map(([, error]) => error),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const describeWork = (conditions) => (conditions.snippet ? `snippet ${conditions.snippet}` : `workflow ${conditions.workflow}`);
|
|
158
|
+
const describeLoad = (throttling) => (throttling ? `with ${describeThrottling(throttling)}` : "without throttling");
|
|
159
|
+
const runCount = (payload) => payload.runs?.count ?? 1;
|
|
160
|
+
const pathOf = (url) => {
|
|
161
|
+
try {
|
|
162
|
+
const { pathname, search } = new URL(url);
|
|
163
|
+
return pathname + search;
|
|
164
|
+
} catch {
|
|
165
|
+
return url;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
|
|
170
|
+
function warningsOf(base, current) {
|
|
171
|
+
const warnings = [];
|
|
172
|
+
const b = base.conditions;
|
|
173
|
+
const c = current.conditions;
|
|
174
|
+
if (!b) {
|
|
175
|
+
warnings.push("the baseline comes from a CLI without conditions (1.3.0 or earlier); it cannot say how it was measured");
|
|
176
|
+
} else if (c) {
|
|
177
|
+
if (describeWork(b) !== describeWork(c)) warnings.push(`the baseline was measured with ${describeWork(b)}, and this run with ${describeWork(c)}`);
|
|
178
|
+
if (b.viewport !== c.viewport) warnings.push(`the baseline was measured on ${b.viewport}, and this run on ${c.viewport}`);
|
|
179
|
+
if (b.waitMs !== c.waitMs) warnings.push(`the baseline waited ${b.waitMs} ms after the load, and this run ${c.waitMs} ms`);
|
|
180
|
+
}
|
|
181
|
+
if (describeLoad(base.throttling) !== describeLoad(current.throttling)) {
|
|
182
|
+
warnings.push(`the baseline was measured ${describeLoad(base.throttling)}, and this run ${describeLoad(current.throttling)}`);
|
|
183
|
+
}
|
|
184
|
+
if (runCount(base) < 2) warnings.push("the baseline has 1 run, so only the thresholds apply; measure both with --runs");
|
|
185
|
+
else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
|
|
186
|
+
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
187
|
+
warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
|
|
188
|
+
}
|
|
189
|
+
// Another host is the review app of a pull request against production: the same page
|
|
190
|
+
if (pathOf(base.url) !== pathOf(current.url)) warnings.push(`the baseline measured ${pathOf(base.url)} and this run ${pathOf(current.url)}`);
|
|
191
|
+
return warnings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
|
|
195
|
+
// much to trust its values, in the header of the reports.
|
|
196
|
+
export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
197
|
+
return {
|
|
198
|
+
file,
|
|
199
|
+
url: base.url,
|
|
200
|
+
runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
|
|
201
|
+
metrics: compareMetrics(base, current, thresholds),
|
|
202
|
+
errors: compareErrors(base, current),
|
|
203
|
+
warnings: warningsOf(base, current),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A matrix against a baseline matrix: each combination with the one of the same id, and the ids that
|
|
208
|
+
// only one side has
|
|
209
|
+
export function compareMatrix(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
210
|
+
const baseById = new Map(base.combinations.map((c) => [c.id, c]));
|
|
211
|
+
const currentIds = new Set(current.combinations.map((c) => c.id));
|
|
212
|
+
const combinations = {};
|
|
213
|
+
for (const c of current.combinations) {
|
|
214
|
+
if (baseById.has(c.id)) combinations[c.id] = compareRun(baseById.get(c.id), c, { file, thresholds });
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
root: {
|
|
218
|
+
file,
|
|
219
|
+
added: current.combinations.filter((c) => !baseById.has(c.id)).map((c) => c.id),
|
|
220
|
+
missing: base.combinations.filter((c) => !currentIds.has(c.id)).map((c) => c.id),
|
|
221
|
+
warnings: [],
|
|
222
|
+
},
|
|
223
|
+
combinations,
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/bin.js
CHANGED
|
@@ -18,6 +18,9 @@ import { parseOwnDomains } from "./own-domains.js";
|
|
|
18
18
|
import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
|
|
19
19
|
import { loadMatrix } from "./matrix.js";
|
|
20
20
|
import { blockingErrors } from "./blocking.js";
|
|
21
|
+
import { measurementConditions } from "./conditions.js";
|
|
22
|
+
import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
|
|
23
|
+
import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
const USAGE = `webperf-snippets <url> [options]
|
|
@@ -63,6 +66,13 @@ Options:
|
|
|
63
66
|
variant it lists, and report a table of the medians
|
|
64
67
|
--median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
|
|
65
68
|
navMs (default: the first of LCP, FCP, TTFB, navMs measured)
|
|
69
|
+
--baseline <file> Compare the page, or each combination of a matrix, with the --json
|
|
70
|
+
output of an earlier run: which metrics got worse or better beyond
|
|
71
|
+
the noise, and which errors are new or fixed. It only informs,
|
|
72
|
+
unless --fail-on-regression
|
|
73
|
+
--threshold <metric>=<v> The threshold of a metric for --baseline, such as LCP=15%,
|
|
74
|
+
LCP=200ms or CLS=0.1. Repeat it for more metrics
|
|
75
|
+
--fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
|
|
66
76
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
67
77
|
Also read from the CRUX_API_KEY environment variable
|
|
68
78
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -86,6 +96,9 @@ Examples:
|
|
|
86
96
|
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
|
|
87
97
|
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
88
98
|
npx webperf-snippets --matrix matrix.json --runs 3 --budget-lcp 2500
|
|
99
|
+
npx webperf-snippets https://example.com --runs 5 --json > baseline.json
|
|
100
|
+
npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
|
|
101
|
+
npx webperf-snippets --matrix matrix.json --baseline last-week.json
|
|
89
102
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
90
103
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
91
104
|
npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
|
|
@@ -166,6 +179,9 @@ async function main() {
|
|
|
166
179
|
matrix: { type: "string" },
|
|
167
180
|
runs: { type: "string" },
|
|
168
181
|
"median-by": { type: "string" },
|
|
182
|
+
baseline: { type: "string" },
|
|
183
|
+
threshold: { type: "string", multiple: true },
|
|
184
|
+
"fail-on-regression": { type: "boolean" },
|
|
169
185
|
"crux-key": { type: "string" },
|
|
170
186
|
"report-to": { type: "string" },
|
|
171
187
|
"api-key": { type: "string" },
|
|
@@ -242,6 +258,23 @@ async function main() {
|
|
|
242
258
|
}
|
|
243
259
|
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
244
260
|
|
|
261
|
+
// The baseline is read and checked before the browser opens: a wrong file fails at once
|
|
262
|
+
let baseline;
|
|
263
|
+
let thresholds;
|
|
264
|
+
if (values.baseline) {
|
|
265
|
+
try {
|
|
266
|
+
baseline = loadBaseline(values.baseline);
|
|
267
|
+
thresholds = thresholdsWith((values.threshold ?? []).map(parseThreshold));
|
|
268
|
+
} catch (err) {
|
|
269
|
+
fail(err.message);
|
|
270
|
+
}
|
|
271
|
+
if (!matrixPath && baseline.kind === "matrix") fail(`${values.baseline} is the baseline of a matrix: compare it with --matrix`);
|
|
272
|
+
if (matrixPath && baseline.kind === "page") fail(`${values.baseline} is the baseline of a page: compare it with a URL, not --matrix`);
|
|
273
|
+
} else {
|
|
274
|
+
if (values.threshold) fail("--threshold needs --baseline");
|
|
275
|
+
if (values["fail-on-regression"]) fail("--fail-on-regression needs --baseline");
|
|
276
|
+
}
|
|
277
|
+
|
|
245
278
|
// What each page load runs, a snippet or a workflow, under the conditions of the load
|
|
246
279
|
const items = values.snippet ? buildSnippetItem(values) : null;
|
|
247
280
|
const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
|
|
@@ -250,6 +283,9 @@ async function main() {
|
|
|
250
283
|
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
251
284
|
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
252
285
|
};
|
|
286
|
+
// What the JSON records of how each page was measured, for a later run to compare with
|
|
287
|
+
const conditionsFor = (viewport) =>
|
|
288
|
+
measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
253
289
|
|
|
254
290
|
if (matrixPath) {
|
|
255
291
|
let matrix;
|
|
@@ -258,7 +294,7 @@ async function main() {
|
|
|
258
294
|
} catch (err) {
|
|
259
295
|
fail(err.message);
|
|
260
296
|
}
|
|
261
|
-
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl });
|
|
297
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds });
|
|
262
298
|
return;
|
|
263
299
|
}
|
|
264
300
|
|
|
@@ -271,11 +307,17 @@ async function main() {
|
|
|
271
307
|
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
272
308
|
payload = { ...median.payload, runs: median.runs };
|
|
273
309
|
}
|
|
310
|
+
payload.conditions = conditionsFor(viewportName);
|
|
274
311
|
|
|
275
312
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
276
313
|
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
277
314
|
if (crux) payload.crux = crux;
|
|
278
315
|
|
|
316
|
+
if (baseline) {
|
|
317
|
+
payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
|
|
318
|
+
for (const warning of payload.baseline.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
319
|
+
}
|
|
320
|
+
|
|
279
321
|
let output;
|
|
280
322
|
if (values.markdown) {
|
|
281
323
|
output = reportMarkdown(payload);
|
|
@@ -315,6 +357,12 @@ async function main() {
|
|
|
315
357
|
}
|
|
316
358
|
process.exit(1);
|
|
317
359
|
}
|
|
360
|
+
// Without --fail-on-regression the comparison only informs, and the exit code is the one of today
|
|
361
|
+
const regressions = values["fail-on-regression"] ? describeRegressions(payload.baseline) : [];
|
|
362
|
+
if (regressions.length > 0) {
|
|
363
|
+
for (const r of regressions) process.stderr.write(`Regression against ${baseline.file}: ${r}\n`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
318
366
|
process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
|
|
319
367
|
}
|
|
320
368
|
|
|
@@ -337,7 +385,7 @@ async function measureRuns(measure, count, medianBy, progress) {
|
|
|
337
385
|
|
|
338
386
|
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
339
387
|
// its median run, and the exit code follows the same rules as one page, for every combination.
|
|
340
|
-
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl }) {
|
|
388
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds }) {
|
|
341
389
|
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
342
390
|
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
343
391
|
|
|
@@ -351,8 +399,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
351
399
|
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
352
400
|
combinations.push(
|
|
353
401
|
median.payload
|
|
354
|
-
? { ...identity, ...median.payload, runs: median.runs }
|
|
355
|
-
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs } }
|
|
402
|
+
? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c.viewport) }
|
|
403
|
+
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c.viewport) }
|
|
356
404
|
);
|
|
357
405
|
}
|
|
358
406
|
|
|
@@ -369,8 +417,18 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
369
417
|
}
|
|
370
418
|
}
|
|
371
419
|
|
|
372
|
-
|
|
373
|
-
|
|
420
|
+
// With --baseline, each combination is compared with the one of the same id in the baseline
|
|
421
|
+
let comparison;
|
|
422
|
+
if (baseline) {
|
|
423
|
+
const { root, combinations: byId } = compareMatrix(baseline.data, { combinations }, { file: baseline.file, thresholds });
|
|
424
|
+
for (const c of combinations) if (byId[c.id]) c.baseline = byId[c.id];
|
|
425
|
+
comparison = root;
|
|
426
|
+
for (const warning of matrixComparisonView(root, combinations).warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const withBaseline = comparison ? { baseline: comparison } : {};
|
|
430
|
+
const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose, ...withBaseline };
|
|
431
|
+
const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, ...withBaseline, combinations }) : reportMatrixHuman(report);
|
|
374
432
|
process.stdout.write(output + "\n");
|
|
375
433
|
|
|
376
434
|
// One report for each combination, with the body of one page and the device, variant and runs of
|
|
@@ -411,7 +469,10 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
411
469
|
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
412
470
|
}
|
|
413
471
|
const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
|
|
414
|
-
|
|
472
|
+
// Without --fail-on-regression the comparison only informs
|
|
473
|
+
const regressions = values["fail-on-regression"] ? describeMatrixRegressions(combinations) : [];
|
|
474
|
+
for (const r of regressions) process.stderr.write(`Regression against ${baseline.file} in ${r}\n`);
|
|
475
|
+
process.exit(violations.length > 0 || failed || regressions.length > 0 ? 1 : 0);
|
|
415
476
|
}
|
|
416
477
|
|
|
417
478
|
main().catch((err) => {
|
package/src/blocking.js
CHANGED
|
@@ -6,7 +6,9 @@ export function blockingErrors(results) {
|
|
|
6
6
|
for (const r of results) {
|
|
7
7
|
if (r.status === "error") errors.push({ id: r.id, message: r.error });
|
|
8
8
|
if (r.reason || !Array.isArray(r.issues)) continue;
|
|
9
|
-
for (const issue of r.issues)
|
|
9
|
+
for (const issue of r.issues) {
|
|
10
|
+
if (issue.severity === "error") errors.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}), ...(issue.target ? { target: issue.target } : {}) });
|
|
11
|
+
}
|
|
10
12
|
}
|
|
11
13
|
return errors;
|
|
12
14
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { cliVersion } from "./report-to.js";
|
|
2
|
+
|
|
3
|
+
// How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
|
|
4
|
+
// both were measured the same way. A snippet is named by its path, so an alias and its path match.
|
|
5
|
+
export function measurementConditions({ viewport, workflow, snippet, waitMs }) {
|
|
6
|
+
return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion };
|
|
7
|
+
}
|
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 }) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The comparison with a baseline as the human and markdown reports draw it: one row per metric with
|
|
2
|
+
// its two values, the change and the verdict, and why an unchanged one did not count. The values stay
|
|
3
|
+
// numbers with their unit, for each report to format.
|
|
4
|
+
|
|
5
|
+
const MINUS = "−";
|
|
6
|
+
const signed = (n, text) => (n > 0 ? `+${text}` : n < 0 ? `${MINUS}${text}` : text);
|
|
7
|
+
const arrow = (delta) => (delta > 0 ? "▲" : delta < 0 ? "▼" : "=");
|
|
8
|
+
const unitOf = (metric) => (metric === "CLS" ? "score" : "ms");
|
|
9
|
+
|
|
10
|
+
// "▲ +380ms (+18%)", "▼ −90ms (−15%)", "▼ −0.0020"
|
|
11
|
+
function describeChange(metric, { delta, deltaPercent }) {
|
|
12
|
+
if (metric === "CLS") return `${arrow(delta)} ${signed(delta, Math.abs(delta).toFixed(4))}`;
|
|
13
|
+
const ms = signed(delta, `${Math.abs(Math.round(delta))}ms`);
|
|
14
|
+
const percent = deltaPercent == null ? "" : ` (${signed(deltaPercent, `${Math.abs(Math.round(deltaPercent))}%`)})`;
|
|
15
|
+
return `${arrow(delta)} ${ms}${percent}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// An unchanged metric that moved stayed under its threshold, or beat it inside the range of the runs
|
|
19
|
+
// of the baseline: noise the baseline already had
|
|
20
|
+
function noteOf(metric, m) {
|
|
21
|
+
if (m.verdict !== "unchanged" || m.delta === 0) return null;
|
|
22
|
+
if (Math.abs(m.delta) > m.threshold) return "inside the range of the baseline";
|
|
23
|
+
return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function baselineView({ file, url, runs, metrics, errors, warnings }) {
|
|
27
|
+
const summary = `${url}, ${runs.count === 1 ? "1 run" : `${runs.count} runs, median by ${runs.medianBy}`}`;
|
|
28
|
+
const rows = Object.entries(metrics).map(([metric, m]) => ({
|
|
29
|
+
metric,
|
|
30
|
+
unit: unitOf(metric),
|
|
31
|
+
baseline: m.baseline,
|
|
32
|
+
current: m.current,
|
|
33
|
+
change: m.verdict === "not measured" ? null : describeChange(metric, m),
|
|
34
|
+
verdict: m.verdict,
|
|
35
|
+
note: m.verdict === "not measured" ? null : noteOf(metric, m),
|
|
36
|
+
}));
|
|
37
|
+
return { file, summary, rows, newErrors: errors.new, fixedErrors: errors.fixed, warnings };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// What got worse, one line each, for --fail-on-regression: the metrics that regressed and the errors
|
|
41
|
+
// that only this run has
|
|
42
|
+
export function describeRegressions({ metrics, errors }) {
|
|
43
|
+
return [
|
|
44
|
+
...Object.entries(metrics)
|
|
45
|
+
.filter(([, m]) => m.verdict === "regressed")
|
|
46
|
+
.map(([metric, m]) => `${metric} ${describeChange(metric, m)}`),
|
|
47
|
+
...errors.new.map((e) => `new error ${e.id}: ${e.message}`),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The change of a metric in a cell of the matrix table: the figure only when it counts, so the table
|
|
52
|
+
// reads at a glance. "▲ +380ms", "▼ −400ms", "=" for unchanged, "·" when one side did not measure it.
|
|
53
|
+
export function changeCell(metric, m) {
|
|
54
|
+
if (m.verdict === "not measured") return { text: "·", verdict: m.verdict };
|
|
55
|
+
if (m.verdict === "unchanged") return { text: "=", verdict: m.verdict };
|
|
56
|
+
const amount = metric === "CLS" ? Math.abs(m.delta).toFixed(4) : `${Math.abs(Math.round(m.delta))}ms`;
|
|
57
|
+
return { text: `${arrow(m.delta)} ${signed(m.delta, amount)}`, verdict: m.verdict };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The comparison of a matrix below its table. A warning that every compared combination has, such as
|
|
61
|
+
// a baseline of one run, shows once; the others name their combinations.
|
|
62
|
+
export function matrixComparisonView(root, combinations) {
|
|
63
|
+
const compared = combinations.filter((c) => c.baseline);
|
|
64
|
+
const idsByWarning = new Map();
|
|
65
|
+
for (const c of compared) {
|
|
66
|
+
for (const warning of c.baseline.warnings) idsByWarning.set(warning, [...(idsByWarning.get(warning) ?? []), c.id]);
|
|
67
|
+
}
|
|
68
|
+
const warnings = [...idsByWarning].map(([warning, ids]) => (ids.length === compared.length ? warning : `${ids.join(", ")}: ${warning}`));
|
|
69
|
+
const errorsOf = (kind) => compared.flatMap((c) => c.baseline.errors[kind].map((e) => `${c.id}: ${e.id}: ${e.message}`));
|
|
70
|
+
return {
|
|
71
|
+
file: root.file,
|
|
72
|
+
warnings: [...root.warnings, ...warnings],
|
|
73
|
+
newErrors: errorsOf("new"),
|
|
74
|
+
fixedErrors: errorsOf("fixed"),
|
|
75
|
+
added: root.added,
|
|
76
|
+
missing: root.missing,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The regressions of every combination, each named by its combination
|
|
81
|
+
export function describeMatrixRegressions(combinations) {
|
|
82
|
+
return combinations.filter((c) => c.baseline).flatMap((c) => describeRegressions(c.baseline).map((r) => `${c.id}: ${r}`));
|
|
83
|
+
}
|