webperf-snippets 1.2.0 → 1.4.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 +126 -4
- package/package.json +3 -2
- package/snippets/CoreWebVitals/CLS.js +3 -2
- package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
- package/src/baseline.js +201 -0
- package/src/bin.js +229 -20
- package/src/blocking.js +14 -0
- package/src/conditions.js +7 -0
- package/src/crux.js +6 -6
- package/src/matrix.js +227 -0
- package/src/ratings.js +16 -0
- package/src/reporters/baseline-view.js +83 -0
- package/src/reporters/human.js +89 -2
- package/src/reporters/markdown.js +84 -2
- package/src/reporters/matrix-view.js +42 -0
- package/src/runs.js +65 -0
- package/src/throttling.js +41 -4
package/src/bin.js
CHANGED
|
@@ -6,18 +6,25 @@ import { resolveSnippetName } from "./snippet-names.js";
|
|
|
6
6
|
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
|
|
7
7
|
import { WORKFLOWS } from "./workflows/index.js";
|
|
8
8
|
import { RULES } from "./decision-tree.js";
|
|
9
|
-
import { reportHuman } from "./reporters/human.js";
|
|
9
|
+
import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
|
|
10
10
|
import { reportJson } from "./reporters/json.js";
|
|
11
|
-
import { reportMarkdown } from "./reporters/markdown.js";
|
|
11
|
+
import { reportMarkdown, reportMatrixMarkdown } from "./reporters/markdown.js";
|
|
12
12
|
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
13
13
|
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
14
14
|
import { MissingBrowserError } from "./browser.js";
|
|
15
15
|
import { parseHeader } from "./headers.js";
|
|
16
16
|
import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
|
|
17
17
|
import { parseOwnDomains } from "./own-domains.js";
|
|
18
|
+
import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
|
|
19
|
+
import { loadMatrix } from "./matrix.js";
|
|
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";
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
const USAGE = `webperf-snippets <url> [options]
|
|
27
|
+
webperf-snippets --matrix <file> [options]
|
|
21
28
|
webperf-snippets mcp
|
|
22
29
|
|
|
23
30
|
Run curated WebPerf Snippets headlessly via Playwright.
|
|
@@ -53,6 +60,19 @@ Options:
|
|
|
53
60
|
the token of an access proxy. Repeat it for more headers
|
|
54
61
|
--own-domains <list> Domains of the site to count as first party, such as its
|
|
55
62
|
own CDN, comma-separated or repeated
|
|
63
|
+
--runs <n> Measure the page n times (1 to 9), one after the other, and
|
|
64
|
+
report the median run
|
|
65
|
+
--matrix <file> Measure every URL of a JSON file on every device and in every
|
|
66
|
+
variant it lists, and report a table of the medians
|
|
67
|
+
--median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
|
|
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
|
|
56
76
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
57
77
|
Also read from the CRUX_API_KEY environment variable
|
|
58
78
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -74,6 +94,11 @@ Examples:
|
|
|
74
94
|
npx webperf-snippets https://example.com --budget-lcp 2500
|
|
75
95
|
npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
76
96
|
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
|
|
97
|
+
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
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
|
|
77
102
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
78
103
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
79
104
|
npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
|
|
@@ -94,11 +119,25 @@ function buildSnippetItem(values) {
|
|
|
94
119
|
return [{ id: values.snippet, path, source: loadSnippet(path) }];
|
|
95
120
|
}
|
|
96
121
|
|
|
97
|
-
|
|
122
|
+
// The count given to --runs, or null when it is not a whole number from 1 to 9
|
|
123
|
+
function parseRunCount(raw) {
|
|
124
|
+
return /^[1-9]$/.test(raw.trim()) ? Number(raw) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkBudgets({ results, runs }, values) {
|
|
98
128
|
const violations = [];
|
|
99
129
|
const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
|
|
100
130
|
const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
|
|
101
131
|
|
|
132
|
+
// With several runs the budgets compare the median of each metric, not one sample
|
|
133
|
+
if (runs) {
|
|
134
|
+
const lcp = runs.summary.LCP?.median;
|
|
135
|
+
const cls = runs.summary.CLS?.median;
|
|
136
|
+
if (lcp != null && lcpBudget != null && lcp > lcpBudget) violations.push(`LCP ${lcp}ms exceeds budget ${lcpBudget}ms`);
|
|
137
|
+
if (cls != null && clsBudget != null && cls > clsBudget) violations.push(`CLS ${cls} exceeds budget ${clsBudget}`);
|
|
138
|
+
return violations;
|
|
139
|
+
}
|
|
140
|
+
|
|
102
141
|
for (const r of results) {
|
|
103
142
|
if (r.status !== "ok") continue;
|
|
104
143
|
if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
|
|
@@ -137,6 +176,12 @@ async function main() {
|
|
|
137
176
|
"storage-state": { type: "string" },
|
|
138
177
|
header: { type: "string", multiple: true },
|
|
139
178
|
"own-domains": { type: "string", multiple: true },
|
|
179
|
+
matrix: { type: "string" },
|
|
180
|
+
runs: { type: "string" },
|
|
181
|
+
"median-by": { type: "string" },
|
|
182
|
+
baseline: { type: "string" },
|
|
183
|
+
threshold: { type: "string", multiple: true },
|
|
184
|
+
"fail-on-regression": { type: "boolean" },
|
|
140
185
|
"crux-key": { type: "string" },
|
|
141
186
|
"report-to": { type: "string" },
|
|
142
187
|
"api-key": { type: "string" },
|
|
@@ -158,10 +203,13 @@ async function main() {
|
|
|
158
203
|
}
|
|
159
204
|
|
|
160
205
|
const url = positionals[0];
|
|
161
|
-
|
|
206
|
+
const matrixPath = values.matrix;
|
|
207
|
+
if (url && matrixPath) fail("Use a URL or --matrix, not both");
|
|
208
|
+
if (!url && !matrixPath) {
|
|
162
209
|
process.stdout.write(USAGE);
|
|
163
210
|
process.exit(2);
|
|
164
211
|
}
|
|
212
|
+
if (matrixPath && values.viewport) fail("--viewport does not apply to --matrix: set the viewport of each device in the file");
|
|
165
213
|
|
|
166
214
|
const waitMs = values.wait ? Number(values.wait) : 3000;
|
|
167
215
|
const viewportName = values.viewport ?? "mobile";
|
|
@@ -202,21 +250,74 @@ async function main() {
|
|
|
202
250
|
}
|
|
203
251
|
const ownDomains = values["own-domains"] && parseOwnDomains(values["own-domains"]);
|
|
204
252
|
|
|
253
|
+
const runCount = values.runs != null ? parseRunCount(values.runs) : undefined;
|
|
254
|
+
if (runCount === null) fail(`Invalid --runs "${values.runs}". Use a whole number from 1 to 9`);
|
|
255
|
+
const medianBy = values["median-by"];
|
|
256
|
+
if (medianBy != null && !MEDIAN_METRICS.includes(medianBy)) {
|
|
257
|
+
fail(`Unknown --median-by "${medianBy}". Choose from: ${MEDIAN_METRICS.join(", ")}`);
|
|
258
|
+
}
|
|
259
|
+
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
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
|
+
|
|
278
|
+
// What each page load runs, a snippet or a workflow, under the conditions of the load
|
|
279
|
+
const items = values.snippet ? buildSnippetItem(values) : null;
|
|
280
|
+
const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
|
|
281
|
+
if (!values.snippet && !workflow) fail(`Unknown workflow: ${values.workflow}`);
|
|
282
|
+
const measureWith = (conditions) => {
|
|
283
|
+
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
284
|
+
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
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" }) });
|
|
289
|
+
|
|
290
|
+
if (matrixPath) {
|
|
291
|
+
let matrix;
|
|
292
|
+
try {
|
|
293
|
+
matrix = loadMatrix(matrixPath, { cpu, network, storageState, headers, ownDomains, runs: runCount, medianBy });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
fail(err.message);
|
|
296
|
+
}
|
|
297
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const conditions = { url, viewport, storageState, headers, cpu, network, ownDomains };
|
|
205
302
|
let payload;
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
|
|
303
|
+
if (runCount == null) {
|
|
304
|
+
payload = await measureWith(conditions);
|
|
209
305
|
} else {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
|
|
306
|
+
const median = await measureRuns(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
|
|
307
|
+
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
308
|
+
payload = { ...median.payload, runs: median.runs };
|
|
214
309
|
}
|
|
310
|
+
payload.conditions = conditionsFor(viewportName);
|
|
215
311
|
|
|
216
312
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
217
313
|
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
218
314
|
if (crux) payload.crux = crux;
|
|
219
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
|
+
|
|
220
321
|
let output;
|
|
221
322
|
if (values.markdown) {
|
|
222
323
|
output = reportMarkdown(payload);
|
|
@@ -238,7 +339,7 @@ async function main() {
|
|
|
238
339
|
timestamp: new Date().toISOString(),
|
|
239
340
|
navMs: payload.navMs,
|
|
240
341
|
results: payload.results,
|
|
241
|
-
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), cli_version: cliVersion },
|
|
342
|
+
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
|
|
242
343
|
},
|
|
243
344
|
});
|
|
244
345
|
if (!outcome.ok) {
|
|
@@ -249,21 +350,129 @@ async function main() {
|
|
|
249
350
|
}
|
|
250
351
|
|
|
251
352
|
// Exit codes.
|
|
252
|
-
const violations = checkBudgets(payload
|
|
353
|
+
const violations = checkBudgets(payload, values);
|
|
253
354
|
if (violations.length > 0) {
|
|
254
355
|
if (!values.json) {
|
|
255
356
|
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
256
357
|
}
|
|
257
358
|
process.exit(1);
|
|
258
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
|
+
}
|
|
366
|
+
process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Measures `count` runs one after the other, never in parallel: they would compete for the CPU and
|
|
370
|
+
// skew the timings. Each run is reported on stderr as it starts, so stdout keeps only the report. A
|
|
371
|
+
// run that fails is kept as { error } and left out of the median.
|
|
372
|
+
async function measureRuns(measure, count, medianBy, progress) {
|
|
373
|
+
const runs = [];
|
|
374
|
+
for (let i = 1; i <= count; i++) {
|
|
375
|
+
process.stderr.write(`${progress(i)}\n`);
|
|
376
|
+
try {
|
|
377
|
+
runs.push(await measure());
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (err instanceof MissingBrowserError) throw err;
|
|
380
|
+
runs.push({ error: err.message });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return summarizeRuns(runs, { medianBy });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
387
|
+
// its median run, and the exit code follows the same rules as one page, for every combination.
|
|
388
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds }) {
|
|
389
|
+
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
390
|
+
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
391
|
+
|
|
392
|
+
const total = matrix.combinations.length * matrix.runs;
|
|
393
|
+
const width = String(total).length;
|
|
394
|
+
let started = 0;
|
|
395
|
+
const combinations = [];
|
|
396
|
+
for (const c of matrix.combinations) {
|
|
397
|
+
const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
|
|
398
|
+
const median = await measureRuns(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
|
|
399
|
+
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
400
|
+
combinations.push(
|
|
401
|
+
median.payload
|
|
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) }
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// The field data of a URL depends on the form factor, not on the variant: one query for each pair
|
|
408
|
+
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
409
|
+
if (cruxKey) {
|
|
410
|
+
const queries = new Map();
|
|
411
|
+
for (const c of combinations.filter((c) => !c.error)) {
|
|
412
|
+
const formFactor = formFactorFor(viewportOf.get(c.id));
|
|
413
|
+
const key = `${c.url} ${formFactor}`;
|
|
414
|
+
if (!queries.has(key)) queries.set(key, fetchCrux({ url: c.url, apiKey: cruxKey, formFactor }));
|
|
415
|
+
const crux = await queries.get(key);
|
|
416
|
+
if (crux) c.crux = crux;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
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);
|
|
432
|
+
process.stdout.write(output + "\n");
|
|
433
|
+
|
|
434
|
+
// One report for each combination, with the body of one page and the device, variant and runs of
|
|
435
|
+
// the combination. The headers are conditions of the load, never part of the body.
|
|
436
|
+
if (reportUrl) {
|
|
437
|
+
for (const c of combinations.filter((c) => !c.error)) {
|
|
438
|
+
const outcome = await reportTo({
|
|
439
|
+
url: reportUrl,
|
|
440
|
+
apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
|
|
441
|
+
body: {
|
|
442
|
+
url: c.url,
|
|
443
|
+
workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
|
|
444
|
+
...(values.snippet ? { snippet: values.snippet } : {}),
|
|
445
|
+
timestamp: new Date().toISOString(),
|
|
446
|
+
navMs: c.navMs,
|
|
447
|
+
results: c.results,
|
|
448
|
+
meta: {
|
|
449
|
+
viewport: viewportOf.get(c.id),
|
|
450
|
+
device: c.device,
|
|
451
|
+
...(c.variant ? { variant: c.variant } : {}),
|
|
452
|
+
waitMs,
|
|
453
|
+
...(c.throttling ? { throttling: c.throttling } : {}),
|
|
454
|
+
runs: c.runs,
|
|
455
|
+
cli_version: cliVersion,
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
if (!outcome.ok) {
|
|
460
|
+
process.stderr.write(`Warning: could not report ${c.id} to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
|
|
461
|
+
} else if (outcome.regressions.length > 0) {
|
|
462
|
+
process.stderr.write(`Regressions detected in ${c.id}:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
259
466
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
467
|
+
const violations = combinations.filter((c) => !c.error).flatMap((c) => checkBudgets(c, values).map((v) => `${v} (${c.id})`));
|
|
468
|
+
if (!values.json) {
|
|
469
|
+
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
470
|
+
}
|
|
471
|
+
const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
|
|
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);
|
|
267
476
|
}
|
|
268
477
|
|
|
269
478
|
main().catch((err) => {
|
package/src/blocking.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The results that make the command exit with 1: a snippet that failed, and an issue of severity
|
|
2
|
+
// error in a step the workflow runs itself. A follow-up (a result with a reason) only informs: its
|
|
3
|
+
// error issues show in the report and leave the exit code to the budgets and to the steps.
|
|
4
|
+
export function blockingErrors(results) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
for (const r of results) {
|
|
7
|
+
if (r.status === "error") errors.push({ id: r.id, message: r.error });
|
|
8
|
+
if (r.reason || !Array.isArray(r.issues)) continue;
|
|
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 } : {}) });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return errors;
|
|
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/crux.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import { rate } from "./ratings.js";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
2
4
|
const CRUX_TIMEOUT_MS = 10000;
|
|
3
5
|
|
|
4
|
-
// Good / needs-improvement upper bounds from web.dev/articles/vitals.
|
|
5
6
|
const METRICS = [
|
|
6
|
-
{ name: "LCP", key: "largest_contentful_paint", unit: "ms"
|
|
7
|
-
{ name: "CLS", key: "cumulative_layout_shift", unit: "score"
|
|
8
|
-
{ name: "INP", key: "interaction_to_next_paint", unit: "ms"
|
|
7
|
+
{ name: "LCP", key: "largest_contentful_paint", unit: "ms" },
|
|
8
|
+
{ name: "CLS", key: "cumulative_layout_shift", unit: "score" },
|
|
9
|
+
{ name: "INP", key: "interaction_to_next_paint", unit: "ms" },
|
|
9
10
|
];
|
|
10
11
|
|
|
11
12
|
const FORM_FACTOR_LABEL = { PHONE: "mobile", DESKTOP: "desktop", TABLET: "tablet" };
|
|
12
13
|
|
|
13
|
-
const rate = (value, { good, poor }) => (value <= good ? "good" : value <= poor ? "needs-improvement" : "poor");
|
|
14
14
|
|
|
15
15
|
// The viewport preset decides which population the field data describes.
|
|
16
16
|
export function formFactorFor(viewportName) {
|
|
@@ -22,7 +22,7 @@ function parseMetrics(record) {
|
|
|
22
22
|
for (const m of METRICS) {
|
|
23
23
|
// CLS p75 comes as a string ("0.08"); LCP and INP as numbers.
|
|
24
24
|
const p75 = Number(record?.metrics?.[m.key]?.percentiles?.p75);
|
|
25
|
-
if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(
|
|
25
|
+
if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(m.name, p75), unit: m.unit };
|
|
26
26
|
}
|
|
27
27
|
return metrics;
|
|
28
28
|
}
|
package/src/matrix.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// A matrix file: several pages, measured on several devices and in several variants, each
|
|
2
|
+
// combination one or more times. This module reads, validates and expands the file into the
|
|
3
|
+
// combinations to measure, with the conditions of each one already combined with the flags of the
|
|
4
|
+
// command line. It opens no browser, so an invalid file fails before any page load.
|
|
5
|
+
//
|
|
6
|
+
// {
|
|
7
|
+
// "urls": { "home": "https://example.com/" }, or an array of URLs
|
|
8
|
+
// "devices": { "mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" } },
|
|
9
|
+
// "variants": { "consent": { "storageState": "consent.json", "headers": {}, "ownDomains": [] } },
|
|
10
|
+
// "runs": 3,
|
|
11
|
+
// "medianBy": "LCP"
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// The conditions of the file replace cpu, network and storageState of the command line where they
|
|
15
|
+
// set them; headers and own domains add to the ones of the command line, which carries the secrets.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { VIEWPORT_PRESETS } from "./runner.js";
|
|
19
|
+
import { resolveNetwork } from "./throttling.js";
|
|
20
|
+
import { parseHeader } from "./headers.js";
|
|
21
|
+
import { parseOwnDomains } from "./own-domains.js";
|
|
22
|
+
import { MEDIAN_METRICS } from "./runs.js";
|
|
23
|
+
|
|
24
|
+
const KEYS = {
|
|
25
|
+
matrix: ["urls", "devices", "variants", "runs", "medianBy"],
|
|
26
|
+
device: ["viewport", "cpu", "network"],
|
|
27
|
+
variant: ["storageState", "headers", "ownDomains"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Without devices, a mid-range phone, as Lighthouse measures by default
|
|
31
|
+
const DEFAULT_DEVICE = { viewport: "mobile", cpu: 4, network: "slow-4g" };
|
|
32
|
+
|
|
33
|
+
// A header whose name says it carries a secret, which a versioned file should not hold
|
|
34
|
+
const LOOKS_SECRET = /secret|token|authorization|key|cookie/i;
|
|
35
|
+
|
|
36
|
+
class MatrixError extends Error {
|
|
37
|
+
constructor(problem) {
|
|
38
|
+
super(`Invalid matrix: ${problem}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
function checkKeys(object, known, at) {
|
|
45
|
+
for (const key of Object.keys(object)) {
|
|
46
|
+
if (!known.includes(key)) {
|
|
47
|
+
throw new MatrixError(`unknown key "${at ? `${at}.` : ""}${key}". Known keys: ${known.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The URLs by name. An array names each URL by its path and query, with the host when there are
|
|
53
|
+
// several, which is what a reader recognises in a table of 5 rows or of 50.
|
|
54
|
+
function readUrls(urls) {
|
|
55
|
+
if (urls === undefined) throw new MatrixError("urls is required");
|
|
56
|
+
const entries = Array.isArray(urls) ? urls.map((url, i) => [`[${i}]`, url]) : isObject(urls) ? Object.entries(urls).map(([name, url]) => [`.${name}`, url, name]) : null;
|
|
57
|
+
if (!entries) throw new MatrixError("urls must be an object of names and URLs, or an array of URLs");
|
|
58
|
+
if (entries.length === 0) throw new MatrixError("urls has no URL");
|
|
59
|
+
|
|
60
|
+
const parsed = entries.map(([at, url, name]) => {
|
|
61
|
+
let parsedUrl;
|
|
62
|
+
try {
|
|
63
|
+
parsedUrl = new URL(url);
|
|
64
|
+
} catch {
|
|
65
|
+
parsedUrl = null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof url !== "string" || !parsedUrl || !["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
68
|
+
throw new MatrixError(`urls${at} "${url}" is not an http or https URL`);
|
|
69
|
+
}
|
|
70
|
+
return { url, parsedUrl, name };
|
|
71
|
+
});
|
|
72
|
+
if (!Array.isArray(urls)) return parsed.map(({ name, url }) => ({ name, url }));
|
|
73
|
+
|
|
74
|
+
const severalHosts = new Set(parsed.map(({ parsedUrl }) => parsedUrl.host)).size > 1;
|
|
75
|
+
const named = parsed.map(({ url, parsedUrl }) => ({
|
|
76
|
+
name: `${severalHosts ? parsedUrl.host : ""}${parsedUrl.pathname}${parsedUrl.search}`,
|
|
77
|
+
url,
|
|
78
|
+
}));
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const { name, url } of named) {
|
|
81
|
+
if (seen.has(name)) {
|
|
82
|
+
const suggestion = name.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
|
|
83
|
+
throw new MatrixError(`two URLs of urls are named "${name}". Name them with an object, such as { "${suggestion}": "${url}" }`);
|
|
84
|
+
}
|
|
85
|
+
seen.add(name);
|
|
86
|
+
}
|
|
87
|
+
return named;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkViewport(viewport, at) {
|
|
91
|
+
if (!VIEWPORT_PRESETS[viewport]) {
|
|
92
|
+
throw new MatrixError(`${at} "${viewport}" is not a preset. Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The devices by name. An array of viewport presets is a shortcut for devices without throttling.
|
|
97
|
+
function readDevices(devices, flags) {
|
|
98
|
+
if (devices === undefined) {
|
|
99
|
+
return [{ name: "mobile", viewport: DEFAULT_DEVICE.viewport, cpu: flags.cpu ?? DEFAULT_DEVICE.cpu, network: flags.network ?? DEFAULT_DEVICE.network }];
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(devices) ? devices.length === 0 : isObject(devices) && Object.keys(devices).length === 0) {
|
|
102
|
+
throw new MatrixError("devices has no device");
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(devices)) {
|
|
105
|
+
devices.forEach((viewport, i) => checkViewport(viewport, `devices[${i}]`));
|
|
106
|
+
return devices.map((viewport) => ({ name: viewport, viewport, cpu: flags.cpu, network: flags.network }));
|
|
107
|
+
}
|
|
108
|
+
if (!isObject(devices)) throw new MatrixError("devices must be an object of names and devices, or an array of viewport presets");
|
|
109
|
+
|
|
110
|
+
return Object.entries(devices).map(([name, device]) => {
|
|
111
|
+
const at = `devices.${name}`;
|
|
112
|
+
if (!isObject(device)) throw new MatrixError(`${at} must be an object, such as { "viewport": "mobile", "cpu": 4, "network": "slow-4g" }`);
|
|
113
|
+
checkKeys(device, KEYS.device, at);
|
|
114
|
+
const viewport = device.viewport ?? "mobile";
|
|
115
|
+
checkViewport(viewport, `${at}.viewport`);
|
|
116
|
+
if (device.cpu !== undefined && !(typeof device.cpu === "number" && device.cpu >= 1)) {
|
|
117
|
+
throw new MatrixError(`${at}.cpu must be a number of at least 1, such as 4`);
|
|
118
|
+
}
|
|
119
|
+
if (device.network !== undefined) {
|
|
120
|
+
try {
|
|
121
|
+
resolveNetwork(device.network);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new MatrixError(`${at}.network: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { name, viewport, cpu: device.cpu ?? flags.cpu, network: device.network ?? flags.network };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The variants by name. Without variants, one variant with no name and no conditions of its own,
|
|
131
|
+
// so the combinations are the pages on the devices and the reports leave the variant out.
|
|
132
|
+
function readVariants(variants, base, warnings) {
|
|
133
|
+
if (variants === undefined) return [{ headers: {}, ownDomains: [] }];
|
|
134
|
+
if (!isObject(variants)) throw new MatrixError("variants must be an object of names and variants");
|
|
135
|
+
if (Object.keys(variants).length === 0) throw new MatrixError("variants has no variant");
|
|
136
|
+
|
|
137
|
+
return Object.entries(variants).map(([name, variant]) => {
|
|
138
|
+
const at = `variants.${name}`;
|
|
139
|
+
if (!isObject(variant)) throw new MatrixError(`${at} must be an object, such as { "storageState": "consent.json" }`);
|
|
140
|
+
checkKeys(variant, KEYS.variant, at);
|
|
141
|
+
|
|
142
|
+
let storageState;
|
|
143
|
+
if (variant.storageState !== undefined) {
|
|
144
|
+
storageState = resolve(base, String(variant.storageState));
|
|
145
|
+
if (!existsSync(storageState)) throw new MatrixError(`${at}.storageState "${variant.storageState}" not found at ${storageState}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const headers = {};
|
|
149
|
+
if (variant.headers !== undefined) {
|
|
150
|
+
if (!isObject(variant.headers)) throw new MatrixError(`${at}.headers must be an object of header names and values`);
|
|
151
|
+
for (const [header, value] of Object.entries(variant.headers)) {
|
|
152
|
+
if (!parseHeader(`${header}: `)) throw new MatrixError(`${at}.headers "${header}" is not a valid header`);
|
|
153
|
+
if (typeof value !== "string") throw new MatrixError(`${at}.headers "${header}" must be a string`);
|
|
154
|
+
if (LOOKS_SECRET.test(header)) {
|
|
155
|
+
warnings.push(`${at}.headers.${header} looks like a secret, and a matrix file is usually versioned. Pass it with --header "${header}: $VARIABLE" instead.`);
|
|
156
|
+
}
|
|
157
|
+
headers[header] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let ownDomains = [];
|
|
162
|
+
if (variant.ownDomains !== undefined) {
|
|
163
|
+
const parsed = Array.isArray(variant.ownDomains) && variant.ownDomains.every((d) => typeof d === "string") ? parseOwnDomains(variant.ownDomains) : null;
|
|
164
|
+
if (!parsed) throw new MatrixError(`${at}.ownDomains must be a list of domains without spaces`);
|
|
165
|
+
ownDomains = parsed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { name, storageState, headers, ownDomains };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The headers of the command line and of the variant; the variant wins on the same header, whatever its case
|
|
173
|
+
function mergeHeaders(fromFlags = {}, fromVariant) {
|
|
174
|
+
const names = new Set(Object.keys(fromVariant).map((name) => name.toLowerCase()));
|
|
175
|
+
const kept = Object.entries(fromFlags).filter(([name]) => !names.has(name.toLowerCase()));
|
|
176
|
+
return Object.fromEntries([...kept, ...Object.entries(fromVariant)]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The file at `path`, with the flags of the command line: cpu, network, storageState, headers,
|
|
180
|
+
// ownDomains, runs and medianBy. Returns the runs, the metric of the median, the combinations in the
|
|
181
|
+
// order they run (URL, then device, then variant) and the warnings to print.
|
|
182
|
+
export function loadMatrix(path, flags = {}) {
|
|
183
|
+
if (!existsSync(path)) throw new Error(`Matrix file not found: ${path}`);
|
|
184
|
+
let matrix;
|
|
185
|
+
try {
|
|
186
|
+
matrix = JSON.parse(readFileSync(path, "utf8"));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new MatrixError(`the file is not valid JSON (${err.message})`);
|
|
189
|
+
}
|
|
190
|
+
if (!isObject(matrix)) throw new MatrixError("the file must hold an object, such as { \"urls\": [\"https://example.com/\"] }");
|
|
191
|
+
checkKeys(matrix, KEYS.matrix);
|
|
192
|
+
|
|
193
|
+
if (matrix.runs !== undefined && !(Number.isInteger(matrix.runs) && matrix.runs >= 1 && matrix.runs <= 9)) {
|
|
194
|
+
throw new MatrixError("runs must be a whole number from 1 to 9");
|
|
195
|
+
}
|
|
196
|
+
if (matrix.medianBy !== undefined && !MEDIAN_METRICS.includes(matrix.medianBy)) {
|
|
197
|
+
throw new MatrixError(`medianBy "${matrix.medianBy}" is not a metric. Choose from: ${MEDIAN_METRICS.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const warnings = [];
|
|
201
|
+
const urls = readUrls(matrix.urls);
|
|
202
|
+
const devices = readDevices(matrix.devices, flags);
|
|
203
|
+
const variants = readVariants(matrix.variants, dirname(resolve(path)), warnings);
|
|
204
|
+
|
|
205
|
+
const combinations = [];
|
|
206
|
+
for (const { name, url } of urls) {
|
|
207
|
+
for (const device of devices) {
|
|
208
|
+
for (const variant of variants) {
|
|
209
|
+
combinations.push({
|
|
210
|
+
id: [name, device.name, variant.name].filter(Boolean).join(" · "),
|
|
211
|
+
name,
|
|
212
|
+
url,
|
|
213
|
+
device: device.name,
|
|
214
|
+
...(variant.name ? { variant: variant.name } : {}),
|
|
215
|
+
viewport: device.viewport,
|
|
216
|
+
cpu: device.cpu,
|
|
217
|
+
network: device.network,
|
|
218
|
+
storageState: variant.storageState ?? flags.storageState,
|
|
219
|
+
headers: mergeHeaders(flags.headers, variant.headers),
|
|
220
|
+
ownDomains: [...(flags.ownDomains ?? []), ...variant.ownDomains],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { runs: flags.runs ?? matrix.runs ?? 1, medianBy: flags.medianBy ?? matrix.medianBy, combinations, warnings };
|
|
227
|
+
}
|
package/src/ratings.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Good / needs-improvement upper bounds from web.dev/articles/vitals, and the ones web.dev gives for
|
|
2
|
+
// FCP and TTFB, the same the snippets rate with.
|
|
3
|
+
export const THRESHOLDS = {
|
|
4
|
+
LCP: { good: 2500, poor: 4000 },
|
|
5
|
+
FCP: { good: 1800, poor: 3000 },
|
|
6
|
+
TTFB: { good: 800, poor: 1800 },
|
|
7
|
+
CLS: { good: 0.1, poor: 0.25 },
|
|
8
|
+
INP: { good: 200, poor: 500 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// "good", "needs-improvement" or "poor", or undefined for a value without thresholds
|
|
12
|
+
export function rate(metric, value) {
|
|
13
|
+
const thresholds = THRESHOLDS[metric];
|
|
14
|
+
if (!thresholds) return undefined;
|
|
15
|
+
return value <= thresholds.good ? "good" : value <= thresholds.poor ? "needs-improvement" : "poor";
|
|
16
|
+
}
|