webperf-snippets 1.13.0 → 1.15.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
@@ -24,6 +24,12 @@ npm install --save-dev webperf-snippets playwright
24
24
  npx playwright install chromium
25
25
  ```
26
26
 
27
+ `npx playwright install chromium` downloads two builds of Chromium: the full Chromium and the headless shell. The CLI and its MCP server run the full Chromium in its headless mode, the binary of Chrome DevTools MCP and of a `--headed` run, and the headless shell only when it is the one installed (`--only-shell`). The full Chromium alone is enough, and skips the download of the headless shell (266 MB on Linux ARM64):
28
+
29
+ ```bash
30
+ npx playwright install --no-shell chromium
31
+ ```
32
+
27
33
  ## Usage
28
34
 
29
35
  ```bash
@@ -70,10 +76,10 @@ JSON output (for piping into `jq` or CI):
70
76
  npx webperf-snippets https://example.com --json
71
77
  ```
72
78
 
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.
79
+ 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 browser (`build` `chromium` for the full Chromium or `chromium-headless-shell`, its `mode` and its `version`), 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
80
 
75
81
  ```json
76
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.13.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
82
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "browser": { "build": "chromium", "mode": "headless", "version": "153.0.8010.12" }, "cliVersion": "1.15.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
77
83
  ```
78
84
 
79
85
  Single snippet:
@@ -171,7 +177,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
171
177
  "timestamp": "2026-05-06T10:00:00.000Z",
172
178
  "navMs": 1240,
173
179
  "results": [],
174
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.13.0" }
180
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.15.0" }
175
181
  }
176
182
  ```
177
183
 
@@ -203,7 +209,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
203
209
  npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
204
210
  ```
205
211
 
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`:
212
+ The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB, INP and the navigation time that it measures, so a measurement of interactions answers with its median INP, 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`:
207
213
 
208
214
  ```json
209
215
  "runs": {
@@ -297,7 +303,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
297
303
  }
298
304
  ```
299
305
 
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, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
306
+ When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs, browser (build, mode or version) 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, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
301
307
 
302
308
  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.
303
309
 
@@ -334,7 +340,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
334
340
  | `--header "Name: value"` | Request header for the origin of the URL only, such as the token of an access proxy. Repeat it for more headers. |
335
341
  | `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
336
342
  | `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
337
- | `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB and `navMs` measured. |
343
+ | `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB, INP and `navMs` measured. |
338
344
  | `--matrix <file>` | Measure every URL of a JSON file on every device and in every variant it lists, and report a table of the medians. |
339
345
  | `--baseline <file>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run: which metrics got worse or better beyond the noise, and which errors are new or fixed. |
340
346
  | `--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. |
@@ -404,15 +410,30 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
404
410
  | Tool | What it does |
405
411
  | --------------- | ---------------------------------------------------------------------------- |
406
412
  | `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
407
- | `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`, `waitMs`, `runs`, `baseline` and `thresholds`. The snippets that record interactions run on a page opened with `open_page`. |
408
- | `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`, `waitMs`, `runs`, `baseline` and `thresholds`. |
413
+ | `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`, `waitMs`, `cpu`, `network`, `interactions`, `runs`, `baseline` and `thresholds`. The snippets that record interactions need `interactions` with a click or a key press, or a page opened with `open_page`. |
414
+ | `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`, `waitMs`, `cpu`, `network`, `interactions`, `runs`, `baseline` and `thresholds`. |
409
415
  | `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. |
410
- | `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
416
+ | `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Takes optional `viewport`, `waitMs`, `cpu` and `network`. Returns a `sessionId`. |
411
417
  | `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
412
418
  | `read_results` | Returns what the tracking snippets of an open page have recorded. |
413
419
  | `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`. |
414
420
  | `close_page` | Closes an open page. |
415
421
 
422
+ `run_snippet`, `run_workflow` and `open_page` take `cpu` and `network`, as `--cpu` and `--network`: a CPU factor of at least 1, such as `4`, and a DevTools preset (`fast-4g`, `slow-4g`, `3g`), applied before the page loads. The answer adds `throttling`, as the JSON does, and the conditions of the run name them, so a baseline measured under other throttling gives a warning.
423
+
424
+ `run_snippet` and `run_workflow` take `interactions`, the steps of `interact` (up to 50), as `--interact-script`: they run after the load of each run, so `runs`, `baseline` and `thresholds` work with them. With a click or a key press, `run_snippet` runs INP, Interactions and Input-Latency-Breakdown, and the `interaction` workflow runs its steps that need input instead of skipping them. The INP of a page over five runs with a click each, with the CPU 4 times slower, against an earlier run:
425
+
426
+ ```json
427
+ {
428
+ "url": "http://localhost:3000/product",
429
+ "snippet": "INP",
430
+ "interactions": [{ "action": "click", "selector": "#buy" }, { "action": "wait", "ms": 300 }],
431
+ "runs": 5,
432
+ "cpu": 4,
433
+ "baseline": "<runId of the run before the change>"
434
+ }
435
+ ```
436
+
416
437
  `run_snippet` and `run_workflow` take `runs`, from 1 to 9, as `--runs`: the page loads that many times, one after the other, and the answer is the median run plus a `runs` field, the one of the JSON, with the median, min, max, the value of each run and the agreement of each metric. A run that fails counts in `failed` and is left out; when every run fails, the answer is a tool error. The server keeps the results of the median run only, as the JSON does, so `get_details` answers from that run and names it in `medianRun`; a metric other than the one that picked it (`medianBy`) can differ there from its median in `runs`.
417
438
 
