webperf-snippets 1.8.0 → 1.9.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 +10 -8
- package/package.json +1 -1
- package/src/baseline.js +40 -10
- package/src/bin.js +5 -2
- package/src/conditions.js +2 -2
- package/src/machine.js +42 -0
- package/src/reporters/baseline-view.js +13 -0
- package/src/reporters/human.js +10 -7
- package/src/reporters/markdown.js +9 -7
- package/src/reporters/matrix-view.js +9 -1
- package/src/runs.js +21 -1
- package/src/stats.js +108 -0
package/README.md
CHANGED
|
@@ -70,10 +70,10 @@ JSON output (for piping into `jq` or CI):
|
|
|
70
70
|
npx webperf-snippets https://example.com --json
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait
|
|
73
|
+
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.9.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
159
|
"navMs": 1240,
|
|
160
160
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.9.0" }
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
@@ -190,7 +190,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
|
|
|
190
190
|
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and the JSON output carries them in `runs`:
|
|
193
|
+
The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and how much the runs agree, and the JSON output carries them in `runs`:
|
|
194
194
|
|
|
195
195
|
```json
|
|
196
196
|
"runs": {
|
|
@@ -199,12 +199,12 @@ The median run is the one with the median LCP. A workflow without LCP uses the f
|
|
|
199
199
|
"medianRun": 3,
|
|
200
200
|
"medianBy": "LCP",
|
|
201
201
|
"summary": {
|
|
202
|
-
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
|
|
202
|
+
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms", "cv": 11.6, "agreement": "noisy" }
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
`medianRun` counts from 1, and `values` keeps the order of the runs. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
|
|
207
|
+
`medianRun` counts from 1, and `values` keeps the order of the runs. `cv` is the coefficient of variation of the runs, their standard deviation over their mean in percent, and `agreement` its band: `consistent` up to 5 %, `variable` up to 10 % and `noisy` above, where one median says little and more runs help. Runs that vary by less than half of the minimum change of `--baseline` (50 ms for LCP, FCP and TTFB, 25 ms for INP, 0.025 for CLS) are `consistent` whatever their CV, since a local TTFB of 3 to 18 ms has a CV of 64 %. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
|
|
208
208
|
|
|
209
209
|
Measure several pages, on several devices and in several variants, in one command with `--matrix <file>`. The file lists them, and the CLI measures every URL on every device and in every variant, each combination `runs` times, one load after the other:
|
|
210
210
|
|
|
@@ -238,7 +238,7 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
|
238
238
|
|
|
239
239
|
The options of the command line apply to every combination: `--workflow`, `--snippet`, `--wait`, `--interact-script`, `--header`, `--own-domains`, `--storage-state`, `--cpu`, `--network` and the budgets. The file replaces `cpu`, `network` and `storageState` where it sets them, and its `headers` and `ownDomains` add to the ones of the command line, the variant winning on the same header. A matrix file is meant to be versioned, so pass secrets, such as the token of an access proxy, with `--header` and an environment variable; a header of the file whose name looks like a secret raises a warning. A key the CLI does not know is an error, so a typo does not measure without its condition.
|
|
240
240
|
|
|
241
|
-
The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants), `runs` and `conditions`, with the viewport of its device. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
|
|
241
|
+
The report is a table with one row per combination and the rated median of each metric, a variable or noisy one followed by the coefficient of variation of its runs (`🟡 2.71s ±14%`), then the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants), `runs` and `conditions`, with the viewport of its device. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
|
|
242
242
|
|
|
243
243
|
With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
|
|
244
244
|
|
|
@@ -257,6 +257,8 @@ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.jso
|
|
|
257
257
|
|
|
258
258
|
LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is unchanged.
|
|
259
259
|
|
|
260
|
+
With five runs or more on each side, the range gives way to a test of the runs themselves: a change that beats its threshold also needs a one-sided Mann-Whitney U p-value under 0.05, the chance that runs of an unchanged page would differ this much. A median can move with two slow runs out of five; the test asks whether most runs moved. The JSON of the metric adds `stats`, with `p`, the `shift` (the Hodges-Lehmann estimate, the median of the differences between every run of one side and every run of the other) and its 95 % interval `ci`, and the reports add them to the verdict: `regressed (p = 0.028, +500ms, 95% CI +20ms to +600ms)`, or `unchanged (no evidence: p = 0.35)` for a change over the threshold that the runs do not back. Three runs a side can never reach p < 0.05, so under five the range decides.
|
|
261
|
+
|
|
260
262
|
| Metric | Share of the baseline | Minimum |
|
|
261
263
|
| -------------- | --------------------- | ------- |
|
|
262
264
|
| LCP, FCP, TTFB | 10% | 100 ms |
|
|
@@ -282,7 +284,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
|
|
|
282
284
|
}
|
|
283
285
|
```
|
|
284
286
|
|
|
285
|
-
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
|
|
287
|
+
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply.
|
|
286
288
|
|
|
287
289
|
The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
|
|
288
290
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
package/src/baseline.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { blockingErrors } from "./blocking.js";
|
|
3
3
|
import { describeThrottling } from "./throttling.js";
|
|
4
|
+
import { mannWhitneyGreater, hodgesLehmann } from "./stats.js";
|
|
4
5
|
|
|
5
6
|
// A measurement compared with an earlier one, its baseline: which metrics got worse or better beyond
|
|
6
7
|
// the noise of the lab, which errors appeared or went away, and what differs in how both were
|
|
@@ -71,23 +72,40 @@ export function thresholdsWith(overrides) {
|
|
|
71
72
|
|
|
72
73
|
const round = (n) => Math.round(n * 10000) / 10000;
|
|
73
74
|
|
|
74
|
-
// The value of a metric in a page: the median of its runs, with their range
|
|
75
|
-
// more, or its one result, as runs.js picks it
|
|
75
|
+
// The value of a metric in a page: the median of its runs, with their range and their values when there
|
|
76
|
+
// are two or more, or its one result, as runs.js picks it
|
|
76
77
|
function measured(payload, metric) {
|
|
77
78
|
const summary = payload.runs?.summary?.[metric];
|
|
78
79
|
if (summary) {
|
|
79
|
-
const
|
|
80
|
-
return { value: summary.median, ...(
|
|
80
|
+
const values = summary.values.filter((value) => value !== null);
|
|
81
|
+
return { value: summary.median, ...(values.length >= 2 ? { range: { min: summary.min, max: summary.max }, values } : {}) };
|
|
81
82
|
}
|
|
82
83
|
const result = payload.results?.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
|
|
83
84
|
return result ? { value: result.value } : null;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// With five runs or more a side, Mann-Whitney U can tell a change from the noise of the runs; with
|
|
88
|
+
// fewer, its p-value cannot go under 0.05 (three a side give 0.05 at best), so the range decides.
|
|
89
|
+
export const RUNS_FOR_STATISTICS = 5;
|
|
90
|
+
const ALPHA = 0.05;
|
|
91
|
+
|
|
92
|
+
// The evidence of a change: the one-sided p-value in its direction, and the shift with its 95 % interval
|
|
93
|
+
function statsOf(base, current, delta) {
|
|
94
|
+
if ((base.values?.length ?? 0) < RUNS_FOR_STATISTICS || (current.values?.length ?? 0) < RUNS_FOR_STATISTICS) return undefined;
|
|
95
|
+
const { p, method } = delta < 0 ? mannWhitneyGreater(base.values, current.values) : mannWhitneyGreater(current.values, base.values);
|
|
96
|
+
const { shift, ci } = hodgesLehmann(base.values, current.values);
|
|
97
|
+
return { p: round(p), shift: round(shift), ci: ci.map(round), method };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A regression must beat the threshold of its metric and, with five runs a side, be unlikely to come
|
|
101
|
+
// from the noise of the runs (p < 0.05). With fewer, when the baseline has several runs, it must beat
|
|
102
|
+
// the worst of them: a median inside the range of the baseline is noise the baseline already had.
|
|
103
|
+
function verdict(base, current, threshold, stats) {
|
|
104
|
+
const worse = current.value - base.value > threshold;
|
|
105
|
+
const better = base.value - current.value > threshold;
|
|
106
|
+
if (stats) return stats.p < ALPHA ? (worse ? "regressed" : better ? "improved" : "unchanged") : "unchanged";
|
|
107
|
+
if (worse && (!base.range || current.value > base.range.max)) return "regressed";
|
|
108
|
+
if (better && (!base.range || current.value < base.range.min)) return "improved";
|
|
91
109
|
return "unchanged";
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -104,13 +122,15 @@ function compareMetrics(base, current, thresholds) {
|
|
|
104
122
|
const { relative = 0, absolute } = thresholds[metric];
|
|
105
123
|
const threshold = round(Math.max(relative * b.value, absolute));
|
|
106
124
|
const delta = c.value - b.value;
|
|
125
|
+
const stats = statsOf(b, c, delta);
|
|
107
126
|
metrics[metric] = {
|
|
108
127
|
baseline: b.value,
|
|
109
128
|
current: c.value,
|
|
110
129
|
delta: round(delta),
|
|
111
130
|
deltaPercent: b.value === 0 ? null : Math.round((delta / b.value) * 1000) / 10,
|
|
112
131
|
threshold,
|
|
113
|
-
verdict: verdict(b, c, threshold),
|
|
132
|
+
verdict: verdict(b, c, threshold, stats),
|
|
133
|
+
...(stats ? { stats } : {}),
|
|
114
134
|
};
|
|
115
135
|
}
|
|
116
136
|
return metrics;
|
|
@@ -166,6 +186,14 @@ const pathOf = (url) => {
|
|
|
166
186
|
}
|
|
167
187
|
};
|
|
168
188
|
|
|
189
|
+
// Another CPU or other cores give other timings for the same page. A different benchmark index alone
|
|
190
|
+
// does not warn: on the same machine it moves by 40 % with its load.
|
|
191
|
+
function machineWarning(base, current) {
|
|
192
|
+
if (!base?.cpu || !current?.cpu) return null;
|
|
193
|
+
if (base.cpu === current.cpu && base.cores === current.cores) return null;
|
|
194
|
+
return `the baseline was measured on another machine (${base.cpu}, ${base.cores} cores) than this run (${current.cpu}, ${current.cores} cores); timings can differ for that alone`;
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
|
|
170
198
|
function warningsOf(base, current) {
|
|
171
199
|
const warnings = [];
|
|
@@ -183,6 +211,8 @@ function warningsOf(base, current) {
|
|
|
183
211
|
}
|
|
184
212
|
if (runCount(base) < 2) warnings.push("the baseline has 1 run, so only the thresholds apply; measure both with --runs");
|
|
185
213
|
else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
|
|
214
|
+
const machine = machineWarning(b?.machine, c?.machine);
|
|
215
|
+
if (machine) warnings.push(machine);
|
|
186
216
|
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
187
217
|
warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
|
|
188
218
|
}
|
package/src/bin.js
CHANGED
|
@@ -20,6 +20,7 @@ import { loadMatrix } from "./matrix.js";
|
|
|
20
20
|
import { blockingErrors } from "./blocking.js";
|
|
21
21
|
import { MEASURED_BY, checkBudgets, parseBudgets, unmeasuredMetrics, violationsOf } from "./budgets.js";
|
|
22
22
|
import { measurementConditions } from "./conditions.js";
|
|
23
|
+
import { machineOf } from "./machine.js";
|
|
23
24
|
import { isHttpUrl } from "./url.js";
|
|
24
25
|
import { loadBaseline, parseThreshold, thresholdsWith, compareRun, compareMatrix } from "./baseline.js";
|
|
25
26
|
import { describeRegressions, describeMatrixRegressions, matrixComparisonView } from "./reporters/baseline-view.js";
|
|
@@ -287,9 +288,11 @@ async function main() {
|
|
|
287
288
|
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
288
289
|
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
289
290
|
};
|
|
290
|
-
// What the JSON records of how each page was measured, for a later run to compare with
|
|
291
|
+
// What the JSON records of how each page was measured, for a later run to compare with. The machine is
|
|
292
|
+
// measured once, before Chromium starts, so its processes do not slow the benchmark down.
|
|
293
|
+
const machine = machineOf();
|
|
291
294
|
const conditionsFor = (viewport) =>
|
|
292
|
-
measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
295
|
+
measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
293
296
|
|
|
294
297
|
if (matrixPath) {
|
|
295
298
|
let matrix;
|
package/src/conditions.js
CHANGED
|
@@ -2,6 +2,6 @@ import { cliVersion } from "./report-to.js";
|
|
|
2
2
|
|
|
3
3
|
// How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
|
|
4
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 };
|
|
5
|
+
export function measurementConditions({ viewport, workflow, snippet, waitMs, machine }) {
|
|
6
|
+
return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion, ...(machine ? { machine } : {}) };
|
|
7
7
|
}
|
package/src/machine.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { arch as osArch, cpus as osCpus, totalmem as osTotalmem } from "node:os";
|
|
2
|
+
|
|
3
|
+
// The machine a measurement ran on, kept in its conditions, so --baseline can say when two were measured
|
|
4
|
+
// on different machines: timings move with the CPU as much as with the page. The machine is told apart by
|
|
5
|
+
// what the operating system reports of it. The benchmark index only informs: on the same machine it
|
|
6
|
+
// moves by 40 % with its load and with the cores the system picks (performance or efficiency).
|
|
7
|
+
|
|
8
|
+
const WINDOW_MS = 40;
|
|
9
|
+
const WINDOWS = 5;
|
|
10
|
+
|
|
11
|
+
// A fixed piece of work: build a string and sort an array, as the benchmark index of Lighthouse builds
|
|
12
|
+
// strings in the page. Its result is returned so the engine cannot drop it.
|
|
13
|
+
function work() {
|
|
14
|
+
let s = "";
|
|
15
|
+
for (let i = 0; i < 2000; i++) s += String.fromCharCode(97 + (i % 26));
|
|
16
|
+
const numbers = Array.from({ length: 200 }, (_, i) => (i * 7919) % 211);
|
|
17
|
+
numbers.sort((a, b) => a - b);
|
|
18
|
+
return s.length + numbers[0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// How many times the work fits in 40 ms, the best of five windows: the first one also warms up the
|
|
22
|
+
// engine, and the best one is the least touched by other work of the machine. In Node, so --cpu, which
|
|
23
|
+
// throttles the page, leaves it alone: it measures the machine. Alone, ten in a row vary by about 6 %.
|
|
24
|
+
export function benchmarkIndex() {
|
|
25
|
+
let best = 0;
|
|
26
|
+
let sink = 0;
|
|
27
|
+
for (let w = 0; w < WINDOWS; w++) {
|
|
28
|
+
const end = performance.now() + WINDOW_MS;
|
|
29
|
+
let count = 0;
|
|
30
|
+
while (performance.now() < end) {
|
|
31
|
+
sink += work();
|
|
32
|
+
count++;
|
|
33
|
+
}
|
|
34
|
+
best = Math.max(best, count);
|
|
35
|
+
}
|
|
36
|
+
return sink > 0 ? best : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function machineOf({ cpus = osCpus, arch = osArch(), totalmem = osTotalmem, benchmark = benchmarkIndex } = {}) {
|
|
40
|
+
const list = cpus();
|
|
41
|
+
return { cpu: list[0]?.model ?? "unknown", cores: list.length, arch, memoryGB: Math.round(totalmem() / 2 ** 30), benchmarkIndex: benchmark() };
|
|
42
|
+
}
|
|
@@ -15,9 +15,22 @@ function describeChange(metric, { delta, deltaPercent }) {
|
|
|
15
15
|
return `${arrow(delta)} ${ms}${percent}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Three decimals where the verdict is decided, two above it: "0.028", "0.35"
|
|
19
|
+
const pValue = (p) => (p < 0.001 ? "< 0.001" : p < 0.05 ? p.toFixed(3) : p.toFixed(2));
|
|
20
|
+
const amountOf = (metric, n) => (metric === "CLS" ? signed(n, Math.abs(n).toFixed(4)) : signed(n, `${Math.abs(Math.round(n))}ms`));
|
|
21
|
+
|
|
22
|
+
// The evidence of a change measured with five runs a side: its p-value, its shift and the 95 % interval
|
|
23
|
+
// of the shift; for a change over the threshold that stayed unchanged, that the runs gave no evidence
|
|
24
|
+
function evidenceOf(metric, m) {
|
|
25
|
+
const { p, shift, ci } = m.stats;
|
|
26
|
+
if (m.verdict === "unchanged") return Math.abs(m.delta) > m.threshold ? `no evidence: p = ${pValue(p)}` : null;
|
|
27
|
+
return `p = ${pValue(p)}, ${amountOf(metric, shift)}, 95% CI ${amountOf(metric, ci[0])} to ${amountOf(metric, ci[1])}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
// An unchanged metric that moved stayed under its threshold, or beat it inside the range of the runs
|
|
19
31
|
// of the baseline: noise the baseline already had
|
|
20
32
|
function noteOf(metric, m) {
|
|
33
|
+
if (m.stats) return evidenceOf(metric, m) ?? (m.verdict === "unchanged" && m.delta !== 0 ? `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}` : null);
|
|
21
34
|
if (m.verdict !== "unchanged" || m.delta === 0) return null;
|
|
22
35
|
if (Math.abs(m.delta) > m.threshold) return "inside the range of the baseline";
|
|
23
36
|
return `under ${metric === "CLS" ? m.threshold : `${Math.round(m.threshold)}ms`}`;
|
package/src/reporters/human.js
CHANGED
|
@@ -3,7 +3,7 @@ import { describeResult } from "../view.js";
|
|
|
3
3
|
import { isSummaryResult, summaryNumbers } from "./summary-view.js";
|
|
4
4
|
import { fieldDataView } from "../crux.js";
|
|
5
5
|
import { describeThrottling } from "../throttling.js";
|
|
6
|
-
import { describeRuns } from "../runs.js";
|
|
6
|
+
import { describeRuns, describeAgreement } from "../runs.js";
|
|
7
7
|
import { matrixView } from "./matrix-view.js";
|
|
8
8
|
import { baselineView } from "./baseline-view.js";
|
|
9
9
|
import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
|
|
@@ -210,12 +210,13 @@ function renderFieldData(crux, results) {
|
|
|
210
210
|
return lines;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
// The runs line, and the median, min and max of each metric across the runs
|
|
213
|
+
// The runs line, and the median, min and max of each metric across the runs, with how much they agree
|
|
214
214
|
function renderRuns(runs) {
|
|
215
215
|
const lines = [styleText("dim", describeRuns(runs))];
|
|
216
|
-
for (const [metric,
|
|
217
|
-
const
|
|
218
|
-
|
|
216
|
+
for (const [metric, value] of Object.entries(runs.summary)) {
|
|
217
|
+
const { median, min, max, unit } = value;
|
|
218
|
+
const cell = (label, v) => pad(`${label} ${formatValue(v, unit)}`, 16);
|
|
219
|
+
lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max)}${describeAgreement(value)}`.trimEnd()));
|
|
219
220
|
}
|
|
220
221
|
return lines;
|
|
221
222
|
}
|
|
@@ -274,7 +275,7 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
274
275
|
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
275
276
|
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
276
277
|
export function reportMatrixHuman(report) {
|
|
277
|
-
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
278
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
|
|
278
279
|
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
279
280
|
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
280
281
|
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
@@ -292,11 +293,13 @@ export function reportMatrixHuman(report) {
|
|
|
292
293
|
continue;
|
|
293
294
|
}
|
|
294
295
|
const drawn = cells.map((cell, i) => {
|
|
295
|
-
const
|
|
296
|
+
const width = legend ? 18 : 14;
|
|
297
|
+
const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}`), width) : pad("·", width);
|
|
296
298
|
return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
|
|
297
299
|
});
|
|
298
300
|
lines.push(row + drawn.join("").trimEnd());
|
|
299
301
|
}
|
|
302
|
+
if (legend) lines.push(styleText("dim", ` ${legend}`));
|
|
300
303
|
|
|
301
304
|
if (errors.length) {
|
|
302
305
|
lines.push("");
|
|
@@ -2,7 +2,7 @@ import { fieldDataView } from "../crux.js";
|
|
|
2
2
|
import { describeResult } from "../view.js";
|
|
3
3
|
import { isSummaryResult, summaryNumbers } from "./summary-view.js";
|
|
4
4
|
import { describeThrottling } from "../throttling.js";
|
|
5
|
-
import { describeRuns } from "../runs.js";
|
|
5
|
+
import { describeRuns, describeAgreement } from "../runs.js";
|
|
6
6
|
import { matrixView } from "./matrix-view.js";
|
|
7
7
|
import { baselineView } from "./baseline-view.js";
|
|
8
8
|
import { budgetRows, matrixBudgetFailures } from "./budgets-view.js";
|
|
@@ -115,11 +115,12 @@ function renderFieldData(crux, results) {
|
|
|
115
115
|
];
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// The median, min and max of each metric across the runs
|
|
118
|
+
// The median, min and max of each metric across the runs, and how much they agree
|
|
119
119
|
function renderRuns(runs) {
|
|
120
|
-
const lines = ["| Metric | Median | Min | Max |", "
|
|
121
|
-
for (const [metric,
|
|
122
|
-
|
|
120
|
+
const lines = ["| Metric | Median | Min | Max | Runs |", "|--------|--------|-----|-----|------|"];
|
|
121
|
+
for (const [metric, value] of Object.entries(runs.summary)) {
|
|
122
|
+
const { median, min, max, unit } = value;
|
|
123
|
+
lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} | ${describeAgreement(value) || "·"} |`);
|
|
123
124
|
}
|
|
124
125
|
return lines;
|
|
125
126
|
}
|
|
@@ -196,7 +197,7 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
196
197
|
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
197
198
|
// comment readable.
|
|
198
199
|
export function reportMatrixMarkdown(report) {
|
|
199
|
-
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
200
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
|
|
200
201
|
// With --baseline, a change column follows each metric
|
|
201
202
|
const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
|
|
202
203
|
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
|
|
@@ -208,12 +209,13 @@ export function reportMatrixMarkdown(report) {
|
|
|
208
209
|
for (const { combination: c, cells, changes } of rows) {
|
|
209
210
|
const drawn = cells
|
|
210
211
|
? cells.flatMap((cell, i) => {
|
|
211
|
-
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
|
|
212
|
+
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}` : "·";
|
|
212
213
|
return changes ? [value, changes[i].text] : [value];
|
|
213
214
|
})
|
|
214
215
|
: ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
|
|
215
216
|
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
216
217
|
}
|
|
218
|
+
if (legend) lines.push("", `> ${legend}`);
|
|
217
219
|
|
|
218
220
|
if (errors.length) {
|
|
219
221
|
lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
|
|
@@ -6,6 +6,11 @@ import { changeCell, matrixComparisonView } from "./baseline-view.js";
|
|
|
6
6
|
// The order of the metric columns of the matrix table
|
|
7
7
|
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
8
8
|
|
|
9
|
+
// A median whose runs are variable or noisy carries their coefficient of variation in its cell; the
|
|
10
|
+
// others carry nothing, so the table stays narrow and the marks stand out
|
|
11
|
+
const varies = (value) => value?.agreement === "variable" || value?.agreement === "noisy";
|
|
12
|
+
const LEGEND = "±: the coefficient of variation of the runs of a variable or noisy median";
|
|
13
|
+
|
|
9
14
|
// What the human and markdown reports of a matrix show, so they only differ in how they draw it:
|
|
10
15
|
// the title, the metric columns, one row per combination with the rated median of each metric, and
|
|
11
16
|
// the errors that decide the exit code. With --baseline, each row also has the change of each metric
|
|
@@ -31,10 +36,13 @@ export function matrixView({ matrix, runs, combinations, baseline }) {
|
|
|
31
36
|
? null
|
|
32
37
|
: metrics.map((metric) => {
|
|
33
38
|
const value = c.runs.summary[metric];
|
|
34
|
-
return value
|
|
39
|
+
return value
|
|
40
|
+
? { median: value.median, unit: value.unit, rating: rate(metric, value.median), ...(varies(value) ? { spread: `±${Math.round(value.cv)}%` } : {}) }
|
|
41
|
+
: null;
|
|
35
42
|
}),
|
|
36
43
|
})),
|
|
37
44
|
...(baseline ? { comparison: matrixComparisonView(baseline, combinations) } : {}),
|
|
45
|
+
...(measured.some((c) => metrics.some((metric) => varies(c.runs.summary[metric]))) ? { legend: LEGEND } : {}),
|
|
38
46
|
errors: combinations.flatMap((c) =>
|
|
39
47
|
c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
|
|
40
48
|
),
|
package/src/runs.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_THRESHOLDS } from "./baseline.js";
|
|
2
|
+
|
|
1
3
|
// Several runs of the same measurement, reduced to one: the median run, as WebPageTest reports it.
|
|
2
4
|
// Every result of the report comes from that run, so they agree with each other (the sub-parts of
|
|
3
5
|
// the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
|
|
@@ -24,6 +26,24 @@ const measures = (runs, metric) => runs.some((run) => typeof valueOf(run, metric
|
|
|
24
26
|
// the two middle ones, so the median is always a run that happened, never an average of two.
|
|
25
27
|
const middle = (sorted) => sorted[Math.floor((sorted.length - 1) / 2)];
|
|
26
28
|
|
|
29
|
+
// How much the runs of a metric agree: their coefficient of variation, the sample standard deviation
|
|
30
|
+
// over the mean, in percent, and its band. Up to 5 % the runs agree; above 10 % one median says little.
|
|
31
|
+
// None for one run, or for runs whose mean is 0, such as a CLS of 0 in every run. Runs that vary by
|
|
32
|
+
// less than half of the change --baseline counts agree whatever their CV: a local TTFB of 3 to 18 ms
|
|
33
|
+
// has a CV of 64 % and a standard deviation of 6 ms.
|
|
34
|
+
function agreementOf(values, metric) {
|
|
35
|
+
if (values.length < 2) return {};
|
|
36
|
+
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
37
|
+
if (mean === 0) return {};
|
|
38
|
+
const sd = Math.sqrt(values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1));
|
|
39
|
+
const cv = Math.round((sd / mean) * 1000) / 10;
|
|
40
|
+
if (sd < DEFAULT_THRESHOLDS[metric].absolute / 2) return { cv, agreement: "consistent" };
|
|
41
|
+
return { cv, agreement: cv <= 5 ? "consistent" : cv <= 10 ? "variable" : "noisy" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// "noisy (CV 14.4%)", for the reports
|
|
45
|
+
export const describeAgreement = ({ cv, agreement }) => (cv === undefined ? "" : `${agreement} (CV ${cv}%)`);
|
|
46
|
+
|
|
27
47
|
// "3 runs, median by LCP: run 2 (1 failed)", for the header of the reports
|
|
28
48
|
export function describeRuns({ count, failed, medianRun, medianBy, medianByRequested }) {
|
|
29
49
|
const by = medianByRequested ? `${medianBy} (no run measured ${medianByRequested})` : medianBy;
|
|
@@ -48,7 +68,7 @@ export function summarizeRuns(runs, { medianBy: requested } = {}) {
|
|
|
48
68
|
const values = runs.map((run) => (run.error ? null : (valueOf(run, metric) ?? null)));
|
|
49
69
|
const sorted = values.filter((value) => value !== null).sort((a, b) => a - b);
|
|
50
70
|
const unit = completed.flatMap((run) => run.results).find((r) => r.metric === metric && r.unit)?.unit;
|
|
51
|
-
summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit };
|
|
71
|
+
summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit, ...agreementOf(sorted, metric) };
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
return {
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Statistics for comparing the runs of two measurements, without a browser: whether one sample tends to
|
|
2
|
+
// be greater than the other (Mann-Whitney U, one-sided) and by how much (the Hodges-Lehmann shift with its
|
|
3
|
+
// 95 % interval). Both work on ranks, so one slow run moves them less than it moves a mean.
|
|
4
|
+
|
|
5
|
+
const EXACT_UP_TO = 16;
|
|
6
|
+
const Z_975 = 1.959963984540054;
|
|
7
|
+
|
|
8
|
+
// The ranks of the pooled values, with the mean rank for ties, and the sizes of the groups of ties
|
|
9
|
+
function rank(values) {
|
|
10
|
+
const order = values.map((value, i) => ({ value, i })).sort((a, b) => a.value - b.value);
|
|
11
|
+
const ranks = new Array(values.length);
|
|
12
|
+
const ties = [];
|
|
13
|
+
for (let start = 0; start < order.length; ) {
|
|
14
|
+
let end = start;
|
|
15
|
+
while (end + 1 < order.length && order[end + 1].value === order[start].value) end++;
|
|
16
|
+
for (let k = start; k <= end; k++) ranks[order[k].i] = (start + end) / 2 + 1;
|
|
17
|
+
if (end > start) ties.push(end - start + 1);
|
|
18
|
+
start = end + 1;
|
|
19
|
+
}
|
|
20
|
+
return { ranks, ties };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// How many splits of m + n distinct values give each U, from U = 0 to m * n. A split with the largest
|
|
24
|
+
// value in the first sample has U of the split without it plus n; otherwise, U of the split without it.
|
|
25
|
+
function exactCounts(m, n) {
|
|
26
|
+
let prev = Array.from({ length: n + 1 }, () => [1]);
|
|
27
|
+
for (let i = 1; i <= m; i++) {
|
|
28
|
+
const row = [new Array(1).fill(1)];
|
|
29
|
+
for (let j = 1; j <= n; j++) {
|
|
30
|
+
const counts = new Array(i * j + 1).fill(0);
|
|
31
|
+
prev[j].forEach((c, u) => (counts[u + j] += c));
|
|
32
|
+
row[j - 1].forEach((c, u) => (counts[u] += c));
|
|
33
|
+
row.push(counts);
|
|
34
|
+
}
|
|
35
|
+
prev = row;
|
|
36
|
+
}
|
|
37
|
+
return prev[n];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The complementary error function, with a fractional error under 1.2e-7 (Numerical Recipes, erfcc)
|
|
41
|
+
function erfc(x) {
|
|
42
|
+
const z = Math.abs(x);
|
|
43
|
+
const t = 1 / (1 + 0.5 * z);
|
|
44
|
+
const r =
|
|
45
|
+
t *
|
|
46
|
+
Math.exp(
|
|
47
|
+
-z * z -
|
|
48
|
+
1.26551223 +
|
|
49
|
+
t * (1.00002368 + t * (0.37409196 + t * (0.09678418 + t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 + t * (-0.82215223 + t * 0.17087277))))))))
|
|
50
|
+
);
|
|
51
|
+
return x >= 0 ? r : 2 - r;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const upperTail = (z) => erfc(z / Math.SQRT2) / 2;
|
|
55
|
+
|
|
56
|
+
const isExact = (m, n, ties) => m <= EXACT_UP_TO && n <= EXACT_UP_TO && ties.length === 0;
|
|
57
|
+
|
|
58
|
+
// Whether `sample` tends to be greater than `reference`: U counts the pairs where it is (a tie counts
|
|
59
|
+
// half), and p is the probability of a U this large or larger if both came from the same distribution.
|
|
60
|
+
// For a regression of a timing, the sample is the current runs and the reference the baseline.
|
|
61
|
+
export function mannWhitneyGreater(sample, reference) {
|
|
62
|
+
const m = sample.length;
|
|
63
|
+
const n = reference.length;
|
|
64
|
+
const { ranks, ties } = rank([...sample, ...reference]);
|
|
65
|
+
const rankSum = ranks.slice(0, m).reduce((sum, r) => sum + r, 0);
|
|
66
|
+
const u = rankSum - (m * (m + 1)) / 2;
|
|
67
|
+
|
|
68
|
+
if (isExact(m, n, ties)) {
|
|
69
|
+
const counts = exactCounts(m, n);
|
|
70
|
+
const total = counts.reduce((sum, c) => sum + c, 0);
|
|
71
|
+
const above = counts.slice(Math.ceil(u)).reduce((sum, c) => sum + c, 0);
|
|
72
|
+
return { u, p: above / total, method: "exact" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const N = m + n;
|
|
76
|
+
const tieTerm = ties.reduce((sum, t) => sum + t ** 3 - t, 0) / (N * (N - 1));
|
|
77
|
+
const sigma = Math.sqrt(((m * n) / 12) * (N + 1 - tieTerm));
|
|
78
|
+
const z = (u - (m * n) / 2 - 0.5) / sigma;
|
|
79
|
+
return { u, p: upperTail(z), method: "normal" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const median = (sorted) => {
|
|
83
|
+
const mid = sorted.length / 2;
|
|
84
|
+
return sorted.length % 2 ? sorted[Math.floor(mid)] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// By how much `sample` is greater than `reference`: the median of the differences of every pair, and the
|
|
88
|
+
// 95 % interval between two of those differences, chosen from the distribution of U. With samples too
|
|
89
|
+
// small for 95 %, such as three runs a side, the interval is every difference.
|
|
90
|
+
export function hodgesLehmann(reference, sample) {
|
|
91
|
+
const m = sample.length;
|
|
92
|
+
const n = reference.length;
|
|
93
|
+
const diffs = sample.flatMap((s) => reference.map((r) => s - r)).sort((a, b) => a - b);
|
|
94
|
+
const { ties } = rank([...sample, ...reference]);
|
|
95
|
+
|
|
96
|
+
let k;
|
|
97
|
+
if (isExact(m, n, ties)) {
|
|
98
|
+
// The largest k with P(U <= k - 1) at most 2.5 %
|
|
99
|
+
const counts = exactCounts(m, n);
|
|
100
|
+
const total = counts.reduce((sum, c) => sum + c, 0);
|
|
101
|
+
let below = 0;
|
|
102
|
+
k = 0;
|
|
103
|
+
while (k < counts.length && (below + counts[k]) / total <= 0.025) below += counts[k++];
|
|
104
|
+
} else {
|
|
105
|
+
k = Math.max(0, Math.floor((m * n) / 2 - Z_975 * Math.sqrt((m * n * (m + n + 1)) / 12)));
|
|
106
|
+
}
|
|
107
|
+
return { shift: median(diffs), ci: [diffs[k], diffs[m * n - 1 - k]] };
|
|
108
|
+
}
|