webperf-snippets 1.15.0 → 1.17.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.
@@ -0,0 +1,60 @@
1
+ // What a set of results found, the same for the audit of the extension and the reports of the CLI: the
2
+ // issues of each result by severity, the overview of all of them and every issue in one list. Only
3
+ // counts and order: how each one shows them is its own.
4
+
5
+ const SEVERITY = ['error', 'warning', 'info']
6
+ const RATING = { good: 'good', 'needs-improvement': 'needs improvement', poor: 'poor' }
7
+ const NOTHING = 'The snippet returned nothing'
8
+
9
+ // The issues of a result by severity and the worst of them. A result that failed counts as an error,
10
+ // and a rating that is not good as a warning when no issue says more
11
+ export function summarizeIssues(result) {
12
+ if (!result || typeof result !== 'object' || result.status === 'error') {
13
+ return { status: 'error', errors: 1, warnings: 0, infos: 0, top: result?.error ?? NOTHING }
14
+ }
15
+ const issues = Array.isArray(result.issues) ? result.issues : []
16
+ const count = (severity) => issues.filter((i) => i.severity === severity).length
17
+ const worst = [...issues].sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity))[0]
18
+ const summary = { status: result.status, errors: count('error'), warnings: count('warning'), infos: count('info'), top: worst?.message ?? '' }
19
+ if (issues.length === 0 && (result.rating === 'poor' || result.rating === 'needs-improvement')) {
20
+ return { ...summary, warnings: 1, top: `Rated ${RATING[result.rating]}` }
21
+ }
22
+ return summary
23
+ }
24
+
25
+ // How many results, from their summaries, have errors, have warnings and no error, or have nothing to fix
26
+ export function overviewOf(summaries) {
27
+ const withErrors = summaries.filter((s) => s.errors > 0).length
28
+ const withWarnings = summaries.filter((s) => s.errors === 0 && s.warnings > 0).length
29
+ return { snippets: summaries.length, withErrors, withWarnings, withNothingToFix: summaries.length - withErrors - withWarnings }
30
+ }
31
+
32
+ export const describeOverview = ({ snippets, withErrors, withWarnings, withNothingToFix }) =>
33
+ `${snippets} snippet${snippets === 1 ? '' : 's'}: ${withErrors} with errors, ${withWarnings} with warnings, ${withNothingToFix} with nothing to fix`
34
+
35
+ // Every issue of a list of results, in their order, each with the id of its result; a result that
36
+ // failed gives an error issue with its message
37
+ export function issuesOf(results) {
38
+ return results.flatMap((result) => {
39
+ const failed = !result || result.status === 'error'
40
+ const list = failed ? [{ severity: 'error', message: result?.error ?? NOTHING }] : Array.isArray(result.issues) ? result.issues : []
41
+ return list.map((i) => ({ ...(i.code ? { code: i.code } : {}), severity: i.severity, id: result?.id, message: i.message, ...(i.target ? { target: i.target } : {}) }))
42
+ })
43
+ }
44
+
45
+ // Every issue of the results in one list, by severity, each with the id of its result and the path of
46
+ // its snippet: the one the result carries, or the one pathOf gives for it. Array.prototype.sort keeps the
47
+ // order of the results within a severity
48
+ export function issueList(results, pathOf = (result) => result?.path) {
49
+ return results
50
+ .flatMap((result) =>
51
+ issuesOf([result]).map(({ code, severity, id, message, target }) => ({ ...(code ? { code } : {}), severity, id, path: pathOf(result), message, ...(target ? { target } : {}) }))
52
+ )
53
+ .sort((a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity))
54
+ }
55
+
56
+ // The audit as JSON: the results, each with the id and the path of its step, plus the overview and every
57
+ // issue of every result in one list, by severity, with the path of its snippet
58
+ export function auditReport({ url, date, results }) {
59
+ return { workflow: 'audit', url: url ?? null, date, overview: overviewOf(results.map(summarizeIssues)), issues: issueList(results), results }
60
+ }
package/src/baseline.js CHANGED
@@ -99,12 +99,18 @@ function statsOf(base, current, delta) {
99
99
  }
100
100
 
101
101
  // A regression must beat the threshold of its metric and, with five runs a side, be unlikely to come