418
439
  Each run is a page load plus `waitMs`. With the default wait of 3 seconds, five runs of a workflow take about 20 seconds on a local page, and about a minute on a page that takes a few seconds to load. When the client asks for progress, the server reports the three stages of each run, numbered across the runs. A client sets its own limit on a tool call: the MCP SDK stops waiting after 60 seconds unless it is given a longer `timeout` or `resetTimeoutOnProgress`, and in Claude Code `MCP_TOOL_TIMEOUT` sets the limit. When the client gives up, the run in progress ends and no other starts.
@@ -511,18 +532,7 @@ LCP, CLS and INP vary by 15 to 30% on shared runners; prefer the deterministic `
511
532
 
512
533
  ## Publishing
513
534
 
514
- The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed.
515
-
516
- ### Release steps
517
-
518
- 1. Bump the version in `cli/package.json`.
519
- 2. Commit the version change.
520
- 3. Tag and push:
521
- ```bash
522
- git tag cli-v0.2.0
523
- git push origin cli-v0.2.0
524
- ```
525
- 4. The `publish-cli` CI job runs, executes the full test suite, and publishes to npm.
535
+ The CLI package is published to npm and to the MCP Registry via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed, and it waits for a maintainer to approve it in the `npm-publish` environment. [`RELEASING.md`](https://github.com/nucliweb/webperf-snippets/blob/main/RELEASING.md) has the steps: the versions to bump, the checks, the tags, and what to do when a publication fails halfway.
526
536
 
527
537
  ### Why tag-based and not path-based
528
538
 
@@ -532,9 +542,9 @@ An alternative is to publish automatically on every push to `main` that touches
532
542
 
533
543
  Tag protection rules restrict who can push `cli-v*` tags. Configure them under **Settings → Rules → New ruleset** in the repository, targeting the `cli-v*` tag pattern and limiting push access to admins or maintainers. This ensures only authorized collaborators can trigger a publish.
534
544
 
535
- ### Required secret
545
+ ### No stored token
536
546
 
537
- The `NPM_TOKEN` secret must be set in the repository settings with publish access to the `webperf-snippets` npm package.
547
+ The workflow publishes with npm trusted publishing: npm exchanges the OIDC token of the run for a short-lived one, so no npm token is stored in the repository. The MCP Registry takes the same OIDC token.
538
548
 
539
549
  ## Known limitations
540
550
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",
@@ -22,7 +22,7 @@
22
22
  "devDependencies": {
23
23
  "@modelcontextprotocol/client": "^2.3.1",
24
24
  "@projectwallace/css-analyzer": "9.9.5",
25
- "vitest": "^5.0.5",
25
+ "vitest": "^5.1.0",
26
26
  "web-vitals": "^6.2.3"
27
27
  },
28
28
  "engines": {
@@ -26,9 +26,13 @@
26
26
  "scroll", "wheel", "touchstart", "touchmove", "touchend",
27
27
  ]);
28
28
 
29
+ // When Playwright clicks, as the CLI does, its injected script can add touch listeners of its own to
30
+ // window, without passive, to check that the click lands on the element. They are not the page's.
31
+ const addedByPlaywright = () => /\bInjectedScript\b/.test(new Error().stack || "");
32
+
29
33
  const _origAddEventListener = EventTarget.prototype.addEventListener;
30
34
  EventTarget.prototype.addEventListener = function (type, listener, options) {
31
- if (SCROLL_EVENT_TYPES.has(type)) {
35
+ if (SCROLL_EVENT_TYPES.has(type) && !addedByPlaywright()) {
32
36
  const isPassive =
33
37
  options === true
34
38
  ? false // old-style capture boolean, not passive
@@ -172,6 +172,18 @@
172
172
  return { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
173
173
  }
174
174
 
175
+ // An <img> with no src or srcset, and no <source> of its <picture> with one, loads nothing yet: a
176
+ // carousel or a lazy loader fills it in later with JavaScript. The checks of one image (its format,
177
+ // its loading, its dimensions) say nothing about it, so it is counted, and the practice named once.
178
+ const hasSource = (img) =>
179
+ Boolean(img.currentSrc) ||
180
+ Boolean(img.getAttribute("src")?.trim()) ||
181
+ Boolean(img.getAttribute("srcset")?.trim()) ||
182
+ (img.parentElement?.tagName === "PICTURE" &&
183
+ Array.from(img.parentElement.querySelectorAll("source")).some((source) => source.getAttribute("srcset")?.trim()));
184
+ const withoutSrc = images.filter((img) => !hasSource(img));
185
+ const withSrc = images.filter(hasSource);
186
+
175
187
  const { candidate: lcpCandidate, nonImageTag } = await findLcpCandidate(images);
176
188
  const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
177
189
 
@@ -179,7 +191,7 @@
179
191
  // Uses the browser cache (force-cache) so already-loaded images
180
192
  // don't trigger new network requests. Falls back to URL detection
181
193
  // if the server does not expose CORS headers.
182
- const formats = await Promise.all(images.map((img) => fetchFormat(img.currentSrc || img.src)));
194
+ const formats = await Promise.all(withSrc.map((img) => fetchFormat(img.currentSrc || img.src)));
183
195
 
184
196
  // @shared uniqueSelector
185
197
  // A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
@@ -215,7 +227,7 @@
215
227
  }
216
228
  // @end-shared uniqueSelector
217
229
 
218
- const audited = images.map((img, i) => {
230
+ const audited = withSrc.map((img, i) => {
219
231
  const inViewport = isInViewport(img);
220
232
  const isLcp = img === lcpCandidate;
221
233
  const rect = img.getBoundingClientRect();
@@ -282,6 +294,8 @@
282
294
  };
283
295
  });
284
296
 
297
+ const WITHOUT_SRC_MESSAGE = `${withoutSrc.length} <img> without src or srcset load nothing until JavaScript fills them in, as in a carousel: put the real URL in src with loading="lazy" and width/height, so the browser loads them without waiting for the script and reserves their space`;
298
+
285
299
  const withIssues = audited.filter((r) => r.issues.length > 0);
286
300
  const totalErrors = audited.flatMap((r) => r.issues.filter((i) => i.s === "error")).length;
287
301
  const totalWarnings = audited.flatMap((r) => r.issues.filter((i) => i.s === "warning")).length;
@@ -293,6 +307,7 @@
293
307
  console.log("");
294
308
  console.log("%cSummary", "font-weight: bold;");
295
309
  console.log(` Total images : ${images.length}`);
310
+ if (withoutSrc.length > 0) console.log(` Without src : ${withoutSrc.length} (filled in later by JavaScript, not audited one by one)`);
296
311
  console.log(` In viewport : ${audited.filter((r) => r.inViewport).length}`);
297
312
  console.log(` Off viewport : ${audited.filter((r) => !r.inViewport).length}`);
298
313
  console.log(
@@ -326,7 +341,7 @@
326
341
 
327
342
  // Full table
328
343
  console.log("");
329
- console.group(`%c📋 All Images (${images.length})`, "font-weight: bold;");
344
+ console.group(`%c📋 Images with a source (${audited.length})`, "font-weight: bold;");
330
345
  console.table(
331
346
  audited.map((r) => ({
332
347
  src: shortSrc(r.src),
@@ -376,6 +391,12 @@
376
391
  console.log("%c✅ No issues found.", "color: #22c55e; font-weight: bold;");
377
392
  }
378
393
 
394
+ if (withoutSrc.length > 0) {
395
+ console.log("");
396
+ console.log(`%cℹ️ ${WITHOUT_SRC_MESSAGE}`, "color: #3b82f6;");
397
+ console.log(" First of them:", withoutSrc[0]);
398
+ }
399
+
379
400
  // Quick reference
380
401
  console.log("");
381
402
  console.group("%c📝 Quick Reference", "color: #3b82f6; font-weight: bold;");
@@ -420,6 +441,7 @@
420
441
  corsLimitedAnalysis: formatGuessedCount > 0,
421
442
  details: {
422
443
  totalImages: images.length,
444
+ withoutSrc: withoutSrc.length,
423
445
  formatGuessedCount,
424
446
  inViewport: audited.filter((r) => r.inViewport).length,
425
447
  offViewport: audited.filter((r) => !r.inViewport).length,
@@ -464,6 +486,7 @@
464
486
  ...(r.src ? { target: r.src.startsWith("data:") ? r.src.slice(0, 60) : r.src } : {}),
465
487
  }))
466
488
  ),
489
+ ...(withoutSrc.length > 0 ? [{ code: "images-without-src", severity: "info", message: WITHOUT_SRC_MESSAGE }] : []),
467
490
  ],
468
491
  };
469
492
  })();
@@ -1152,7 +1152,7 @@ Returns buffered long tasks immediately. Ongoing tracking continues. `items` lis
1152
1152
 
1153
1153
  #### Image-Element-Audit (async)
1154
1154
 
1155
- `items` holds the 50 images to look at first: the LCP image, then those with the most errors and warnings. `count` and `details` cover every image. The format comes from the `Content-Type` of the response; when a cross-origin image blocks `fetch()` it is guessed from the URL, `details.formatGuessedCount` counts those images and `corsLimitedAnalysis` is `true`.
1155
+ `items` holds the 50 images to look at first: the LCP image, then those with the most errors and warnings. `count` and `details` cover every image. The format comes from the `Content-Type` of the response; when a cross-origin image blocks `fetch()` it is guessed from the URL, `details.formatGuessedCount` counts those images and `corsLimitedAnalysis` is `true`. An `<img>` without `src` or `srcset`, and without a `<source>` of its `<picture>` that has one, loads nothing yet, as the slides a carousel fills in with JavaScript: `details.withoutSrc` counts those images, they stay out of `items` and of the checks of one image, and one `images-without-src` issue names them all.
1156
1156
 
1157
1157
  ```json
