webperf-snippets 1.7.1 → 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 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 and the version of the CLI.
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.7.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.7.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
 
@@ -382,12 +384,13 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
382
384
  | Tool | What it does |
383
385
  | --------------- | ---------------------------------------------------------------------------- |
384
386
  | `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
385
- | `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns the object it returns. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `waitMs`. The snippets that record interactions run on a page opened with `open_page`. |
387
+ | `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `waitMs`. The snippets that record interactions run on a page opened with `open_page`. |
386
388
  | `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport` and `waitMs`. |
387
389
  | `get_crux` | Returns the CrUX p75 of LCP, CLS and INP for a URL, or its origin. Takes `url` and an optional `formFactor` (`phone`, `desktop`, `tablet`). Needs `CRUX_API_KEY` in the environment of the server. |
388
390
  | `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
389
391
  | `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
390
392
  | `read_results` | Returns what the tracking snippets of an open page have recorded. |
393
+ | `get_details` | Returns the whole object of one snippet of a run, by the `runId` that `run_snippet`, `run_workflow` or `read_results` returned, without measuring again. Takes `runId` and `snippet`. |
391
394
  | `close_page` | Closes an open page. |
392
395
 
393
396
  The server also offers resources, documents a client can attach to the context:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.7.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 when there are two or
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 runs = summary.values.filter((value) => value !== null).length;
80
- return { value: summary.median, ...(runs >= 2 ? { range: { min: summary.min, max: summary.max } } : {}) };
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
- // A regression must beat the threshold of its metric and, when the baseline has several runs, the
87
- // worst of them: a median inside the range of the baseline is noise the baseline already had.
88
- function verdict(base, current, threshold) {
89
- if (current.value - base.value > threshold && (!base.range || current.value > base.range.max)) return "regressed";
90
- if (base.value - current.value > threshold && (!base.range || current.value < base.range.min)) return "improved";
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
+ }
@@ -0,0 +1,29 @@
1
+ import { RULES, nextSteps } from "../decision-tree.js";
2
+ import { loadSnippet } from "../load-snippet.js";
3
+ import { isTrackingSnippet } from "../tracking.js";
4
+
5
+ const isTrackingPath = (path) => isTrackingSnippet(path, loadSnippet(path));
6
+
7
+ // The call of the tool that runs a snippet: a tracking snippet needs a page the agent interacts with. A
8
+ // snippet waits as long as the run that called for it, so it reads the same paint.
9
+ const callFor = ({ path, reason, url, viewport, waitMs, isTracking }) =>
10
+ isTracking(path)
11
+ ? { tool: "open_page", args: { url, track: [path], viewport }, reason }
12
+ : { tool: "run_snippet", args: { url, snippet: path, viewport, ...(waitMs !== undefined ? { waitMs } : {}) }, reason };
13
+
14
+ // The follow-ups the decision tree of the CLI asks for, as the calls an agent makes next. The rules name
15
+ // the steps of a workflow (LCP, CLS, TTFB, FCP), which are the scripts of the snippets, so a result of
16
+ // run_snippet, whose id is Category/Name, is matched by its script. `ran` leaves out what already ran.
17
+ export function followUpCalls({ results, url, viewport, waitMs, ran = [], rules = RULES, isTracking = isTrackingPath }) {
18
+ const named = results.map((r) => ({ ...r, id: r.script ?? r.id }));
19
+ return nextSteps(named, rules, ran).map(({ path, reason }) => callFor({ path, reason, url, viewport, waitMs, isTracking }));
20
+ }
21
+
22
+ // The steps a workflow skipped because they measure clicks and key presses, in one page to open
23
+ export function trackingCall({ paths, url, viewport }) {
24
+ return {
25
+ tool: "open_page",
26
+ args: { url, track: paths, viewport },
27
+ reason: "These steps measure clicks and key presses: open the page with them, interact with it and read them with read_results",
28
+ };
29
+ }
@@ -31,7 +31,8 @@ export function registerPrompts(server) {
31
31
  `1. Call run_workflow with url "${url}", workflow "core-web-vitals" and viewport "${viewport}". It returns LCP and CLS, and adds LCP-Subparts with the reason when LCP is over 2.5 s.`,
32
32
  `2. Call get_crux for the same URL with formFactor "${viewport === "desktop" ? "desktop" : "phone"}", to compare the lab with what real users get. If it answers that there is no key, say so and go on with the lab data.`,
33
33
  '3. If LCP-Subparts did not run, call run_snippet with snippet "LCP-Subparts".',
34
- "4. Report the LCP value and rating in the lab and in the field, the LCP element, the slowest sub-part and what it means, and the fixes for that sub-part, the most effective first.",
34
+ "4. If the answers are summaries (detail: summary), call get_details with the runId for LCP and LCP-Subparts: a summary leaves out the LCP element and the sub-parts.",
35
+ "5. Report the LCP value and rating in the lab and in the field, the LCP element, the slowest sub-part and what it means, and the fixes for that sub-part, the most effective first.",
35
36
  "",
36
37
  `${QUOTE} The result of each snippet is described below.`,
37
38
  ].join("\n")
@@ -63,7 +64,7 @@ export function registerPrompts(server) {
63
64
  selector
64
65
  ? `2. Call interact with a click on "${selector}". Add a few more clicks or key presses if the page has other controls a user would use.`
65
66
  : "2. Call interact with clicks on the controls a user would use first: main buttons, links that open menus, form fields. If you cannot tell which ones, ask me.",
66
- "3. Call read_results. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
67
+ "3. Call read_results; if it answers with summaries, call get_details with its runId for INP and Input-Latency-Breakdown. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
67
68
  "4. Call close_page.",
68
69
  "5. Report the worst interaction, its target, the dominant phase and the fixes for that phase.",
69
70
  "",
package/src/mcp/server.js CHANGED
@@ -13,6 +13,8 @@ import { createSessions, SessionError } from "./sessions.js";
13
13
  import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
14
14
  import { registerResources } from "./documents.js";
15
15
  import { registerPrompts } from "./prompts.js";
16
+ import { shapeResults, createRuns, RunError } from "./summary.js";
17
+ import { followUpCalls, trackingCall } from "./next-steps.js";
16
18
  import { isHttpUrl } from "../url.js";
17
19
 
18
20
  const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
@@ -33,6 +35,27 @@ const SnippetResult = z
33
35
 
34
36
  const SessionId = z.string().describe("The id open_page returned");
35
37
 
38
+ const RunId = z.string().describe("The id of the run, for get_details");
39
+ // Only a summary names its run: whole objects leave nothing for get_details to add
40
+ const SummaryRunId = RunId.optional().describe("The id of the run, for get_details; only with summaries");
41
+
42
+ // auto, the default, returns whole objects when they are small and summaries when they are large: a
43
+ // summary of a small result costs the agent a turn for the details, which is more than it saves
44
+ const Detail = z
45
+ .enum(["auto", "summary", "full"])
46
+ .default("auto")
47
+ .describe("auto: whole objects when small, summaries when large");
48
+ const DetailOut = z.enum(["summary", "full"]).describe("Whether the results are summaries or the whole objects");
49
+
50
+ // The call an agent makes next, with the arguments it takes as they are
51
+ const NextSteps = z
52
+ .array(z.object({ tool: z.string(), args: z.record(z.string(), z.unknown()), reason: z.string() }))
53
+ .optional()
54
+ .describe("The calls to make next, with their arguments and why, when the results call for them");
55
+
56
+ const DETAIL_NOTE =
57
+ "Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
58
+
36
59
  // A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
37
60
  // selector, the URL of a script. The tools that return what a page holds say how to read it, and so
38
61
  // do the instructions of the server, which a client gives the model before any tool.
@@ -98,6 +121,7 @@ function describeSnippet(name) {
98
121
  export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env } = {}) {
99
122
  const server = new McpServer({ name: "webperf-snippets", version: cliVersion }, { instructions: INSTRUCTIONS });
100
123
  const sessions = createSessions({ launchBrowser, maxPages, idleMs });
124
+ const runs = createRuns();
101
125
  // The client left (end of stdin, or a closed transport): close the pages and Chromium
102
126
  server.server.onclose = () => sessions.closeAll();
103
127
  registerResources(server);
@@ -138,24 +162,28 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
138
162
  {
139
163
  title: "Run a snippet",
140
164
  description:
141
- `Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${PAGE_DATA}`,
165
+ `Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${DETAIL_NOTE} ${PAGE_DATA}`,
142
166
  inputSchema: z.object({
143
167
  url: HttpUrl,
144
168
  snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
145
169
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
146
170
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
171
+ detail: Detail,
147
172
  }),
148
173
  outputSchema: z.object({
149
174
  url: z.string(),
175
+ runId: SummaryRunId,
176
+ detail: DetailOut,
150
177
  snippet: z.string().describe("Category/Name of the snippet that ran"),
151
178
  viewport: z.enum(VIEWPORTS),
152
179
  navMs: z.number().describe("From the start of the navigation to the end of the wait"),
153
180
  pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
154
181
  result: SnippetResult,
182
+ nextSteps: NextSteps,
155
183
  }),
156
184
  annotations: { readOnlyHint: true, openWorldHint: true },
157
185
  },
158
- async ({ url, snippet, viewport, waitMs }, ctx) => {
186
+ async ({ url, snippet, viewport, waitMs, detail }, ctx) => {
159
187
  let path;
160
188
  try {
161
189
  path = resolveSnippetName(snippet);
@@ -169,8 +197,21 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
169
197
 
170
198
  try {
171
199
  const run = await runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress: progressFor(ctx) });
172
- const { id, ...result } = run.results[0];
173
- return toolResult({ url, snippet: id, viewport, navMs: run.navMs, pageErrors: run.pageErrors, result });
200
+ const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
201
+ const runId = shaped.detail === "summary" ? runs.keep(run.results) : undefined;
202
+ const id = run.results[0].id;
203
+ const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
204
+ return toolResult({
205
+ url,
206
+ ...(runId ? { runId } : {}),
207
+ detail: shaped.detail,
208
+ snippet: id,
209
+ viewport,
210
+ navMs: run.navMs,
211
+ pageErrors: run.pageErrors,
212
+ result: shaped.results[0],
213
+ ...(nextSteps.length ? { nextSteps } : {}),
214
+ });
174
215
  } catch (err) {
175
216
  if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
176
217
  if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
@@ -184,15 +225,18 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
184
225
  {
185
226
  title: "Run a workflow",
186
227
  description:
187
- `Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page. ${PAGE_DATA}`,
228
+ `Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page. ${DETAIL_NOTE} ${PAGE_DATA}`,
188
229
  inputSchema: z.object({
189
230
  url: HttpUrl,
190
231
  workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
191
232
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
192
233
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
234
+ detail: Detail,
193
235
  }),
194
236
  outputSchema: z.object({
195
237
  url: z.string(),
238
+ runId: SummaryRunId,
239
+ detail: DetailOut,
196
240
  workflow: z.string(),
197
241
  viewport: z.enum(VIEWPORTS),
198
242
  navMs: z.number(),
@@ -200,10 +244,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
200
244
  results: z
201
245
  .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
202
246
  .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
247
+ nextSteps: NextSteps,
203
248
  }),
204
249
  annotations: { readOnlyHint: true, openWorldHint: true },
205
250
  },
206
- async ({ url, workflow, viewport, waitMs }, ctx) => {
251
+ async ({ url, workflow, viewport, waitMs, detail }, ctx) => {
207
252
  try {
208
253
  const run = await runMeasurement({
209
254
  url,
@@ -219,7 +264,24 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
219
264
  ? { ...r, reason: "This step measures clicks and key presses: open the page with open_page and this snippet in track, use it with interact, and read it with read_results." }
220
265
  : r
221
266
  );
222
- return toolResult({ url, workflow, viewport, navMs: run.navMs, pageErrors: run.pageErrors, results });
267
+ const shaped = shapeResults(results, detail);
268
+ const runId = shaped.detail === "summary" ? runs.keep(results) : undefined;
269
+ // The runner already ran the follow-ups of the decision tree; what is left is the steps it skipped
270
+ const skipped = results
271
+ .filter((r) => r.status === "skipped")
272
+ .map((r) => WORKFLOWS[workflow].steps.find((s) => s.id === r.id)?.path)
273
+ .filter(Boolean);
274
+ const nextSteps = skipped.length ? [trackingCall({ paths: skipped, url, viewport })] : [];
275
+ return toolResult({
276
+ url,
277
+ ...(runId ? { runId } : {}),
278
+ ...shaped,
279
+ workflow,
280
+ viewport,
281
+ navMs: run.navMs,
282
+ pageErrors: run.pageErrors,
283
+ ...(nextSteps.length ? { nextSteps } : {}),
284
+ });
223
285
  } catch (err) {
224
286
  if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
225
287
  if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
@@ -358,22 +420,58 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
358
420
  {
359
421
  title: "Read the tracking snippets",
360
422
  description:
361
- `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${PAGE_DATA}`,
362
- inputSchema: z.object({ sessionId: SessionId }),
363
- outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
423
+ `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
424
+ inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
425
+ outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
364
426
  annotations: { readOnlyHint: true, openWorldHint: false },
