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.
- package/README.md +150 -12
- package/package.json +3 -2
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +190 -188
- package/snippets/Loading/Content-Visibility.js +273 -264
- package/snippets/SCHEMA.md +13 -0
- package/snippets/_view/index.js +1 -0
- package/snippets/_view/report.js +60 -0
- package/src/baseline.js +21 -6
- package/src/bin.js +143 -41
- package/src/blocking.js +29 -8
- package/src/interactions.js +63 -6
- package/src/live-baseline.js +76 -0
- package/src/mcp/server.js +29 -17
- package/src/mcp/steps.js +12 -0
- package/src/overview.js +20 -0
- package/src/reporters/baseline-view.js +39 -9
- package/src/reporters/human.js +12 -3
- package/src/reporters/markdown.js +44 -11
- package/src/view.js +1 -1
|
@@ -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).
|
|
103
|
-
// the
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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>
|
|
77
|
-
output of an earlier run
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
if (matrixPath && baseline
|
|
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,
|
|
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
|
-
|
|
335
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
process.exit(
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
const
|
|
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")
|
|
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 (
|
|
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
|
|
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
|
}
|
package/src/interactions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|