1158
1158
  {
@@ -1649,7 +1649,7 @@ Each issue about one hint names its URL in `target`.
1649
1649
 
1650
1650
  ### Image-Element-Audit
1651
1651
 
1652
- Each issue names its image in `target`, except `lcp-not-img`.
1652
+ Each issue names its image in `target`, except `lcp-not-img` and `images-without-src`.
1653
1653
 
1654
1654
  | Code | Severity | When |
1655
1655
  |------|----------|------|
@@ -1665,6 +1665,7 @@ Each issue names its image in `target`, except `lcp-not-img`.
1665
1665
  | `lazy-and-high-priority` | error | An image with both `loading="lazy"` and `fetchpriority="high"` |
1666
1666
  | `missing-dimensions` | warning | An image without `width` and `height` |
1667
1667
  | `no-modern-format` | info | An image that is not WebP, AVIF, JPEG XL or SVG, outside a `<picture>` |
1668
+ | `images-without-src` | info | One or more `<img>` without `src` or `srcset`, which JavaScript fills in later; one issue for all of them |
1668
1669
 
1669
1670
  ### Oversized-Images
1670
1671
 
package/src/baseline.js CHANGED
@@ -204,6 +204,23 @@ function machineWarning(base, current) {
204
204
  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`;
205
205
  }
206
206
 
207
+ // The full Chromium and the headless shell are two builds, and a version of either can move the timings
208
+ // of the same page. A baseline without a browser comes from a CLI that launched the headless shell, with
209
+ // Playwright 1.49 or later, so it warns only when this run used another browser.
210
+ const BUILDS = { chromium: "the full Chromium", "chromium-headless-shell": "the headless shell" };
211
+ const describeBrowser = ({ build, mode, version }) => `${BUILDS[build] ?? build} ${version}${mode === "headed" ? ", headed" : ""}`;
212
+
213
+ function browserWarning(base, current) {
214
+ if (!base || !current?.browser) return null;
215
+ if (!base.browser) {
216
+ const { build, mode } = current.browser;
217
+ if (build === "chromium-headless-shell" && mode === "headless") return null;
218
+ return `the baseline does not name its browser (CLI ${base.cliVersion} launched the headless shell with Playwright 1.49 or later), and this run used ${describeBrowser(current.browser)}; timings can differ for that alone`;
219
+ }
220
+ const [was, is] = [describeBrowser(base.browser), describeBrowser(current.browser)];
221
+ return was === is ? null : `the baseline was measured with ${was}, and this run with ${is}; timings can differ for that alone`;
222
+ }
223
+
207
224
  const valueCount = (payload, metric) => measured(payload, metric).values?.length ?? 1;
208
225
 
209
226
  // Why five runs can leave four values, when a run failed; a run that missed a metric alone does not count
@@ -251,6 +268,8 @@ function warningsOf(base, current, metrics) {
251
268
  }
252
269
  const machine = machineWarning(b?.machine, c?.machine);
253
270
  if (machine) warnings.push(machine);
271
+ const browser = browserWarning(b, c);
272
+ if (browser) warnings.push(browser);
254
273
  if (b && c && b.cliVersion !== c.cliVersion) {
255
274
  warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
256
275
  }
package/src/bin.js CHANGED
@@ -71,7 +71,8 @@ Options:
71
71
  --matrix <file> Measure every URL of a JSON file on every device and in every
72
72
  variant it lists, and report a table of the medians
73
73
  --median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
74
- navMs (default: the first of LCP, FCP, TTFB, navMs measured)
74
+ navMs (default: the first of LCP, FCP, TTFB, INP, navMs
75
+ measured)
75
76
  --baseline <file> Compare the page, or each combination of a matrix, with the --json
76
77
  output of an earlier run: which metrics got worse or better beyond
77
78
  the noise, and which errors are new or fixed. It only informs,
@@ -313,8 +314,9 @@ async function main() {
313
314
  // What the JSON records of how each page was measured, for a later run to compare with. The machine is
314
315
  // measured once, before Chromium starts, so its processes do not slow the benchmark down.
315
316
  const machine = machineOf();
316
- const conditionsFor = (viewport) =>
317
- measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
317
+ // The browser is the one of the run the report keeps: the run itself, or the median one
318
+ const conditionsFor = ({ viewport, cpu, network, browser }) =>
319
+ measurementConditions({ viewport, waitMs, cpu, network, browser, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
318
320
 
319
321
  if (matrixPath) {
320
322
  let matrix;
@@ -336,7 +338,8 @@ async function main() {
336
338
  if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
337
339
  payload = { ...median.payload, runs: median.runs };
338
340
  }
339
- payload.conditions = conditionsFor(viewportName);
341
+ const { browser, ...measured } = payload;
342
+ payload = { ...measured, conditions: conditionsFor({ viewport: viewportName, cpu, network, browser }) };
340
343
 
341
344
  if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
342
345
 
@@ -420,10 +423,11 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
420
423
  const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
421
424
  const median = await measureRunsWithProgress(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
422
425
  const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
426
+ const { browser, ...measured } = median.payload ?? {};
423
427
  combinations.push(
424
428
  median.payload
425
- ? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c.viewport) }
426
- : { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c.viewport) }
429
+ ? { ...identity, ...measured, runs: median.runs, conditions: conditionsFor({ ...c, browser }) }
430
+ : { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
427
431
  );
428
432
  }
429
433
 
package/src/browser.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { chromium } from "playwright";
2
3
 
3
4
  // Chromium for Playwright is a download of its own: npx installs this package and its peer playwright,
@@ -13,9 +14,45 @@ export class MissingBrowserError extends Error {
13
14
 
14
15
  export const isMissingBrowser = (err) => err instanceof MissingBrowserError || /Executable doesn't exist/.test(err?.message ?? "");
15
16
 
16
- export async function launchChromium(options) {
17
+ // Whether the full Chromium of this Playwright is on the machine. executablePath() names it in every
18
+ // version since 1.40, whichever of the two binaries a launch would pick.
19
+ function fullChromiumInstalled() {
17
20
  try {
18
- return await chromium.launch(options);
21
+ return existsSync(chromium.executablePath());
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ // Which Chromium a launch uses. Headless, Playwright 1.49 and later picks the headless shell
28
+ // (chromium_headless_shell-*), a build of its own, and channel "chromium" picks the full Chromium in its
29
+ // headless mode, the binary Chrome DevTools MCP runs. So the full Chromium when it is installed
30
+ // (npx playwright install chromium installs both; --no-shell only this one), and the headless shell
31
+ // otherwise (--only-shell). It is checked on disk, not by a launch that fails: Playwright makes the same
32
+ // check before it starts the process, so the outcome is the same, without a failure in its logs.
33
+ // Before 1.49 there is no headless shell, and channel "chromium" is the executable a launch without a
34
+ // channel runs, with the same arguments. Headed, Playwright already runs the full Chromium; and a
35
+ // channel or an executable of the caller wins.
36
+ export function chromiumLaunchOptions(options = {}, { hasFullChromium = fullChromiumInstalled } = {}) {
37
+ if (options.headless === false || options.channel || options.executablePath) return options;
38
+ return hasFullChromium() ? { channel: "chromium", ...options } : options;
39
+ }
40
+
41
+ // The browser a launch with these options ran, for the conditions of a measurement: its build (the full
42
+ // Chromium, "chromium", or the headless shell, "chromium-headless-shell"), its mode and its version. A
43
+ // later run compared with it can then say when the browser changed, which can move the timings.
44
+ export function launchedBrowser(options = {}, browser) {
45
+ const headed = options.headless === false;
46
+ return {
47
+ build: options.channel ?? (headed ? "chromium" : "chromium-headless-shell"),
48
+ mode: headed ? "headed" : "headless",
49
+ version: browser.version(),
50
+ };
51
+ }
52
+
53
+ export async function launchChromium(options, { launch = (o) => chromium.launch(o), hasFullChromium } = {}) {
54
+ try {
55
+ return await launch(chromiumLaunchOptions(options, { hasFullChromium }));
19
56
  } catch (err) {
20
57
  throw isMissingBrowser(err) ? new MissingBrowserError() : err;
21
58
  }
package/src/conditions.js CHANGED
@@ -1,7 +1,18 @@
1
1
  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
- // 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, machine }) {
6
- return { viewport, ...(snippet ? { snippet } : { workflow }), waitMs, cliVersion, ...(machine ? { machine } : {}) };
4
+ // both were measured the same way. A snippet is named by its path, so an alias and its path match. The
5
+ // CPU factor and the network go in only when the run was throttled, so a run without throttling keeps
6
+ // the conditions of earlier versions. The browser is the one the run launched (browser.js, launchedBrowser).
7
+ export function measurementConditions({ viewport, workflow, snippet, waitMs, cpu, network, browser, machine }) {
8
+ return {
9
+ viewport,
10
+ ...(snippet ? { snippet } : { workflow }),
11
+ waitMs,
12
+ ...(cpu != null ? { cpu } : {}),
13
+ ...(network != null ? { network } : {}),
14
+ ...(browser ? { browser } : {}),
15
+ cliVersion,
16
+ ...(machine ? { machine } : {}),
17
+ };
7
18
  }
@@ -1,29 +1,49 @@
1
1
  import { RULES, nextSteps } from "../decision-tree.js";
2
2
  import { loadSnippet } from "../load-snippet.js";
3
3
  import { isTrackingSnippet } from "../tracking.js";
4
+ import { throttlingOf } from "../throttling.js";
4
5
 
5
6
  const isTrackingPath = (path) => isTrackingSnippet(path, loadSnippet(path));
6
7
 
7
8
  // 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 }) =>
9
+ // snippet waits as long as the run that called for it, so it reads the same paint, and loads under its
10
+ // throttling, so it measures the same page.
11
+ const callFor = ({ path, reason, url, viewport, waitMs, cpu, network, isTracking }) =>
10
12
  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
+ ? { tool: "open_page", args: { url, track: [path], viewport, ...throttlingOf({ cpu, network }) }, reason }
14
+ : { tool: "run_snippet", args: { url, snippet: path, viewport, ...(waitMs !== undefined ? { waitMs } : {}), ...throttlingOf({ cpu, network }) }, reason };
13
15
 
14
16
  // The follow-ups the decision tree of the CLI asks for, as the calls an agent makes next. The rules name
15
17
  // the steps of a workflow (LCP, CLS, TTFB, FCP), which are the scripts of the snippets, so a result of
16
18
  // 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 }) {
19
+ export function followUpCalls({ results, url, viewport, waitMs, cpu, network, ran = [], rules = RULES, isTracking = isTrackingPath }) {
18
20
  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 }));
21
+ return nextSteps(named, rules, ran).map(({ path, reason }) => callFor({ path, reason, url, viewport, waitMs, cpu, network, isTracking }));
20
22
  }
21
23
 
22
24
  // The steps a workflow skipped because they measure clicks and key presses, in one page to open
23
- export function trackingCall({ paths, url, viewport }) {
25
+ export function trackingCall({ paths, url, viewport, cpu, network }) {
24
26
  return {
25
27
  tool: "open_page",
26
- args: { url, track: paths, viewport },
28
+ args: { url, track: paths, viewport, ...throttlingOf({ cpu, network }) },
27
29
  reason: "These steps measure clicks and key presses: open the page with them, interact with it and read them with read_results",
28
30
  };
29
31
  }
32
+
33
+ // A click the agent completes: only the agent can choose the element of the page to click
34
+ const CLICK_TO_COMPLETE = { action: "click", selector: "<selector of the element to click>" };
35
+
36
+ // The steps a workflow skipped because they measure clicks and key presses. First the workflow again
37
+ // with interactions, which measures them in one call, with the runs and the baseline of a page load; then
38
+ // a page to open with them, for an agent that explores the page step by step.
39
+ export function inputStepsCalls({ workflow, paths, url, viewport, waitMs, cpu, network }) {
40
+ return [
41
+ {
42
+ tool: "run_workflow",
43
+ args: { url, workflow, viewport, ...(waitMs !== undefined ? { waitMs } : {}), ...throttlingOf({ cpu, network }), interactions: [CLICK_TO_COMPLETE] },
44
+ reason:
45
+ "These steps measure clicks and key presses: run the workflow again with interactions, a click or type step on an element of the page in place of this selector; runs and baseline work with them",
46
+ },
47
+ trackingCall({ paths, url, viewport, cpu, network }),
48
+ ];
49
+ }
package/src/mcp/server.js CHANGED
@@ -15,12 +15,13 @@ import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js
15
15
  import { registerResources } from "./documents.js";
16
16
  import { registerPrompts } from "./prompts.js";
17
17
  import { shapeResults, createRuns, RunError } from "./summary.js";
18
- import { followUpCalls, trackingCall } from "./next-steps.js";
18
+ import { followUpCalls, inputStepsCalls } from "./next-steps.js";
19
19
  import { isHttpUrl } from "../url.js";
20
20
  import { machineOf } from "../machine.js";
21
21
  import { measurementConditions } from "../conditions.js";
22
22
  import { prepareBaseline, compareWithBaseline } from "./baseline.js";
23
23
  import { MAX_RUNS, measureRuns } from "../runs.js";
24
+ import { MIN_CPU_FACTOR, NETWORK_PRESETS, applyThrottling, throttlingOf } from "../throttling.js";
24
25
 
25
26
  const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
26
27
  const STAGES = 3;
@@ -72,7 +73,27 @@ const Runs = z
72
73
  "Measure the page this many times, one after the other, as --runs of the CLI: the answer is the median run, plus runs with the median, min, max and agreement of each metric. With baseline, 5 or more on both sides give the statistical verdict: use 5 for a comparison, and more only when agreement says noisy. Each run is a full page load and wait, so it takes that much longer"
73
74
  );
74
75
 
76
+ // The throttling of --cpu and --network, applied to the page before it loads. Without them the page runs
77
+ // at the speed of the machine, as the CLI does. A network of the tools is a preset: its own values are
78
+ // for --matrix.
79
+ const Cpu = z
80
+ .number()
81
+ .min(MIN_CPU_FACTOR)
82
+ .optional()
83
+ .describe(
84
+ "Slow the CPU of the page down by this factor, as --cpu of the CLI. Leave it out unless the user asks for throttling or the baseline was throttled: compared with a baseline, use the cpu it was measured with (no throttling by default)"
85
+ );
86
+ const Network = z
87
+ .enum(Object.keys(NETWORK_PRESETS))
88
+ .optional()
89
+ .describe(
90
+ "Throttle the network as DevTools does, as --network of the CLI. Leave it out unless the user asks for throttling or the baseline was throttled: compared with a baseline, use the network it was measured with (no throttling by default)"
91
+ );
92
+
75
93
  const LooseObject = (shape) => z.looseObject(shape).meta({ additionalProperties: true });
94
+ const Throttling = LooseObject({})
95
+ .optional()
96
+ .describe("With cpu or network, the throttling the page ran under, as the throttling field of the JSON of the CLI");
76
97
  const RunsSummary = LooseObject({
77
98
  count: z.number(),
78
99
  failed: z.number().describe("Runs left out because they failed"),
@@ -117,6 +138,7 @@ const DETAIL_NOTE =
117
138
  "Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
118
139
  const BASELINE_NOTE = "With baseline, it also compares the run with an earlier one, as --baseline of the CLI, and says what regressed.";
119
140
  const QUEUE_NOTE = "Measurements run one at a time, so several in one turn save no time.";
141
+ const THROTTLING_NOTE = "cpu and network throttle the page as --cpu and --network of the CLI, and the answer says so in throttling.";
120
142
 
121
143
  // A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
122
144
  // selector, the URL of a script. The tools that return what a page holds say how to read it, and so
@@ -133,6 +155,16 @@ const Step = z.discriminatedUnion("action", [
133
155
  z.object({ action: z.literal("wait"), ms: z.number().int().min(0).max(10000) }),
134
156
  ]);
135
157
 
158
+ // The steps of interact, run after the load of each run, as --interact-script of the CLI
159
+ const Interactions = z
160
+ .array(Step)
161
+ .min(1)
162
+ .max(50)
163
+ .optional()
164
+ .describe(
165
+ "Steps to run on the page after the load, as --interact-script of the CLI: click, type, hover, scroll or wait, as in interact. A click or a key press is what INP, Interactions and Input-Latency-Breakdown measure. Each run repeats them, so runs and baseline work with them"
166
+ );
167
+
136
168
  const SnippetInfo = z.object({
137
169
  name: z.string().describe("Category/Name, the name other tools take"),
138
170
  category: z.enum(CATEGORIES),
@@ -282,11 +314,14 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
282
314
 
283
315
  // The machine goes in the conditions of every run, as in the JSON of the CLI, so a baseline measured
284
316
  // on another one warns. It is measured once, in the turn of the first run, before its Chromium starts.
317
+ // The browser is the one the run launched, the median one with several runs.
285
318
  let machineOfServer;
286
- const conditionsOf = (conditions) => measurementConditions({ ...conditions, machine: (machineOfServer ??= machine()) });
287
-
288
319
  const measureInTurn = (conditions, measure, count, ctx) =>
289
- inTurn(ctx, STAGES * count, async () => ({ conditions: conditionsOf(conditions), run: await measurePage(measure, count, ctx, callLimitMs) }));
320
+ inTurn(ctx, STAGES * count, async () => {
321
+ machineOfServer ??= machine();
322
+ const { browser, ...run } = await measurePage(measure, count, ctx, callLimitMs);
323
+ return { conditions: measurementConditions({ ...conditions, browser, machine: machineOfServer }), run };
324
+ });
290
325
 
291
326
  // A run of a page load, kept with its page in the shape of the JSON of the CLI: its runId is the
292
327
  // baseline of a later run, and the resources list it
@@ -341,12 +376,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
341
376
  {
342
377
  title: "Run a snippet",
343
378
  description:
344
- `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. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
379
+ `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) need interactions with a click or a key press, which run after the load of each run; open_page explores a page step by step instead. ${THROTTLING_NOTE} ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
345
380
  inputSchema: z.object({
346
381
  url: HttpUrl,
347
382
  snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
348
383
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
349
384
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
385
+ cpu: Cpu,
386
+ network: Network,
387
+ interactions: Interactions,
350
388
  runs: Runs,
351
389
  detail: Detail,
352
390
  baseline: Baseline,
@@ -360,6 +398,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
360
398
  viewport: z.enum(VIEWPORTS),
361
399
  navMs: z.number().describe("From the start of the navigation to the end of the wait"),
362
400
  pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
401
+ throttling: Throttling,
363
402
  result: SnippetResult,
364
403
  runs: RunsSummary,
365
404
  baseline: Comparison,
@@ -367,7 +406,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
367
406
  }),
368
407
  annotations: { readOnlyHint: true, openWorldHint: true },
369
408
  },
370
- async ({ url, snippet, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
409
+ async ({ url, snippet, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
371
410
  let path;
372
411
  try {
373
412
  path = resolveSnippetName(snippet);
@@ -375,8 +414,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
375
414
  return toolError(err.message);
376
415
  }
377
416
  const source = loadSnippet(path);
378
- if (isTrackingSnippet(path, source)) {
379
- return toolError(`${path} records interactions: open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`);
417
+ // With interactions, the runner installs it before the steps and reads it after them
418
+ if (isTrackingSnippet(path, source) && !interactions) {
419
+ return toolError(
420
+ `${path} records interactions: run it with interactions that click or type on an element of the page, which run after the load, or open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`
421
+ );
380
422
  }
381
423
  let base;
382
424
  try {
@@ -387,15 +429,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
387
429
 
388
430
  try {
389
431
  const { conditions, run } = await measureInTurn(
390
- { viewport, snippet: path, waitMs },
391
- (onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
432
+ { viewport, snippet: path, waitMs, cpu, network },
433
+ (onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
392
434
  count,
393
435
  ctx
394
436
  );
395
437
  const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
396
438
  const { runId, current } = keepPage(run, run.results, conditions);
397
439
  const id = run.results[0].id;
398
- const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
440
+ const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, cpu, network, ran: [path] });
399
441
  return toolResult({
400
442
  url,
401
443
  runId,
@@ -404,6 +446,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
404
446
  viewport,
405
447
  navMs: run.navMs,
406
448
  pageErrors: run.pageErrors,
449
+ ...(run.throttling ? { throttling: run.throttling } : {}),
407
450
  result: shaped.results[0],
408
451
  ...(run.runs ? { runs: run.runs } : {}),
409
452
  ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
@@ -422,12 +465,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
422
465
  {
423
466
  title: "Run a workflow",
424
467
  description:
425
- `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. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
468
+ `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, or while it runs interactions, which with a click or a key press also measure INP, Interactions and Input-Latency-Breakdown. ${THROTTLING_NOTE} ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
426
469
  inputSchema: z.object({
427
470
  url: HttpUrl,
428
471
  workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
429
472
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
430
473
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
474
+ cpu: Cpu,
475
+ network: Network,
476
+ interactions: Interactions,
431
477
  runs: Runs,
432
478
  detail: Detail,
433
479
  baseline: Baseline,
@@ -441,6 +487,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
441
487
  viewport: z.enum(VIEWPORTS),
442
488
  navMs: z.number(),
443
489
  pageErrors: z.array(z.string()),
490
+ throttling: Throttling,
444
491
  results: z
445
492
  .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
446
493
  .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
@@ -450,7 +497,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
450
497
  }),
451
498
  annotations: { readOnlyHint: true, openWorldHint: true },
452
499
  },
453
- async ({ url, workflow, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
500
+ async ({ url, workflow, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
454
501
  let base;
455
502
  try {
456
503
  base = baselineFor({ baseline, thresholds });
@@ -459,15 +506,19 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
459
506
  }
460
507
  try {
461
508
  const { conditions, run } = await measureInTurn(
462
- { viewport, workflow, waitMs },
463
- (onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
509
+ { viewport, workflow, waitMs, cpu, network },
510
+ (onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
464
511
  count,
465
512
  ctx
466
513
  );
467
514
  // The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
468
515
  const results = run.results.map((r) =>
469
516
  r.status === "skipped"
470
- ? { ...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." }
517
+ ? {
518
+ ...r,
519
+ reason:
520
+ "This step measures clicks and key presses: run the workflow with interactions that click or type on an element of the page, or open the page with open_page and this snippet in track, use it with interact, and read it with read_results.",
521
+ }
471
522
  : r
472
523
  );
473
524
  const shaped = shapeResults(results, detail);
@@ -477,7 +528,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
477
528
  .filter((r) => r.status === "skipped")
478
529
  .map((r) => WORKFLOWS[workflow].steps.find((s) => s.id === r.id)?.path)
479
530
  .filter(Boolean);
480
- const nextSteps = skipped.length ? [trackingCall({ paths: skipped, url, viewport })] : [];
531
+ const nextSteps = skipped.length ? inputStepsCalls({ workflow, paths: skipped, url, viewport, waitMs, cpu, network }) : [];
481
532
  return toolResult({
482
533
  url,
483
534
  runId,
@@ -486,6 +537,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
486
537
  viewport,
487
538
  navMs: run.navMs,
488
539
  pageErrors: run.pageErrors,
540
+ ...(run.throttling ? { throttling: run.throttling } : {}),
489
541
  ...(run.runs ? { runs: run.runs } : {}),
490
542
  ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
491
543
  ...(nextSteps.length ? { nextSteps } : {}),
@@ -558,23 +610,26 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
558
610
  {
559
611
  title: "Open a page",
560
612
  description:
561
- `Opens the URL in a headless Chromium that stays open between calls, and installs the snippets that record interactions (INP, Interactions, Input-Latency-Breakdown, and the other tracking snippets in list_snippets). Returns a sessionId for interact, read_results and close_page. At most 3 pages are open at a time, and a page closes after 10 minutes without use. ${PAGE_DATA}`,
613
+ `Opens the URL in a headless Chromium that stays open between calls, and installs the snippets that record interactions (INP, Interactions, Input-Latency-Breakdown, and the other tracking snippets in list_snippets). Returns a sessionId for interact, read_results and close_page. ${THROTTLING_NOTE} The throttling stays for the interactions on the page. At most 3 pages are open at a time, and a page closes after 10 minutes without use. ${PAGE_DATA}`,
562
614
  inputSchema: z.object({
563
615
  url: HttpUrl,
564
616
  track: z.array(z.string()).min(1).describe("The tracking snippets to install, by name, alias or Category/Name"),
565
617
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
566
618
  waitMs: z.number().int().min(0).max(10000).default(1000).describe("Wait after the load event before installing the snippets, in ms"),
619
+ cpu: Cpu,
620
+ network: Network,
567
621
  }),
568
622
  outputSchema: z.object({
569
623
  sessionId: z.string(),
570
624
  url: z.string(),
571
625
  viewport: z.enum(VIEWPORTS),
572
626
  navMs: z.number(),
627
+ throttling: Throttling,
573
628
  tracking: z.array(z.object({ snippet: z.string(), status: z.string() })).describe("The installed snippets"),
574
629
  }),
575
630
  annotations: { readOnlyHint: true, openWorldHint: true },
576
631
  },
577
- withSession(async ({ url, track, viewport, waitMs }, ctx) => {
632
+ withSession(async ({ url, track, viewport, waitMs, cpu, network }, ctx) => {
578
633
  const items = [];
579
634
  for (const name of track) {
580
635
  let path;
@@ -600,6 +655,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
600
655
  throw err;
601
656
  }
602
657
  try {
658
+ // Before the load, as the runner applies it: the load and every interaction run throttled
659
+ await applyThrottling(session.context, session.page, { cpu, network });
603
660
  const navStart = Date.now();
604
661
  await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
605
662
  if (waitMs > 0) await session.page.waitForTimeout(waitMs);
@@ -609,7 +666,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
609
666
  if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
610
667
  session.stopListening();
611
668
  const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
612
- return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
669
+ const throttling = throttlingOf({ cpu, network });
670
+ return toolResult({ sessionId: session.id, url, viewport, navMs, ...(throttling ? { throttling } : {}), tracking });
613
671
  } catch (err) {
614
672
  await sessions.close(session.id);
615
673
  if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
package/src/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { launchChromium } from "./browser.js";
1
+ import { chromiumLaunchOptions, launchChromium, launchedBrowser } from "./browser.js";
2
2
  import { loadSnippet } from "./load-snippet.js";
3
3
  import { runInteractions, loadInteractionSteps, isInputStep, settle } from "./interactions.js";
4
4
  import { isTrackingSnippet } from "./tracking.js";
@@ -131,7 +131,9 @@ export async function runSnippets({
131
131
  signal,
132
132
  }) {
133
133
  await onProgress(1, "Opening Chromium");
134
- const browser = await launchChromium({ headless });
134
+ const launchOptions = chromiumLaunchOptions({ headless });
135
+ const browser = await launchChromium(launchOptions);
136
+ const launched = launchedBrowser(launchOptions, browser);
135
137
  const stopListening = closeOnAbort(browser, signal);
136
138
  try {
137
139
  const context = await browser.newContext({ viewport, storageState });
@@ -148,7 +150,7 @@ export async function runSnippets({
148
150
  const prepared = items.map((item) => ({ ...item, source: withOwnDomains(item.source, ownDomains) }));
149
151
  const results = await evaluateAroundInteractions(page, prepared, interactions);
150
152
  await onProgress(3, "Snippets evaluated");
151
- return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
153
+ return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }), browser: launched };
152
154
  } finally {
153
155
  stopListening();
154
156
  await browser.close();
@@ -174,7 +176,9 @@ export async function runMeasurement({
174
176
  signal,
175
177
  }) {
176
178
  await onProgress(1, "Opening Chromium");
177
- const browser = await launchChromium({ headless });
179
+ const launchOptions = chromiumLaunchOptions({ headless });
180
+ const browser = await launchChromium(launchOptions);
181
+ const launched = launchedBrowser(launchOptions, browser);
178
182
  const stopListening = closeOnAbort(browser, signal);
179
183
  try {
180
184
  const context = await browser.newContext({ viewport, storageState });
@@ -237,7 +241,7 @@ export async function runMeasurement({
237
241
  }
238
242
 
239
243
  await onProgress(3, "Workflow and follow-ups evaluated");
240
- return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
244
+ return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }), browser: launched };
241
245
  } finally {
242
246
  stopListening();
243
247
  await browser.close();
package/src/runs.js CHANGED
@@ -13,7 +13,7 @@ export const MAX_RUNS = 9;
13
13
  export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
14
14
 
15
15
  // Without --median-by, the median follows the first of these that the runs measured
16
- const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "navMs"];
16
+ const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "INP", "navMs"];
17
17
 
18
18
  const SUMMARY_METRICS = MEDIAN_METRICS.filter((metric) => metric !== "navMs");
19
19
 
package/src/throttling.js CHANGED
@@ -10,10 +10,13 @@ export const NETWORK_PRESETS = {
10
10
  "3g": { title: "3G", download: (500 * 1000) / 8 * 0.8, upload: (500 * 1000) / 8 * 0.8, latency: 400 * 5 },
11
11
  };
12
12
 
13
+ // The smallest CPU factor, no slowdown, for --cpu and the cpu of the tools of the MCP server
14
+ export const MIN_CPU_FACTOR = 1;
15
+
13
16
  // The factor given to --cpu, or null when it is not a number of at least 1
14
17
  export function parseCpuFactor(raw) {
15
18
  const factor = Number(raw);
16
- return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
19
+ return raw.trim() !== "" && Number.isFinite(factor) && factor >= MIN_CPU_FACTOR ? factor : null;
17
20
  }
18
21
 
19
22
  const NETWORK_VALUES = ["downloadKbps", "uploadKbps", "latencyMs"];