webperf-snippets 1.8.0 → 1.10.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 +24 -8
- package/package.json +1 -1
- package/src/baseline.js +40 -10
- package/src/bin.js +25 -10
- package/src/conditions.js +2 -2
- package/src/crux.js +186 -1
- package/src/machine.js +42 -0
- package/src/mcp/server.js +25 -7
- package/src/reporters/baseline-view.js +13 -0
- package/src/reporters/human.js +48 -10
- package/src/reporters/markdown.js +46 -9
- 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.10.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -136,6 +136,19 @@ Field data (CrUX p75, mobile)
|
|
|
136
136
|
|
|
137
137
|
Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/api) and pass it with `--crux-key` or the `CRUX_API_KEY` environment variable; prefer the variable, because a flag ends up in shell history and CI logs. The section appears in the human and markdown output and under `crux` in the `--json` output.
|
|
138
138
|
|
|
139
|
+
With a key, the field data also gives what CrUX knows of the LCP when it is an image: the p75 of its sub-parts, TTFB, load delay, load duration and render delay. When the measurement ran LCP-Subparts, with `--snippet LCP-Subparts` or the `core-web-vitals` workflow over 2.5 s, a table puts each one next to its lab value, with a note where one side is at least 1.5 times the other and 100 ms apart: a slower server or network for users, an image the lab finds sooner, slower connections, slower devices, or a lab slower than users. The field sub-parts describe only the loads whose LCP is an image, and the table says which share that is; when the LCP of the lab is not an image, only TTFB and render delay compare. Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP. A line gives the navigation types of the users, by share, and points to Back-Forward-Cache when over 5 % of the visits come back without the back/forward cache. The JSON output carries them in `crux.subParts`, `crux.lcpImageShare`, `crux.roundTripTime` and `crux.navigationTypes`.
|
|
140
|
+
|
|
141
|
+
`--crux-history <weeks>` adds the trend of the field data over the last 1 to 40 weeks, from the [CrUX History API](https://developer.chrome.com/docs/crux/history-api), with the same key. Each week is the p75 of the 28 days before it, so the weeks overlap and the series is smooth. For LCP, CLS and INP the section gives the first and the last week, the change between them and its direction, `stable` within 5 %, and the weeks as a sparkline, where a gap is a week with too little data:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
Field data trend (CrUX p75, 40 weeks, mobile, origin-level)
|
|
145
|
+
LCP 2.88s → 3.10s ▲ +7.7% worsening ▃▃▄▃▃▃▄▆▇██▇▆▄▂▁▂▃▃▄▅▆▆▆▆▆▆
|
|
146
|
+
CLS 0.2600 → 0.2100 ▼ −19.2% improving ▅▆▆▇▇█▇▆▆▅▄▃▃▃▃▃▂▂▁▂▂▂▂▂▂▂
|
|
147
|
+
INP 253ms → 251ms ▼ −0.8% stable ▇▅▃▂▂▄▆██▇▅▃▂▃▄▆▅▃▂▁▂▃▃▄▅▆
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The JSON output carries it in `crux.history`, with the periods, the p75 of each week and the trend of each metric. Like the p75, it uses the page when it has data of its own and its origin otherwise, and says which in `scope`.
|
|
151
|
+
|
|
139
152
|
- The population follows `--viewport`: `desktop` uses desktop data, `mobile` and `tablet` use phone data. The heading says which one.
|
|
140
153
|
- When the page itself has no data, the CLI looks the origin up instead and labels the section `origin-level`, so it is not mistaken for page-level data. If the origin has none either, the output says `Field data: not available`.
|
|
141
154
|
- A CrUX error (quota, network, no answer within 10 seconds) only skips the section. It never changes the exit code, and the key is never printed.
|
|
@@ -158,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
171
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
172
|
"navMs": 1240,
|
|
160
173
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
174
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.10.0" }
|
|
162
175
|
}
|
|
163
176
|
```
|
|
164
177
|
|
|
@@ -190,7 +203,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
|
|
|
190
203
|
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
191
204
|
```
|
|
192
205
|
|
|
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`:
|
|
206
|
+
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
207
|
|
|
195
208
|
```json
|
|
196
209
|
"runs": {
|
|
@@ -199,12 +212,12 @@ The median run is the one with the median LCP. A workflow without LCP uses the f
|
|
|
199
212
|
"medianRun": 3,
|
|
200
213
|
"medianBy": "LCP",
|
|
201
214
|
"summary": {
|
|
202
|
-
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
|
|
215
|
+
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms", "cv": 11.6, "agreement": "noisy" }
|
|
203
216
|
}
|
|
204
217
|
}
|
|
205
218
|
```
|
|
206
219
|
|
|
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`.
|
|
220
|
+
`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
221
|
|
|
209
222
|
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
223
|
|
|
@@ -238,7 +251,7 @@ npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
|
238
251
|
|
|
239
252
|
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
253
|
|
|
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.
|
|
254
|
+
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
255
|
|
|
243
256
|
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
257
|
|
|
@@ -257,6 +270,8 @@ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.jso
|
|
|
257
270
|
|
|
258
271
|
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
272
|
|
|
273
|
+
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.
|
|
274
|
+
|
|
260
275
|
| Metric | Share of the baseline | Minimum |
|
|
261
276
|
| -------------- | --------------------- | ------- |
|
|
262
277
|
| LCP, FCP, TTFB | 10% | 100 ms |
|
|
@@ -282,7 +297,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
|
|
|
282
297
|
}
|
|
283
298
|
```
|
|
284
299
|
|
|
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.
|
|
300
|
+
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
301
|
|
|
287
302
|
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
303
|
|
|
@@ -325,6 +340,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
325
340
|
| `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
|
|
326
341
|
| `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
|
|
327
342
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
343
|
+
| `--crux-history <weeks>` | Add the trend of the field data over the last 1 to 40 weeks. Needs a CrUX key. |
|
|
328
344
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
329
345
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
330
346
|
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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
|
@@ -10,7 +10,7 @@ import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
|
|
|
10
10
|
import { reportJson } from "./reporters/json.js";
|
|
11
11
|
import { reportMarkdown, reportMatrixMarkdown } from "./reporters/markdown.js";
|
|
12
12
|
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
13
|
-
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
13
|
+
import { fetchCrux, fetchCruxHistory, formFactorFor } from "./crux.js";
|
|
14
14
|
import { MissingBrowserError } from "./browser.js";
|
|
15
15
|
import { parseHeader } from "./headers.js";
|
|
16
16
|
import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
|
|
@@ -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";
|
|
@@ -79,6 +80,7 @@ Options:
|
|
|
79
80
|
LCP=200ms or CLS=0.1. Repeat it for more metrics
|
|
80
81
|
--fail-on-regression Exit 1 when a metric regressed or an error is new against --baseline
|
|
81
82
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
83
|
+
--crux-history <weeks> Add the trend of the field data over the last 1 to 40 weeks (needs a CrUX key).
|
|
82
84
|
Also read from the CRUX_API_KEY environment variable
|
|
83
85
|
--report-to <url> POST the results to this https URL after the run
|
|
84
86
|
(without it the CLI makes no external calls)
|
|
@@ -174,6 +176,7 @@ async function main() {
|
|
|
174
176
|
threshold: { type: "string", multiple: true },
|
|
175
177
|
"fail-on-regression": { type: "boolean" },
|
|
176
178
|
"crux-key": { type: "string" },
|
|
179
|
+
"crux-history": { type: "string" },
|
|
177
180
|
"report-to": { type: "string" },
|
|
178
181
|
"api-key": { type: "string" },
|
|
179
182
|
verbose: { type: "boolean" },
|
|
@@ -252,6 +255,19 @@ async function main() {
|
|
|
252
255
|
}
|
|
253
256
|
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
254
257
|
|
|
258
|
+
// The field data: the p75 of the last 28 days with a key, and its trend week by week with --crux-history
|
|
259
|
+
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
260
|
+
let cruxWeeks;
|
|
261
|
+
if (values["crux-history"] !== undefined) {
|
|
262
|
+
cruxWeeks = /^\d+$/.test(values["crux-history"]) ? Number(values["crux-history"]) : NaN;
|
|
263
|
+
if (!(cruxWeeks >= 1 && cruxWeeks <= 40)) fail(`--crux-history takes 1 to 40 weeks, not "${values["crux-history"]}"`);
|
|
264
|
+
if (!cruxKey) fail("--crux-history needs a CrUX key: --crux-key or CRUX_API_KEY");
|
|
265
|
+
}
|
|
266
|
+
const fieldDataOf = async (url, formFactor) => {
|
|
267
|
+
const crux = await fetchCrux({ url, apiKey: cruxKey, formFactor });
|
|
268
|
+
return cruxWeeks ? { ...crux, history: await fetchCruxHistory({ url, apiKey: cruxKey, formFactor, weeks: cruxWeeks }) } : crux;
|
|
269
|
+
};
|
|
270
|
+
|
|
255
271
|
// The baseline is read and checked before the browser opens: a wrong file fails at once
|
|
256
272
|
let baseline;
|
|
257
273
|
let thresholds;
|
|
@@ -287,9 +303,11 @@ async function main() {
|
|
|
287
303
|
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
288
304
|
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
289
305
|
};
|
|
290
|
-
// What the JSON records of how each page was measured, for a later run to compare with
|
|
306
|
+
// What the JSON records of how each page was measured, for a later run to compare with. The machine is
|
|
307
|
+
// measured once, before Chromium starts, so its processes do not slow the benchmark down.
|
|
308
|
+
const machine = machineOf();
|
|
291
309
|
const conditionsFor = (viewport) =>
|
|
292
|
-
measurementConditions({ viewport, waitMs, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
310
|
+
measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
293
311
|
|
|
294
312
|
if (matrixPath) {
|
|
295
313
|
let matrix;
|
|
@@ -298,7 +316,7 @@ async function main() {
|
|
|
298
316
|
} catch (err) {
|
|
299
317
|
fail(err.message);
|
|
300
318
|
}
|
|
301
|
-
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets });
|
|
319
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf });
|
|
302
320
|
return;
|
|
303
321
|
}
|
|
304
322
|
|
|
@@ -313,9 +331,7 @@ async function main() {
|
|
|
313
331
|
}
|
|
314
332
|
payload.conditions = conditionsFor(viewportName);
|
|
315
333
|
|
|
316
|
-
|
|
317
|
-
const crux = cruxKey ? await fetchCrux({ url, apiKey: cruxKey, formFactor: formFactorFor(viewportName) }) : undefined;
|
|
318
|
-
if (crux) payload.crux = crux;
|
|
334
|
+
if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
|
|
319
335
|
|
|
320
336
|
if (baseline) {
|
|
321
337
|
payload.baseline = compareRun(baseline.data, payload, { file: baseline.file, thresholds });
|
|
@@ -391,7 +407,7 @@ async function measureRuns(measure, count, medianBy, progress) {
|
|
|
391
407
|
|
|
392
408
|
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
393
409
|
// its median run, and the exit code follows the same rules as one page, for every combination.
|
|
394
|
-
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets }) {
|
|
410
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl, conditionsFor, baseline, thresholds, budgets, cruxKey, fieldDataOf }) {
|
|
395
411
|
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
396
412
|
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
397
413
|
|
|
@@ -411,13 +427,12 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
411
427
|
}
|
|
412
428
|
|
|
413
429
|
// The field data of a URL depends on the form factor, not on the variant: one query for each pair
|
|
414
|
-
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
415
430
|
if (cruxKey) {
|
|
416
431
|
const queries = new Map();
|
|
417
432
|
for (const c of combinations.filter((c) => !c.error)) {
|
|
418
433
|
const formFactor = formFactorFor(viewportOf.get(c.id));
|
|
419
434
|
const key = `${c.url} ${formFactor}`;
|
|
420
|
-
if (!queries.has(key)) queries.set(key,
|
|
435
|
+
if (!queries.has(key)) queries.set(key, fieldDataOf(c.url, formFactor));
|
|
421
436
|
const crux = await queries.get(key);
|
|
422
437
|
if (crux) c.crux = crux;
|
|
423
438
|
}
|
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/crux.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { rate } from "./ratings.js";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
4
|
+
// The History API lives next to the daily one; an endpoint given for tests or a proxy points at both
|
|
5
|
+
const historyEndpointOf = (endpoint) => endpoint.replace(/records:queryRecord$/, "records:queryHistoryRecord");
|
|
4
6
|
const CRUX_TIMEOUT_MS = 10000;
|
|
5
7
|
|
|
6
8
|
const METRICS = [
|
|
@@ -27,6 +29,42 @@ function parseMetrics(record) {
|
|
|
27
29
|
return metrics;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// The sub-parts of the LCP as CrUX reports them, for the loads whose LCP is an image, by the names of
|
|
33
|
+
// the lab sub-parts of LCP-Subparts
|
|
34
|
+
const SUB_PARTS = [
|
|
35
|
+
{ part: "ttfb", label: "TTFB", key: "largest_contentful_paint_image_time_to_first_byte" },
|
|
36
|
+
{ part: "resourceLoadDelay", label: "Load delay", key: "largest_contentful_paint_image_resource_load_delay" },
|
|
37
|
+
{ part: "resourceLoadTime", label: "Load duration", key: "largest_contentful_paint_image_resource_load_duration" },
|
|
38
|
+
{ part: "elementRenderDelay", label: "Render delay", key: "largest_contentful_paint_image_element_render_delay" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const p75Of = (record, key) => {
|
|
42
|
+
const value = Number(record?.metrics?.[key]?.percentiles?.p75);
|
|
43
|
+
return Number.isFinite(value) ? value : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// What else the record of a page says besides LCP, CLS and INP: the sub-parts of an image LCP, the
|
|
47
|
+
// share of loads whose LCP is an image, the round trip time and the navigation types, by share
|
|
48
|
+
export function parseFieldExtras(record) {
|
|
49
|
+
const extras = {};
|
|
50
|
+
const subParts = {};
|
|
51
|
+
for (const { part, key } of SUB_PARTS) {
|
|
52
|
+
const value = p75Of(record, key);
|
|
53
|
+
if (value !== undefined) subParts[part] = value;
|
|
54
|
+
}
|
|
55
|
+
if (Object.keys(subParts).length) extras.subParts = subParts;
|
|
56
|
+
const imageShare = record?.metrics?.largest_contentful_paint_resource_type?.fractions?.image;
|
|
57
|
+
if (typeof imageShare === "number") extras.lcpImageShare = imageShare;
|
|
58
|
+
const rtt = p75Of(record, "round_trip_time");
|
|
59
|
+
if (rtt !== undefined) extras.roundTripTime = rtt;
|
|
60
|
+
const fractions = record?.metrics?.navigation_types?.fractions;
|
|
61
|
+
if (fractions) {
|
|
62
|
+
const sorted = Object.entries(fractions).filter(([, share]) => share > 0).sort((a, b) => b[1] - a[1]);
|
|
63
|
+
if (sorted.length) extras.navigationTypes = Object.fromEntries(sorted);
|
|
64
|
+
}
|
|
65
|
+
return extras;
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
async function query({ endpoint, apiKey, body, timeoutMs }) {
|
|
31
69
|
const res = await fetch(`${endpoint}?key=${encodeURIComponent(apiKey)}`, {
|
|
32
70
|
method: "POST",
|
|
@@ -61,7 +99,78 @@ export async function fetchCrux({
|
|
|
61
99
|
if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}`, failed: true };
|
|
62
100
|
const metrics = parseMetrics(answer.record);
|
|
63
101
|
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
|
|
64
|
-
return { scope, formFactor, metrics };
|
|
102
|
+
return { scope, formFactor, metrics, ...parseFieldExtras(answer.record) };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
105
|
+
return { unavailable: reason, failed: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A trend compares the first and the last periods with data: each period already covers 28 days, so the
|
|
110
|
+
// series is smooth enough. Within 5 % it is stable; lower is better for every metric.
|
|
111
|
+
const STABLE_WITHIN = 5;
|
|
112
|
+
|
|
113
|
+
export function trendOf(p75s) {
|
|
114
|
+
const values = p75s.filter((v) => v !== null);
|
|
115
|
+
const first = values[0];
|
|
116
|
+
const last = values.at(-1);
|
|
117
|
+
if (values.length < 2) return { first, last, change: null, trend: null };
|
|
118
|
+
const change = first === 0 ? null : Math.round(((last - first) / first) * 1000) / 10;
|
|
119
|
+
const trend = change === null || Math.abs(change) <= STABLE_WITHIN ? "stable" : change < 0 ? "improving" : "worsening";
|
|
120
|
+
return { first, last, change, trend };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const BARS = "▁▂▃▄▅▆▇█";
|
|
124
|
+
|
|
125
|
+
// The series as bars between its lowest and highest values, a space for a period without data
|
|
126
|
+
export function sparkline(values) {
|
|
127
|
+
const known = values.filter((v) => v !== null);
|
|
128
|
+
const min = Math.min(...known);
|
|
129
|
+
const max = Math.max(...known);
|
|
130
|
+
return values.map((v) => (v === null ? " " : max === min ? BARS[3] : BARS[Math.round(((v - min) / (max - min)) * 7)])).join("");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dateOf = ({ year, month, day }) => `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
134
|
+
|
|
135
|
+
// The record of the History API as periods, oldest first, and the p75s of each Core Web Vital
|
|
136
|
+
export function parseHistory(record) {
|
|
137
|
+
const periods = (record?.collectionPeriods ?? []).map((p) => ({ start: dateOf(p.firstDate), end: dateOf(p.lastDate) }));
|
|
138
|
+
const metrics = {};
|
|
139
|
+
for (const m of METRICS) {
|
|
140
|
+
const raw = record?.metrics?.[m.key]?.percentilesTimeseries?.p75s;
|
|
141
|
+
if (!raw) continue;
|
|
142
|
+
// CLS comes as strings ("0.21"), and a period with too little data as null
|
|
143
|
+
const p75s = raw.map((v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Number(v)));
|
|
144
|
+
if (p75s.every((v) => v === null)) continue;
|
|
145
|
+
metrics[m.name] = { unit: m.unit, p75s, ...trendOf(p75s) };
|
|
146
|
+
}
|
|
147
|
+
return { periods, metrics };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The p75 of LCP, CLS and INP week by week over the last `weeks` periods (1 to 40), from the History
|
|
151
|
+
// API: the URL, or its origin when the URL has too little traffic. Never throws, and like fetchCrux
|
|
152
|
+
// never puts the key in a message.
|
|
153
|
+
export async function fetchCruxHistory({
|
|
154
|
+
url,
|
|
155
|
+
apiKey,
|
|
156
|
+
weeks,
|
|
157
|
+
formFactor = "PHONE",
|
|
158
|
+
endpoint = process.env.WEBPERF_CRUX_ENDPOINT ?? DEFAULT_ENDPOINT,
|
|
159
|
+
timeoutMs = CRUX_TIMEOUT_MS,
|
|
160
|
+
}) {
|
|
161
|
+
const history = historyEndpointOf(endpoint);
|
|
162
|
+
try {
|
|
163
|
+
let scope = "url";
|
|
164
|
+
let answer = await query({ endpoint: history, apiKey, body: { url, formFactor, collectionPeriodCount: weeks }, timeoutMs });
|
|
165
|
+
if (answer.notFound) {
|
|
166
|
+
scope = "origin";
|
|
167
|
+
answer = await query({ endpoint: history, apiKey, body: { origin: new URL(url).origin, formFactor, collectionPeriodCount: weeks }, timeoutMs });
|
|
168
|
+
}
|
|
169
|
+
if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
|
|
170
|
+
if (answer.status) return { unavailable: `the CrUX History API answered ${answer.status}`, failed: true };
|
|
171
|
+
const { periods, metrics } = parseHistory(answer.record);
|
|
172
|
+
if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX history" };
|
|
173
|
+
return { scope, formFactor, weeks: periods.length, periods, metrics };
|
|
65
174
|
} catch (err) {
|
|
66
175
|
const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
|
|
67
176
|
return { unavailable: reason, failed: true };
|
|
@@ -84,3 +193,79 @@ export function fieldDataView(crux, results) {
|
|
|
84
193
|
});
|
|
85
194
|
return { title, rows };
|
|
86
195
|
}
|
|
196
|
+
|
|
197
|
+
// The trend of the field data, shared by both reporters: per metric the first and last p75 of the
|
|
198
|
+
// weeks, the change and its direction, and the series as a sparkline
|
|
199
|
+
export function historyView(history) {
|
|
200
|
+
const label = FORM_FACTOR_LABEL[history.formFactor] ?? history.formFactor.toLowerCase();
|
|
201
|
+
const title = `Field data trend (CrUX p75, ${history.weeks} weeks, ${label}${history.scope === "origin" ? ", origin-level" : ""})`;
|
|
202
|
+
const rows = METRICS.filter((m) => history.metrics[m.name]).map((m) => {
|
|
203
|
+
const { p75s, first, last, change, trend } = history.metrics[m.name];
|
|
204
|
+
const changeText = change === null ? "·" : `${change > 0 ? "▲ +" : change < 0 ? "▼ −" : "= "}${Math.abs(change)}%`;
|
|
205
|
+
return { metric: m.name, unit: m.unit, first, last, change: changeText, trend: trend ?? "·", sparkline: sparkline(p75s) };
|
|
206
|
+
});
|
|
207
|
+
return { title, rows };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const share = (fraction) => (fraction < 0.01 ? `${Math.round(fraction * 1000) / 10}%` : `${Math.round(fraction * 100)}%`);
|
|
211
|
+
|
|
212
|
+
// A sub-part counts as different when one side is at least 1.5 times the other and 100 ms apart: less
|
|
213
|
+
// than that is within the noise of the lab and of a percentile
|
|
214
|
+
const differs = (slow, fast) => slow >= fast * 1.5 && slow - fast >= 100;
|
|
215
|
+
|
|
216
|
+
const rttNote = (rtt) => (rtt === undefined ? "" : ` (RTT p75 ${rtt} ms)`);
|
|
217
|
+
const FIELD_SLOWER = {
|
|
218
|
+
ttfb: (rtt) => `real users wait longer for the server: network distance${rttNote(rtt)}, the CDN or cache misses the lab does not see`,
|
|
219
|
+
resourceLoadDelay: () => 'the lab finds the image sooner: preload it or give it fetchpriority="high", or users run more scripts before it',
|
|
220
|
+
resourceLoadTime: (rtt) => `slower connections in the field${rttNote(rtt)}: compress or resize the LCP image`,
|
|
221
|
+
elementRenderDelay: () => "slower devices or more main-thread work in the field",
|
|
222
|
+
};
|
|
223
|
+
const LAB_SLOWER = "the lab is slower than users: check the CPU and network throttling of the lab";
|
|
224
|
+
|
|
225
|
+
// The sub-parts of the field next to those of LCP-Subparts in the lab, when it ran, with a note where
|
|
226
|
+
// they differ, and what the field sub-parts describe. Null without field sub-parts.
|
|
227
|
+
export function subPartsView(extras, results) {
|
|
228
|
+
if (!extras.subParts) return null;
|
|
229
|
+
const labResult = results.find((r) => r.script === "LCP-Subparts" && r.status === "ok");
|
|
230
|
+
const labParts = labResult?.details?.subParts ?? {};
|
|
231
|
+
// The field sub-parts are those of image LCPs: a lab LCP without a resource, such as text, has no load
|
|
232
|
+
// delay or duration to set against them
|
|
233
|
+
const labImage = !labResult || Boolean(labResult.details?.url);
|
|
234
|
+
const rows = SUB_PARTS.filter(({ part }) => extras.subParts[part] !== undefined).map(({ part, label }) => {
|
|
235
|
+
const field = extras.subParts[part];
|
|
236
|
+
const comparable = labImage || part === "ttfb" || part === "elementRenderDelay";
|
|
237
|
+
const lab = comparable ? (labParts[part]?.value ?? null) : null;
|
|
238
|
+
let note = null;
|
|
239
|
+
if (lab !== null && differs(field, lab)) note = FIELD_SLOWER[part](extras.roundTripTime);
|
|
240
|
+
else if (lab !== null && differs(lab, field)) note = LAB_SLOWER;
|
|
241
|
+
return { part, label, field, lab, note };
|
|
242
|
+
});
|
|
243
|
+
const caveats = [
|
|
244
|
+
...(extras.lcpImageShare !== undefined ? [`The field sub-parts describe the ${share(extras.lcpImageShare)} of loads whose LCP is an image.`] : []),
|
|
245
|
+
...(labImage ? [] : ["The LCP of the lab is not an image, so it has no load delay or duration to compare."]),
|
|
246
|
+
"Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP.",
|
|
247
|
+
];
|
|
248
|
+
return { rows, caveats };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const NAVIGATION_LABELS = {
|
|
252
|
+
navigate: "navigate",
|
|
253
|
+
navigate_cache: "navigate from cache",
|
|
254
|
+
reload: "reload",
|
|
255
|
+
restore: "restore",
|
|
256
|
+
back_forward: "back/forward",
|
|
257
|
+
back_forward_cache: "back/forward cache",
|
|
258
|
+
prerender: "prerender",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// How users arrive at the page, by share, and a pointer to Back-Forward-Cache when over 5 % of the
|
|
262
|
+
// visits come back without the back/forward cache, which would have shown the page at once
|
|
263
|
+
export function navigationView(extras) {
|
|
264
|
+
if (!extras.navigationTypes) return null;
|
|
265
|
+
const parts = Object.entries(extras.navigationTypes).map(([type, fraction]) => `${NAVIGATION_LABELS[type] ?? type} ${share(fraction)}`);
|
|
266
|
+
const missed = extras.navigationTypes.back_forward ?? 0;
|
|
267
|
+
return {
|
|
268
|
+
line: `Navigations: ${parts.join(", ")}`,
|
|
269
|
+
hint: missed > 0.05 ? `${share(missed)} of the visits come back without the back/forward cache: run Back-Forward-Cache to see what blocks it.` : null,
|
|
270
|
+
};
|
|
271
|
+
}
|
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
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { isTrackingSnippet } from "../tracking.js";
|
|
|
7
7
|
import { runSnippets, runMeasurement, evaluateItems, collectTrackingData, VIEWPORT_PRESETS } from "../runner.js";
|
|
8
8
|
import { WORKFLOWS } from "../workflows/index.js";
|
|
9
9
|
import { RULES } from "../decision-tree.js";
|
|
10
|
-
import { fetchCrux } from "../crux.js";
|
|
10
|
+
import { fetchCrux, fetchCruxHistory } from "../crux.js";
|
|
11
11
|
import { runInteractions, isInputStep, settle } from "../interactions.js";
|
|
12
12
|
import { createSessions, SessionError } from "./sessions.js";
|
|
13
13
|
import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
|
|
@@ -295,10 +295,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
295
295
|
{
|
|
296
296
|
title: "Get CrUX field data",
|
|
297
297
|
description:
|
|
298
|
-
"Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get.",
|
|
298
|
+
"Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get. With weeks, it adds the trend: the p75 of each week, each the 28 days before it, with the change from the first week to the last.",
|
|
299
299
|
inputSchema: z.object({
|
|
300
300
|
url: HttpUrl,
|
|
301
301
|
formFactor: z.enum(Object.keys(CRUX_FORM_FACTORS)).default("phone").describe("The devices the data describes"),
|
|
302
|
+
weeks: z.number().int().min(1).max(40).optional().describe("Add the trend of the last 1 to 40 weeks"),
|
|
302
303
|
}),
|
|
303
304
|
outputSchema: z.object({
|
|
304
305
|
url: z.string(),
|
|
@@ -309,21 +310,38 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
309
310
|
.optional()
|
|
310
311
|
.describe("By metric name: LCP, CLS, INP"),
|
|
311
312
|
unavailable: z.string().optional().describe("Why there is no field data, such as a page with too little traffic"),
|
|
313
|
+
subParts: z
|
|
314
|
+
.record(z.string(), z.number())
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("The p75 of the sub-parts of an image LCP: ttfb, resourceLoadDelay, resourceLoadTime, elementRenderDelay"),
|
|
317
|
+
lcpImageShare: z.number().optional().describe("The share of loads whose LCP is an image, which the sub-parts describe"),
|
|
318
|
+
roundTripTime: z.number().optional().describe("The p75 of the round trip time of the users, in ms"),
|
|
319
|
+
navigationTypes: z.record(z.string(), z.number()).optional().describe("The share of each navigation type, such as navigate, reload or back_forward_cache"),
|
|
320
|
+
history: z
|
|
321
|
+
.looseObject({})
|
|
322
|
+
.meta({ additionalProperties: true })
|
|
323
|
+
.optional()
|
|
324
|
+
.describe("With weeks: scope, weeks, periods, and per metric its p75s, first, last, change in percent and trend"),
|
|
312
325
|
}),
|
|
313
326
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
314
327
|
},
|
|
315
|
-
async ({ url, formFactor }) => {
|
|
328
|
+
async ({ url, formFactor, weeks }) => {
|
|
316
329
|
const apiKey = env.CRUX_API_KEY;
|
|
317
330
|
if (!apiKey) return toolError(CRUX_SETUP);
|
|
318
|
-
const
|
|
331
|
+
const options = {
|
|
319
332
|
url,
|
|
320
333
|
apiKey,
|
|
321
334
|
formFactor: CRUX_FORM_FACTORS[formFactor],
|
|
322
335
|
...(env.WEBPERF_CRUX_ENDPOINT ? { endpoint: env.WEBPERF_CRUX_ENDPOINT } : {}),
|
|
323
|
-
}
|
|
336
|
+
};
|
|
337
|
+
const crux = await fetchCrux(options);
|
|
324
338
|
if (crux.failed) return toolError(`Could not read the CrUX data: ${crux.unavailable}.`);
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
const history = weeks ? await fetchCruxHistory({ ...options, weeks }) : undefined;
|
|
340
|
+
// A history that failed says why in its own unavailable, next to the p75 that did come
|
|
341
|
+
const trend = history ? { history: history.failed ? { unavailable: history.unavailable } : history } : {};
|
|
342
|
+
if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable, ...trend });
|
|
343
|
+
const { scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes } = crux;
|
|
344
|
+
return toolResult({ url, formFactor, scope, metrics, subParts, lcpImageShare, roundTripTime, navigationTypes, ...trend });
|
|
327
345
|
}
|
|
328
346
|
);
|
|
329
347
|
|
|
@@ -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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { describeResult } from "../view.js";
|
|
3
3
|
import { isSummaryResult, summaryNumbers } from "./summary-view.js";
|
|
4
|
-
import { fieldDataView } from "../crux.js";
|
|
4
|
+
import { fieldDataView, historyView, subPartsView, navigationView } 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";
|
|
@@ -197,7 +197,8 @@ function renderResult(r, verbose) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function renderFieldData(crux, results) {
|
|
200
|
-
|
|
200
|
+
const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
|
|
201
|
+
if (crux.unavailable) return [styleText("dim", `Field data: not available (${crux.unavailable})`), ...trend];
|
|
201
202
|
const { title, rows } = fieldDataView(crux, results);
|
|
202
203
|
const lines = [styleText("bold", title)];
|
|
203
204
|
for (const row of rows) {
|
|
@@ -207,15 +208,50 @@ function renderFieldData(crux, results) {
|
|
|
207
208
|
` ${icon} ${pad(row.metric, 6)} ${pad(paint(row.rating, formatValue(row.p75, row.unit)), 10)} ${pad(styleText("dim", row.rating), 20)} ${styleText("dim", `(synthetic: ${synthetic})`)}`
|
|
208
209
|
);
|
|
209
210
|
}
|
|
210
|
-
return lines;
|
|
211
|
+
return [...lines, ...renderFieldSubParts(crux, results), ...renderNavigations(crux), ...trend];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// "TTFB 1.53s lab 400ms real users wait longer for the server…"
|
|
215
|
+
function renderFieldSubParts(crux, results) {
|
|
216
|
+
const view = subPartsView(crux, results);
|
|
217
|
+
if (!view) return [];
|
|
218
|
+
return [
|
|
219
|
+
"",
|
|
220
|
+
styleText("bold", "LCP sub-parts (CrUX p75 against the lab)"),
|
|
221
|
+
...view.rows.map(
|
|
222
|
+
(row) => ` ${pad(row.label, 14)}${pad(formatValue(row.field, "ms"), 10)}${pad(`lab ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")}`, 12)}${row.note ?? ""}`.trimEnd()
|
|
223
|
+
),
|
|
224
|
+
...view.caveats.map((caveat) => styleText("dim", ` ${caveat}`)),
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderNavigations(crux) {
|
|
229
|
+
const view = navigationView(crux);
|
|
230
|
+
if (!view) return [];
|
|
231
|
+
return ["", styleText("dim", view.line), ...(view.hint ? [` ${view.hint}`] : [])];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// "LCP 2.88s → 3.10s ▲ +7.7% worsening ▁▅█"
|
|
235
|
+
function renderFieldTrend(history) {
|
|
236
|
+
if (history.unavailable) return [styleText("dim", `Field data trend: not available (${history.unavailable})`)];
|
|
237
|
+
const { title, rows } = historyView(history);
|
|
238
|
+
const style = { improving: "green", worsening: "red" };
|
|
239
|
+
return [
|
|
240
|
+
styleText("bold", title),
|
|
241
|
+
...rows.map(
|
|
242
|
+
(row) =>
|
|
243
|
+
` ${pad(row.metric, 6)}${pad(`${formatValue(row.first, row.unit)} → ${formatValue(row.last, row.unit)}`, 20)}${pad(row.change, 10)}${pad(style[row.trend] ? styleText(style[row.trend], row.trend) : row.trend, 12)}${row.sparkline}`
|
|
244
|
+
),
|
|
245
|
+
];
|
|
211
246
|
}
|
|
212
247
|
|
|
213
|
-
// The runs line, and the median, min and max of each metric across the runs
|
|
248
|
+
// The runs line, and the median, min and max of each metric across the runs, with how much they agree
|
|
214
249
|
function renderRuns(runs) {
|
|
215
250
|
const lines = [styleText("dim", describeRuns(runs))];
|
|
216
|
-
for (const [metric,
|
|
217
|
-
const
|
|
218
|
-
|
|
251
|
+
for (const [metric, value] of Object.entries(runs.summary)) {
|
|
252
|
+
const { median, min, max, unit } = value;
|
|
253
|
+
const cell = (label, v) => pad(`${label} ${formatValue(v, unit)}`, 16);
|
|
254
|
+
lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max)}${describeAgreement(value)}`.trimEnd()));
|
|
219
255
|
}
|
|
220
256
|
return lines;
|
|
221
257
|
}
|
|
@@ -274,7 +310,7 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, th
|
|
|
274
310
|
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
275
311
|
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
276
312
|
export function reportMatrixHuman(report) {
|
|
277
|
-
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
313
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
|
|
278
314
|
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
279
315
|
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
280
316
|
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
@@ -292,11 +328,13 @@ export function reportMatrixHuman(report) {
|
|
|
292
328
|
continue;
|
|
293
329
|
}
|
|
294
330
|
const drawn = cells.map((cell, i) => {
|
|
295
|
-
const
|
|
331
|
+
const width = legend ? 18 : 14;
|
|
332
|
+
const value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}`), width) : pad("·", width);
|
|
296
333
|
return value + (changes ? pad(styleText(VERDICT_STYLE[changes[i].verdict], changes[i].text), 12) : "");
|
|
297
334
|
});
|
|
298
335
|
lines.push(row + drawn.join("").trimEnd());
|
|
299
336
|
}
|
|
337
|
+
if (legend) lines.push(styleText("dim", ` ${legend}`));
|
|
300
338
|
|
|
301
339
|
if (errors.length) {
|
|
302
340
|
lines.push("");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { fieldDataView } from "../crux.js";
|
|
1
|
+
import { fieldDataView, historyView, subPartsView, navigationView } 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";
|
|
@@ -102,7 +102,8 @@ function renderSection(title, results) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function renderFieldData(crux, results) {
|
|
105
|
-
|
|
105
|
+
const trend = crux.history ? ["", ...renderFieldTrend(crux.history)] : [];
|
|
106
|
+
if (crux.unavailable) return [`> Field data: not available (${crux.unavailable})`, ...trend];
|
|
106
107
|
const { title, rows } = fieldDataView(crux, results);
|
|
107
108
|
return [
|
|
108
109
|
`### ${title}`,
|
|
@@ -112,14 +113,49 @@ function renderFieldData(crux, results) {
|
|
|
112
113
|
const synthetic = row.synthetic == null ? "n/a" : formatValue(row.synthetic, row.unit);
|
|
113
114
|
return `| ${row.metric} | ${formatValue(row.p75, row.unit)} | ${RATING_ICON[row.rating] ?? "·"} ${row.rating} | ${synthetic} |`;
|
|
114
115
|
}),
|
|
116
|
+
...renderFieldSubParts(crux, results),
|
|
117
|
+
...renderNavigations(crux),
|
|
118
|
+
...trend,
|
|
115
119
|
];
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
|
|
122
|
+
function renderFieldSubParts(crux, results) {
|
|
123
|
+
const view = subPartsView(crux, results);
|
|
124
|
+
if (!view) return [];
|
|
125
|
+
return [
|
|
126
|
+
"",
|
|
127
|
+
"### LCP sub-parts (CrUX p75 against the lab)",
|
|
128
|
+
"| Sub-part | Field p75 | Lab | Note |",
|
|
129
|
+
"|----------|-----------|-----|------|",
|
|
130
|
+
...view.rows.map((row) => `| ${row.label} | ${formatValue(row.field, "ms")} | ${row.lab === null ? "n/a" : formatValue(row.lab, "ms")} | ${row.note ?? ""} |`),
|
|
131
|
+
"",
|
|
132
|
+
...view.caveats.map((caveat) => `> ${caveat}`),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderNavigations(crux) {
|
|
137
|
+
const view = navigationView(crux);
|
|
138
|
+
if (!view) return [];
|
|
139
|
+
return ["", `> ${view.line}`, ...(view.hint ? [">", `> ${view.hint}`] : [])];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderFieldTrend(history) {
|
|
143
|
+
if (history.unavailable) return [`> Field data trend: not available (${history.unavailable})`];
|
|
144
|
+
const { title, rows } = historyView(history);
|
|
145
|
+
return [
|
|
146
|
+
`### ${title}`,
|
|
147
|
+
"| Metric | First | Last | Change | Trend | Weeks |",
|
|
148
|
+
"|--------|-------|------|--------|-------|-------|",
|
|
149
|
+
...rows.map((row) => `| ${row.metric} | ${formatValue(row.first, row.unit)} | ${formatValue(row.last, row.unit)} | ${row.change} | ${row.trend} | ${row.sparkline} |`),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The median, min and max of each metric across the runs, and how much they agree
|
|
119
154
|
function renderRuns(runs) {
|
|
120
|
-
const lines = ["| Metric | Median | Min | Max |", "
|
|
121
|
-
for (const [metric,
|
|
122
|
-
|
|
155
|
+
const lines = ["| Metric | Median | Min | Max | Runs |", "|--------|--------|-----|-----|------|"];
|
|
156
|
+
for (const [metric, value] of Object.entries(runs.summary)) {
|
|
157
|
+
const { median, min, max, unit } = value;
|
|
158
|
+
lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} | ${describeAgreement(value) || "·"} |`);
|
|
123
159
|
}
|
|
124
160
|
return lines;
|
|
125
161
|
}
|
|
@@ -196,7 +232,7 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttli
|
|
|
196
232
|
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
197
233
|
// comment readable.
|
|
198
234
|
export function reportMatrixMarkdown(report) {
|
|
199
|
-
const { file, summary, hasVariants, metrics, rows, errors, comparison } = matrixView(report);
|
|
235
|
+
const { file, summary, hasVariants, metrics, rows, errors, comparison, legend } = matrixView(report);
|
|
200
236
|
// With --baseline, a change column follows each metric
|
|
201
237
|
const metricHeadings = metrics.flatMap((m) => (comparison ? [m, "Δ"] : [m]));
|
|
202
238
|
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metricHeadings];
|
|
@@ -208,12 +244,13 @@ export function reportMatrixMarkdown(report) {
|
|
|
208
244
|
for (const { combination: c, cells, changes } of rows) {
|
|
209
245
|
const drawn = cells
|
|
210
246
|
? cells.flatMap((cell, i) => {
|
|
211
|
-
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·";
|
|
247
|
+
const value = cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}${cell.spread ? ` ${cell.spread}` : ""}` : "·";
|
|
212
248
|
return changes ? [value, changes[i].text] : [value];
|
|
213
249
|
})
|
|
214
250
|
: ["❌ every run failed", ...metricHeadings.slice(1).map(() => "")];
|
|
215
251
|
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
216
252
|
}
|
|
253
|
+
if (legend) lines.push("", `> ${legend}`);
|
|
217
254
|
|
|
218
255
|
if (errors.length) {
|
|
219
256
|
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
|
+
}
|