365
427
  },
366
- withSession(async ({ sessionId }) => {
428
+ withSession(async ({ sessionId, detail }) => {
367
429
  const session = sessions.get(sessionId);
368
- const results = [];
369
- for (const installed of session.tracking) {
370
- const { id, ...result } = await collectTrackingData(session.page, installed, session.hadInput);
371
- results.push(result);
372
- }
373
- return toolResult({ sessionId, pageErrors: session.pageErrors, results });
430
+ const collected = [];
431
+ for (const installed of session.tracking) collected.push(await collectTrackingData(session.page, installed, session.hadInput));
432
+ const shaped = shapeResults(collected.map(({ id, ...result }) => result), detail);
433
+ const runId = shaped.detail === "summary" ? runs.keep(collected) : undefined;
434
+ return toolResult({ sessionId, ...(runId ? { runId } : {}), ...shaped, pageErrors: session.pageErrors });
374
435
  })
375
436
  );
376
437
 
438
+ server.registerTool(
439
+ "get_details",
440
+ {
441
+ title: "Get the details of a result",
442
+ description:
443
+ `Returns the whole object of one snippet of a summarized run, by its runId: what the summary leaves out, from the same run, without measuring again. ${PAGE_DATA}`,
444
+ inputSchema: z.object({
445
+ runId: RunId,
446
+ snippet: z.string().describe("The id of the result in the summary, or its script"),
447
+ }),
448
+ outputSchema: z.object({ runId: z.string(), snippet: z.string().describe("The id of the result in the run"), result: SnippetResult }),
449
+ annotations: { readOnlyHint: true, openWorldHint: false },
450
+ },
451
+ async ({ runId, snippet }) => {
452
+ // The id the summary shows first, then the Category/Name an alias or a short name stands for
453
+ let resolved;
454
+ try {
455
+ resolved = resolveSnippetName(snippet);
456
+ } catch {
457
+ resolved = undefined;
458
+ }
459
+ try {
460
+ let result;
461
+ try {
462
+ result = runs.get(runId, snippet);
463
+ } catch (err) {
464
+ if (!(err instanceof RunError) || !resolved || resolved === snippet) throw err;
465
+ result = runs.get(runId, resolved);
466
+ }
467
+ return toolResult({ runId, snippet: result.id, result });
468
+ } catch (err) {
469
+ if (err instanceof RunError) return toolError(err.message);
470
+ throw err;
471
+ }
472
+ }
473
+ );
474
+
377
475
  server.registerTool(
378
476
  "close_page",
379
477
  {
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // A failure of get_details the model can act on: a run that is not kept, or a snippet it did not run
4
+ export class RunError extends Error {}
5
+
6
+ // What an agent needs from a result to decide what to do next; the rest (details, items, thresholds)
7
+ // is in get_details, from the same run
8
+ const SUMMARY_FIELDS = ["id", "script", "status", "metric", "value", "unit", "rating", "error", "message", "reason"];
9
+ const ISSUE_FIELDS = ["code", "severity", "message", "target"];
10
+
11
+ const pick = (object, fields) => Object.fromEntries(fields.filter((f) => object[f] !== undefined).map((f) => [f, object[f]]));
12
+
13
+ export function summarize(result) {
14
+ const summary = pick(result, SUMMARY_FIELDS);
15
+ if (result.issues?.length) summary.issues = result.issues.map((issue) => pick(issue, ISSUE_FIELDS));
16
+ return summary;
17
+ }
18
+
19
+ // About 2,000 tokens. Below it the whole objects cost the agent less than the turn it would spend on
20
+ // get_details for what a summary leaves out: in the MCP evals of 2026-10-29, summaries of small results
21
+ // made the agent call get_details in 4 of 9 runs, and each turn sent 8 to 12k tokens of context again.
22
+ export const FULL_UP_TO_CHARS = 8000;
23
+
24
+ // The results of a call as the agent asked for them; with auto, whole when they are small, and summaries
25
+ // with get_details for the rest when they are large
26
+ export function shapeResults(results, detail = "auto", maxFullChars = FULL_UP_TO_CHARS) {
27
+ const full = detail === "full" || (detail === "auto" && JSON.stringify(results).length <= maxFullChars);
28
+ return full ? { detail: "full", results } : { detail: "summary", results: results.map(summarize) };
29
+ }
30
+
31
+ // The full results of the last runs of an MCP connection, by run id, so get_details answers with the
32
+ // values the summary came from: measuring again would give other ones
33
+ export function createRuns({ max = 20 } = {}) {
34
+ const runs = new Map();
35
+
36
+ function keep(results) {
37
+ const runId = randomUUID();
38
+ runs.set(runId, results);
39
+ // A Map keeps the order of insertion, so the first key is the oldest run
40
+ if (runs.size > max) runs.delete(runs.keys().next().value);
41
+ return runId;
42
+ }
43
+
44
+ function get(runId, snippet) {
45
+ const results = runs.get(runId);
46
+ if (!results) {
47
+ throw new RunError(`Unknown runId ${runId}: this server keeps the last ${max} runs of the connection. Run the tool again for a new one.`);
48
+ }
49
+ const result = results.find((r) => r.id === snippet) ?? results.find((r) => r.script === snippet);
50
+ if (!result) throw new RunError(`${snippet} is not in run ${runId}, which has: ${results.map((r) => r.id).join(", ")}.`);
51
+ return result;
52
+ }
53
+
54
+ return { keep, get };
55
+ }
@@ -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`}`;
@@ -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, { median, min, max, unit }] of Object.entries(runs.summary)) {
217
- const cell = (label, value) => pad(`${label} ${formatValue(value, unit)}`, 16);
218
- lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max).trimEnd()}`));
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 value = cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14);
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, { median, min, max, unit }] of Object.entries(runs.summary)) {
122
- lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} |`);
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 ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
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
+ }