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/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
- function checkBudgets(results, values) {
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
- if (!url) {
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 (values.snippet) {
207
- const items = buildSnippetItem(values);
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 workflowName = values.workflow ?? "core-web-vitals";
211
- const workflow = WORKFLOWS[workflowName];
212
- if (!workflow) fail(`Unknown workflow: ${workflowName}`);
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.results, values);
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 anyError = payload.results.some((r) => r.status === "error");
261
- // A follow-up (a result with a reason) only informs: its error issues show in the report and
262
- // leave the exit code to the budgets and to the steps the workflow runs itself.
263
- const anyAuditViolation = payload.results.some(
264
- (r) => !r.reason && Array.isArray(r.issues) && r.issues.some((i) => i.severity === "error"),
265
- );
266
- process.exit(anyError || anyAuditViolation ? 1 : 0);
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) => {
@@ -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", good: 2500, poor: 4000 },
7
- { name: "CLS", key: "cumulative_layout_shift", unit: "score", good: 0.1, poor: 0.25 },
8
- { name: "INP", key: "interaction_to_next_paint", unit: "ms", good: 200, poor: 500 },
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(p75, m), unit: m.unit };
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
+ }