102
- // from the noise of the runs (p < 0.05). With fewer, when the baseline has several runs, it must beat
103
- // the worst of them: a median inside the range of the baseline is noise the baseline already had.
102
+ // from the noise of the runs (p < 0.05). A change that beats its threshold without that evidence is
103
+ // inconclusive: the runs can tell neither a change nor its absence, so more runs, or another
104
+ // measurement, decide. With fewer runs, when the baseline has several, it must beat the worst of them:
105
+ // a median inside the range of the baseline is noise the baseline already had.
104
106
  function verdict(base, current, threshold, stats) {
105
107
  const worse = current.value - base.value > threshold;
106
108
  const better = base.value - current.value > threshold;
107
- if (stats) return stats.p < ALPHA ? (worse ? "regressed" : better ? "improved" : "unchanged") : "unchanged";
109
+ if (stats) {
110
+ if (!worse && !better) return "unchanged";
111
+ if (stats.p >= ALPHA) return "inconclusive";
112
+ return worse ? "regressed" : "improved";
113
+ }
108
114
  if (worse && (!base.range || current.value > base.range.max)) return "regressed";
109
115
  if (better && (!base.range || current.value < base.range.min)) return "improved";
110
116
  return "unchanged";
@@ -278,12 +284,21 @@ function warningsOf(base, current, metrics) {
278
284
  return warnings;
279
285
  }
280
286
 
287
+ // The metrics whose change beat their threshold for the worse without the evidence of the runs. Every
288
+ // metric is better lower, CLS included, so a positive change is worse.
289
+ export function inconclusiveForTheWorse({ metrics }) {
290
+ return Object.entries(metrics)
291
+ .filter(([, m]) => m.verdict === "inconclusive" && m.delta > 0)
292
+ .map(([metric]) => metric);
293
+ }
294
+
281
295
  // A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
282
- // much to trust its values, in the header of the reports.
283
- export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
296
+ // much to trust its values, in the header of the reports. A baseline read from a file names it; one
297
+ // measured in the same run, a live baseline, has no file, and its URL names it.
298
+ export function compareRun(base, current, { file, live = false, thresholds = DEFAULT_THRESHOLDS }) {
284
299
  const metrics = compareMetrics(base, current, thresholds);
285
300
  return {
286
- file,
301
+ ...(live ? { live: true } : { file }),
287
302
  url: base.url,
288
303
  runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
289
304
  metrics,
package/src/bin.js CHANGED
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { loadSnippet } from "./load-snippet.js";
5
5
  import { resolveSnippetName } from "./snippet-names.js";
6
6
  import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
7
+ import { readInteractionScript, InteractionError } from "./interactions.js";
7
8
  import { WORKFLOWS } from "./workflows/index.js";
8
9
  import { RULES } from "./decision-tree.js";
9
10
  import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
@@ -17,13 +18,15 @@ import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
17
18
  import { parseOwnDomains } from "./own-domains.js";
18
19
  import { MEDIAN_METRICS, measureRuns } from "./runs.js";
19
20
  import { loadMatrix } from "./matrix.js";
20
- import { blockingErrors } from "./blocking.js";
21
+ import { failingIssues, parseFailOn } from "./blocking.js";
21
22
  import { MEASURED_BY, checkBudgets, parseBudgets, unmeasuredMetrics, violationsOf } from "./budgets.js";
22
23
  import { measurementConditions } from "./conditions.js";
24
+ import { pathsOfRun, reportOf } from "./overview.js";
23
25
  import { machineOf } from "./machine.js";
24
26
  import { isHttpUrl } from "./url.js";
25
27
  import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
26
- import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
28
+ import { describeRegressions, describeInconclusive, describeMatrixRegressions, describeMatrixInconclusive, matrixComparisonView } from "./reporters/baseline-view.js";
29
+ import { parseLiveBaseline, measureLiveBaseline } from "./live-baseline.js";
27
30
 
28
31
 
29
32
  const USAGE = `webperf-snippets <url> [options]
@@ -73,13 +76,22 @@ Options:
73
76
  --median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
74
77
  navMs (default: the first of LCP, FCP, TTFB, INP, navMs
75
78
  measured)
76
- --baseline <file> Compare the page, or each combination of a matrix, with the --json
77
- output of an earlier run: which metrics got worse or better beyond
78
- the noise, and which errors are new or fixed. It only informs,
79
- unless --fail-on-regression
79
+ --baseline <file|url> Compare the page, or each combination of a matrix, with the --json
80
+ output of an earlier run, or the page with another URL measured in
81
+ this run, their runs alternating (a live baseline): which metrics got
82
+ worse or better beyond the noise, and which errors are new or fixed.
83
+ It only informs, unless --fail-on-regression
80
84
  --threshold <metric>=<v> The threshold of a metric for --baseline, such as LCP=15%,
81
85
  LCP=200ms or CLS=0.1. Repeat it for more metrics
82
86
  --fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
87
+ --fail-on-inconclusive Exit 1 when a metric is inconclusive for the worse against --baseline:
88
+ over its threshold, without the evidence of the runs
89
+ --max-runs <n> With a live baseline and --runs 5 or more: when a metric is
90
+ inconclusive for the worse, measure both pages up to n runs each (at
91
+ most 9) and compare once more
92
+ --fail-on <level> The issues of the snippets that exit 1: error (default), warning
93
+ (errors and warnings) or never. Budgets, --fail-on-regression
94
+ and runs that fail keep exiting 1
83
95
  --crux-key <key> Add CrUX field data (p75) next to the measured values.
84
96
  --crux-history <weeks> Add the trend of the field data over the last 1 to 40 weeks (needs a CrUX key).
85
97
  Also read from the CRUX_API_KEY environment variable
@@ -107,12 +119,22 @@ Examples:
107
119
  npx webperf-snippets --matrix matrix.json --runs 3 --budget-lcp 2500
108
120
  npx webperf-snippets https://example.com --runs 5 --json > baseline.json
109
121
  npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
122
+ npx webperf-snippets https://pr-123.example.com --runs 5 --max-runs 7 --baseline https://example.com --fail-on-regression
110
123
  npx webperf-snippets --matrix matrix.json --baseline last-week.json
111
124
  npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
112
125
  npx webperf-snippets https://example.com/dashboard --storage-state auth.json
113
126
  npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
114
127
  `;
115
128
 
129
+ // A pipe takes 64 KB at a time and process.exit() does not wait for the rest, so a report larger than
130
+ // that reached `| jq` or a script cut short. The report is written whole before the code after it exits
131
+ const writeReport = (text) => new Promise((resolve) => process.stdout.write(text, () => resolve()));
132
+ // A reader that closes the pipe early, as head does, ends the writing, not the run: no error, and the
133
+ // exit code is the one of the report
134
+ process.stdout.on("error", (err) => {
135
+ if (err.code !== "EPIPE") throw err;
136
+ });
137
+
116
138
  function fail(message, code = 2) {
117
139
  process.stderr.write(`${message}\n`);
118
140
  process.exit(code);
@@ -177,6 +199,9 @@ async function main() {
177
199
  baseline: { type: "string" },
178
200
  threshold: { type: "string", multiple: true },
179
201
  "fail-on-regression": { type: "boolean" },
202
+ "fail-on-inconclusive": { type: "boolean" },
203
+ "max-runs": { type: "string" },
204
+ "fail-on": { type: "string" },
180
205
  "crux-key": { type: "string" },
181
206
  "crux-history": { type: "string" },
182
207
  "report-to": { type: "string" },
@@ -207,15 +232,19 @@ async function main() {
207
232
  const url = positionals[0];
208
233
  const matrixPath = values.matrix;
209
234
  if (url && matrixPath) fail("Use a URL or --matrix, not both");
210
- if (!url && !matrixPath) {
211
- process.stdout.write(USAGE);
212
- process.exit(2);
213
- }
235
+ // To stderr, as every usage error: a pipe or a redirect of stdout gets no usage text as a report
236
+ if (!url && !matrixPath) fail(USAGE.trimEnd());
214
237
  // Without the URL itself, which can carry a token in its query, as a header can
215
238
  if (url && !isHttpUrl(url)) fail("The URL must be an http or https URL, such as https://example.com or http://localhost:3000");
216
239
  if (matrixPath && values.viewport) fail("--viewport does not apply to --matrix: set the viewport of each device in the file");
217
240
 
218
- const waitMs = values.wait ? Number(values.wait) : 3000;
241
+ if (values.json && values.markdown) fail("Use --json or --markdown, not both");
242
+
243
+ // Number("") is 0 and Number("abc") is NaN, which the wait takes as no wait: only digits are a wait
244
+ if (values.wait != null && !/^\d+$/.test(values.wait)) {
245
+ fail(`Invalid --wait "${values.wait}". Use a number of milliseconds, such as 3000, or 0 for no wait`);
246
+ }
247
+ const waitMs = values.wait != null ? Number(values.wait) : 3000;
219
248
  const viewportName = values.viewport ?? "mobile";
220
249
  const viewport = VIEWPORT_PRESETS[viewportName];
221
250
  if (!viewport) {
@@ -235,7 +264,15 @@ async function main() {
235
264
  if (problem) fail(problem);
236
265
  }
237
266
 
238
- const interactScript = values["interact-script"];
267
+ // The steps are read and checked before the browser opens: a wrong file fails at once
268
+ let interactions;
269
+ if (values["interact-script"] != null) {
270
+ try {
271
+ interactions = readInteractionScript(values["interact-script"]);
272
+ } catch (err) {
273
+ fail(err.message);
274
+ }
275
+ }
239
276
  const storageState = values["storage-state"];
240
277
  if (storageState && !existsSync(storageState)) {
241
278
  fail(`Storage state file not found: ${storageState}`);
@@ -276,21 +313,37 @@ async function main() {
276
313
  return cruxWeeks ? { ...crux, history: await fetchCruxHistory({ url, apiKey: cruxKey, formFactor, weeks: cruxWeeks }) } : crux;
277
314
  };
278
315
 
279
- // The baseline is read and checked before the browser opens: a wrong file fails at once
316
+ // The baseline is read and checked before the browser opens: a wrong file fails at once. A URL is a
317
+ // live baseline, measured in this run with the page
280
318
  let baseline;
281
319
  let thresholds;
320
+ let live;
321
+ let maxRuns;
322
+ try {
323
+ ({ live, maxRuns } = parseLiveBaseline({ baseline: values.baseline, matrix: matrixPath, runs: runCount, maxRuns: values["max-runs"] }));
324
+ } catch (err) {
325
+ fail(err.message);
326
+ }
282
327
  if (values.baseline) {
283
328
  try {
284
- baseline = loadBaseline(values.baseline);
329
+ if (!live) baseline = loadBaseline(values.baseline);
285
330
  thresholds = thresholdsWith((values.threshold ?? []).map(parseThreshold));
286
331
  } catch (err) {
287
332
  fail(err.message);
288
333
  }
289
- if (!matrixPath && baseline.kind === "matrix") fail(`${values.baseline} is the baseline of a matrix: compare it with --matrix`);
290
- if (matrixPath && baseline.kind === "page") fail(`${values.baseline} is the baseline of a page: compare it with a URL, not --matrix`);
334
+ if (!matrixPath && baseline?.kind === "matrix") fail(`${values.baseline} is the baseline of a matrix: compare it with --matrix`);
335
+ if (matrixPath && baseline?.kind === "page") fail(`${values.baseline} is the baseline of a page: compare it with a URL, not --matrix`);
291
336
  } else {
292
337
  if (values.threshold) fail("--threshold needs --baseline");
293
338
  if (values["fail-on-regression"]) fail("--fail-on-regression needs --baseline");
339
+ if (values["fail-on-inconclusive"]) fail("--fail-on-inconclusive needs --baseline");
340
+ }
341
+
342
+ let failOn;
343
+ try {
344
+ failOn = parseFailOn(values["fail-on"]);
345
+ } catch (err) {
346
+ fail(err.message);
294
347
  }
295
348
 
296
349
  let budgets;
@@ -303,12 +356,12 @@ async function main() {
303
356
  // What each page load runs, a snippet or a workflow, under the conditions of the load
304
357
  const items = values.snippet ? buildSnippetItem(values) : null;
305
358
  const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
306
- if (!values.snippet && !workflow) fail(`Unknown workflow: ${values.workflow}`);
359
+ if (!values.snippet && !workflow) fail(`Unknown workflow: "${values.workflow}". Choose from: ${Object.keys(WORKFLOWS).join(", ")}`);
307
360
  // A budget of a metric that no snippet of the run measures could never pass: a usage error
308
361
  const [unmeasured] = unmeasuredMetrics(budgets, items ? [items[0].path] : workflow.steps.map((step) => step.path));
309
362
  if (unmeasured) fail(budgetUsage(unmeasured, items ? `--snippet ${values.snippet}` : `the ${workflow.id} workflow`));
310
363
  const measureWith = (conditions) => {
311
- const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
364
+ const options = { waitMs, headless: !values.headed, interactions, ...conditions };
312
365
  return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
313
366
  };
314
367
  // What the JSON records of how each page was measured, for a later run to compare with. The machine is
@@ -318,6 +371,9 @@ async function main() {
318
371
  const conditionsFor = ({ viewport, cpu, network, browser }) =>
319
372
  measurementConditions({ viewport, waitMs, cpu, network, browser, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
320
373
 
374
+ // The path of the snippet of each result, for the list of issues of the report
375
+ const pathOf = pathsOfRun({ items: items ?? [], workflow, rules: RULES });
376
+
321
377
  if (matrixPath) {
322
378
  let matrix;
323
379
  try {
@@ -325,26 +381,60 @@ async function main() {
325
381
  } catch (err) {
326
382
  fail(err.message);
327
383
  }
328
- await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf });
384
+ await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, pathOf, failOn, baseline, thresholds, budgets, cruxKey, fieldDataOf });
329
385
  return;
330
386
  }
331
387
 
332
388
  const conditions = { url, viewport, storageState, headers, cpu, network, ownDomains };
389
+ // When the measurement started, in UTC
390
+ const date = new Date().toISOString();
391
+ // A page as the report gives it: what the results found goes before them, so a reader, or an agent,
392
+ // sees it first, and how it was measured after them
393
+ const pageOf = ({ browser, ...measured }) => ({
394
+ url: measured.url,
395
+ date,
396
+ navMs: measured.navMs,
397
+ ...reportOf(measured.results, pathOf),
398
+ ...measured,
399
+ conditions: conditionsFor({ viewport: viewportName, cpu, network, browser }),
400
+ });
401
+ // The median run of several, with their runs field; one run without --runs has none, as always
402
+ const medianOf = (median) => (runCount == null ? median.payload : { ...median.payload, runs: median.runs });
403
+
333
404
  let payload;
334
- if (runCount == null) {
335
- payload = await measureWith(conditions);
405
+ let comparison;
406
+ if (live) {
407
+ // Both pages under the same conditions, their runs alternating, and the comparison of the CLI
408
+ const urls = { baseline: values.baseline, current: url };
409
+ const measuredLive = await measureLiveBaseline((side) => measureWith({ ...conditions, url: urls[side] }), {
410
+ runs: runCount ?? 1,
411
+ maxRuns,
412
+ medianBy,
413
+ compare: (base, current) => compareRun(pageOf(medianOf(base)), pageOf(medianOf(current)), { live: true, thresholds }),
414
+ isFatal: (err) => err instanceof MissingBrowserError,
415
+ onRun: ({ side, run, runs, step, steps }) => process.stderr.write(`[${step}/${steps}] ${side} ${urls[side]} · run ${run}/${runs}\n`),
416
+ onExtend: ({ after, metrics, maxRuns }) =>
417
+ process.stderr.write(`${metrics.join(", ")} inconclusive for the worse after ${after} runs a side: measuring both pages up to ${maxRuns} runs each\n`),
418
+ });
419
+ if (!measuredLive.current.payload) throw new Error(`Every run failed: ${measuredLive.current.errors.join("; ")}`);
420
+ if (!measuredLive.baseline.payload) throw new Error(`Every run of the baseline ${values.baseline} failed: ${measuredLive.baseline.errors.join("; ")}`);
421
+ payload = pageOf(medianOf(measuredLive.current));
422
+ // The runs it added, next to the runs of the baseline
423
+ const { metrics, errors, warnings, ...head } = measuredLive.comparison;
424
+ comparison = { ...head, ...(measuredLive.extended ? { extended: measuredLive.extended } : {}), metrics, errors, warnings };
425
+ } else if (runCount == null) {
426
+ payload = pageOf(await measureWith(conditions));
336
427
  } else {
337
428
  const median = await measureRunsWithProgress(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
338
429
  if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
339
- payload = { ...median.payload, runs: median.runs };
430
+ payload = pageOf(medianOf(median));
340
431
  }
341
- const { browser, ...measured } = payload;
342
- payload = { ...measured, conditions: conditionsFor({ viewport: viewportName, cpu, network, browser }) };
343
432
 
344
433
  if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
345
434
 
346
- if (baseline) {
347
- payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
435
+ if (baseline) comparison = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
436
+ if (comparison) {
437
+ payload.baseline = comparison;
348
438
  for (const warning of payload.baseline.warnings) process.stderr.write(`Warning: ${warning}\n`);
349
439
  }
350
440
 
@@ -359,7 +449,7 @@ async function main() {
359
449
  } else {
360
450
  output = reportHuman({ ...payload, verbose: values.verbose });
361
451
  }
362
- process.stdout.write(output + "\n");
452
+ await writeReport(output + "\n");
363
453
 
364
454
  if (reportUrl) {
365
455
  const outcome = await reportTo({
@@ -371,6 +461,8 @@ async function main() {
371
461
  ...(values.snippet ? { snippet: values.snippet } : {}),
372
462
  timestamp: new Date().toISOString(),
373
463
  navMs: payload.navMs,
464
+ overview: payload.overview,
465
+ issues: payload.issues,
374
466
  results: payload.results,
375
467
  ...(payload.budgets ? { budgets: payload.budgets } : {}),
376
468
  meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
@@ -389,13 +481,15 @@ async function main() {
389
481
  for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
390
482
  process.exit(1);
391
483
  }
392
- // Without --fail-on-regression the comparison only informs, and the exit code is the one of today
484
+ // Without --fail-on-regression or --fail-on-inconclusive the comparison only informs, and the exit
485
+ // code is the one of today. A live baseline is named by its URL, a file by its path
486
+ const against = live ? values.baseline : baseline?.file;
393
487
  const regressions = values["fail-on-regression"] ? describeRegressions(payload.baseline) : [];
394
- if (regressions.length > 0) {
395
- for (const r of regressions) process.stderr.write(`Regression against ${baseline.file}: ${r}\n`);
396
- process.exit(1);
397
- }
398
- process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
488
+ const inconclusive = values["fail-on-inconclusive"] ? describeInconclusive(payload.baseline) : [];
489
+ for (const r of regressions) process.stderr.write(`Regression against ${against}: ${r}\n`);
490
+ for (const i of inconclusive) process.stderr.write(`Inconclusive against ${against}: ${i}\n`);
491
+ if (regressions.length > 0 || inconclusive.length > 0) process.exit(1);
492
+ process.exit(failingIssues(payload.results, failOn).length > 0 ? 1 : 0);
399
493
  }
400
494
 
401
495
  // Each run is reported on stderr as it starts, so stdout keeps only the report
@@ -411,7 +505,7 @@ const measureRunsWithProgress = (measure, count, medianBy, progress) =>
411
505
 
412
506
  // Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
413
507
  // its median run, and the exit code follows the same rules as one page, for every combination.
414
- async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf }) {
508
+ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, pathOf, failOn, baseline, thresholds, budgets, cruxKey, fieldDataOf }) {
415
509
  for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
416
510
  const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
417
511
 
@@ -419,15 +513,17 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
419
513
  const width = String(total).length;
420
514
  let started = 0;
421
515
  const combinations = [];
516
+ const startedAt = new Date().toISOString();
422
517
  for (const c of matrix.combinations) {
518
+ const date = new Date().toISOString();
423
519
  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 };
424
520
  const median = await measureRunsWithProgress(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
425
521
  const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
426
522
  const { browser, ...measured } = median.payload ?? {};
427
523
  combinations.push(
428
524
  median.payload
429
- ? { ...identity, ...measured, runs: median.runs, conditions: conditionsFor({ ...c, browser }) }
430
- : { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
525
+ ? { ...identity, date, navMs: measured.navMs, ...reportOf(measured.results, pathOf), ...measured, runs: median.runs, conditions: conditionsFor({ ...c, browser }) }
526
+ : { ...identity, date, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
431
527
  );
432
528
  }
433
529
 
@@ -456,8 +552,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
456
552
 
457
553
  const withBaseline = comparison ? { baseline: comparison } : {};
458
554
  const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose, ...withBaseline };
459
- const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, ...withBaseline, combinations }) : reportMatrixHuman(report);
460
- process.stdout.write(output + "\n");
555
+ const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, date: startedAt, runs: matrix.runs, ...withBaseline, combinations }) : reportMatrixHuman(report);
556
+ await writeReport(output + "\n");
461
557
 
462
558
  // One report for each combination, with the body of one page and the device, variant and runs of
463
559
  // the combination. The headers are conditions of the load, never part of the body.
@@ -472,6 +568,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
472
568
  ...(values.snippet ? { snippet: values.snippet } : {}),
473
569
  timestamp: new Date().toISOString(),
474
570
  navMs: c.navMs,
571
+ overview: c.overview,
572
+ issues: c.issues,
475
573
  results: c.results,
476
574
  ...(c.budgets ? { budgets: c.budgets } : {}),
477
575
  meta: {
@@ -495,15 +593,19 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
495
593
 
496
594
  const violations = combinations.flatMap((c) => violationsOf(c.budgets ?? []).map((v) => `${v} (${c.id})`));
497
595
  for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
498
- const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
596
+ const failed = combinations.some((c) => c.error || failingIssues(c.results, failOn).length > 0);
499
597
  // Without --fail-on-regression the comparison only informs
500
598
  const regressions = values["fail-on-regression"] ? describeMatrixRegressions(combinations) : [];
501
599
  for (const r of regressions) process.stderr.write(`Regression against ${baseline.file} in ${r}\n`);
502
- process.exit(violations.length > 0 || failed || regressions.length > 0 ? 1 : 0);
600
+ const inconclusive = values["fail-on-inconclusive"] ? describeMatrixInconclusive(combinations) : [];
601
+ for (const i of inconclusive) process.stderr.write(`Inconclusive against ${baseline.file} in ${i}\n`);
602
+ process.exit(violations.length > 0 || failed || regressions.length > 0 || inconclusive.length > 0 ? 1 : 0);
503
603
  }
504
604
 
505
605
  main().catch((err) => {
506
606
  // A missing Chromium is a step of the setup, not a bug: the message says what to run, without a stack
507
- process.stderr.write(err instanceof MissingBrowserError ? `${err.message}\n` : `Error: ${err.stack ?? err.message}\n`);
607
+ // Nor is a step of --interact-script that the page did not take: its message names the step
608
+ const known = err instanceof MissingBrowserError || err instanceof InteractionError;
609
+ process.stderr.write(known ? `${err.message}\n` : `Error: ${err.stack ?? err.message}\n`);
508
610
  process.exit(1);
509
611
  });
package/src/blocking.js CHANGED
@@ -1,14 +1,35 @@
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 = [];
1
+ // The results that make the command exit with 1: a snippet that failed, and an issue of the severities
2
+ // that count in a step the workflow runs itself. A follow-up (a result with a reason) only informs: its
3
+ // issues show in the report and leave the exit code to the budgets and to the steps.
4
+ function failing(results, severities) {
5
+ const issues = [];
6
6
  for (const r of results) {
7
- if (r.status === "error") errors.push({ id: r.id, message: r.error });
7
+ if (r.status === "error") issues.push({ id: r.id, message: r.error });
8
8
  if (r.reason || !Array.isArray(r.issues)) continue;
9
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 } : {}) });
10
+ if (severities.includes(issue.severity)) {
11
+ issues.push({ id: r.id, message: issue.message, ...(issue.code ? { code: issue.code } : {}), ...(issue.target ? { target: issue.target } : {}) });
12
+ }
11
13
  }
12
14
  }
13
- return errors;
15
+ return issues;
16
+ }
17
+
18
+ // The errors that decide the exit code by default, and the ones --baseline compares
19
+ export const blockingErrors = (results) => failing(results, ["error"]);
20
+
21
+ // What --fail-on takes: the issues that make the command exit with 1. error, the default, is the errors
22
+ // above; warning adds the warnings of the steps; never leaves the exit code to the budgets,
23
+ // --fail-on-regression and the runs that fail.
24
+ export const FAIL_ON = ["error", "warning", "never"];
25
+
26
+ export function parseFailOn(value) {
27
+ if (value === undefined) return "error";
28
+ if (FAIL_ON.includes(value)) return value;
29
+ throw new Error(`--fail-on must be error, warning or never, not "${value}"`);
30
+ }
31
+
32
+ export function failingIssues(results, failOn = "error") {
33
+ if (failOn === "never") return [];
34
+ return failing(results, failOn === "warning" ? ["error", "warning"] : ["error"]);
14
35
  }
@@ -1,18 +1,23 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, existsSync } from "node:fs";
2
+
3
+ // How long a step waits for its element. Playwright waits 30 s, which is a long silence for a selector
4
+ // with a typo; the pages the MCP server keeps open wait the same 5 s (mcp/sessions.js)
5
+ export const STEP_TIMEOUT_MS = 5000;
2
6
 
3
7
  export async function executeStep(page, step) {
8
+ const timeout = { timeout: STEP_TIMEOUT_MS };
4
9
  switch (step.action) {
5
10
  case "scroll":
6
11
  await page.evaluate((y) => window.scrollBy(0, y), step.y ?? 300);
7
12
  break;
8
13
  case "click":
9
- await page.click(step.selector);
14
+ await page.click(step.selector, timeout);
10
15
  break;
11
16
  case "hover":
12
- await page.hover(step.selector);
17
+ await page.hover(step.selector, timeout);
13
18
  break;
14
19
  case "type":
15
- await page.type(step.selector, step.text);
20
+ await page.type(step.selector, step.text, timeout);
16
21
  break;
17
22
  case "wait":
18
23
  await page.waitForTimeout(step.ms);
@@ -27,6 +32,52 @@ export function loadInteractionSteps(source) {
27
32
  return Array.isArray(source) ? source : JSON.parse(readFileSync(source, "utf8")).interactions;
28
33
  }
29
34
 
35
+ const ACTIONS = "click, hover, type, scroll or wait";
36
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
37
+ // What each action needs, as the schema of the steps of the MCP server (mcp/steps.js), which a test
38
+ // keeps in agreement with this
39
+ const STEP_PROBLEM = {
40
+ click: (step) => typeof step.selector !== "string" && 'needs a "selector", the CSS selector of its element',
41
+ hover: (step) => STEP_PROBLEM.click(step),
42
+ type: (step) => STEP_PROBLEM.click(step) || (typeof step.text !== "string" && 'needs a "text", the text to type'),
43
+ scroll: (step) => step.y !== undefined && !Number.isInteger(step.y) && 'takes "y" as a whole number of pixels',
44
+ wait: (step) => !(Number.isInteger(step.ms) && step.ms >= 0) && 'needs "ms", a whole number of milliseconds of at least 0',
45
+ };
46
+
47
+ // What is wrong in a list of steps, or null: the first problem, naming its step from 1
48
+ export function stepsProblem(steps) {
49
+ if (!Array.isArray(steps)) return 'it needs a list of steps, as { "interactions": [{ "action": "click", "selector": "#buy" }] }';
50
+ if (steps.length === 0) return "it has no steps";
51
+ for (const [i, step] of steps.entries()) {
52
+ const name = `step ${i + 1}`;
53
+ if (!isObject(step)) return `${name} is not an object, such as { "action": "click", "selector": "#buy" }`;
54
+ if (step.action === undefined) return `${name} has no action. Use ${ACTIONS}`;
55
+ if (!Object.hasOwn(STEP_PROBLEM, step.action)) return `${name} has the unknown action ${JSON.stringify(step.action)}. Use ${ACTIONS}`;
56
+ const problem = STEP_PROBLEM[step.action](step);
57
+ if (problem) return `${name} (${step.action}) ${problem}`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // The steps of the file of --interact-script, checked: an error here is one of usage, known before
63
+ // the browser opens, and its message names the file and the step
64
+ export function readInteractionScript(path) {
65
+ if (!existsSync(path)) throw new Error(`Interaction script not found: ${path}`);
66
+ let content;
67
+ try {
68
+ content = JSON.parse(readFileSync(path, "utf8"));
69
+ } catch (err) {
70
+ throw new Error(`Invalid --interact-script ${path}: it is not valid JSON (${err.message})`);
71
+ }
72
+ const problem = stepsProblem(isObject(content) ? content.interactions : undefined);
73
+ if (problem) throw new Error(`Invalid --interact-script ${path}: ${problem}`);
74
+ return content.interactions;
75
+ }
76
+
77
+ // A step that the page did not take: an element that is not there, hidden or covered. The page is the
78
+ // cause, not the CLI, so the report is its message, without a stack
79
+ export class InteractionError extends Error {}
80
+
30
81
  // A click or a key press creates the events that Interactions and Input-Latency-Breakdown measure;
31
82
  // a scroll, a hover and a wait do not.
32
83
  export function isInputStep(step) {
@@ -34,8 +85,14 @@ export function isInputStep(step) {
34
85
  }
35
86
 
36
87
  export async function runInteractions(page, source) {
37
- for (const step of loadInteractionSteps(source)) {
38
- await executeStep(page, step);
88
+ for (const [i, step] of loadInteractionSteps(source).entries()) {
89
+ try {
90
+ await executeStep(page, step);
91
+ } catch (err) {
92
+ const target = step.selector === undefined ? step.action : `${step.action} ${JSON.stringify(step.selector)}`;
93
+ // The first line: Playwright adds its call log after it
94
+ throw new InteractionError(`Step ${i + 1} of the interactions (${target}) failed: ${err.message.split("\n")[0]}`, { cause: err });
95
+ }
39
96
  }
40
97
  }
41
98