webperf-snippets 1.13.0 → 1.14.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
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
73
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.13.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
76
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.14.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
77
77
  ```
78
78
 
79
79
  Single snippet:
@@ -171,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
171
171
  "timestamp": "2026-05-06T10:00:00.000Z",
172
172
  "navMs": 1240,
173
173
  "results": [],
174
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.13.0" }
174
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.14.0" }
175
175
  }
176
176
  ```
177
177
 
@@ -203,7 +203,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
203
203
  npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
204
204
  ```
205
205
 
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`:
206
+ 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
207
 
208
208
  ```json
209
209
  "runs": {
@@ -334,7 +334,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
334
334
  | `--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
335
  | `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
336
336
  | `--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. |
337
+ | `--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
338
  | `--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
339
  | `--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
340
  | `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
@@ -404,15 +404,30 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
404
404
  | Tool | What it does |
405
405
  | --------------- | ---------------------------------------------------------------------------- |
406
406
  | `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`. |
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`, `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`. |
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`, `cpu`, `network`, `interactions`, `runs`, `baseline` and `thresholds`. |
409
409
  | `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`. |
410
+ | `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
411
  | `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
412
412
  | `read_results` | Returns what the tracking snippets of an open page have recorded. |
413
413
  | `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
414
  | `close_page` | Closes an open page. |
415
415
 
416
+ `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.
417
+
418
+ `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:
419
+
420
+ ```json
421
+ {
422
+ "url": "http://localhost:3000/product",
423
+ "snippet": "INP",
424
+ "interactions": [{ "action": "click", "selector": "#buy" }, { "action": "wait", "ms": 300 }],
425
+ "runs": 5,
426
+ "cpu": 4,
427
+ "baseline": "<runId of the run before the change>"
428
+ }
429
+ ```
430
+
416
431
  `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
432
 
418
433
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.13.0",
3
+ "version": "1.14.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",
@@ -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
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,8 @@ 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
+ const conditionsFor = ({ viewport, cpu, network }) =>
318
+ measurementConditions({ viewport, waitMs, cpu, network, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
318
319
 
319
320
  if (matrixPath) {
320
321
  let matrix;
@@ -336,7 +337,7 @@ async function main() {
336
337
  if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
337
338
  payload = { ...median.payload, runs: median.runs };
338
339
  }
339
- payload.conditions = conditionsFor(viewportName);
340
+ payload.conditions = conditionsFor({ viewport: viewportName, cpu, network });
340
341
 
341
342
  if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
342
343
 
@@ -422,8 +423,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
422
423
  const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
423
424
  combinations.push(
424
425
  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) }
426
+ ? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c) }
427
+ : { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
427
428
  );
428
429
  }
429
430
 
package/src/conditions.js CHANGED
@@ -1,7 +1,17 @@
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.
7
+ export function measurementConditions({ viewport, workflow, snippet, waitMs, cpu, network, machine }) {
8
+ return {
9
+ viewport,
10
+ ...(snippet ? { snippet } : { workflow }),
11
+ waitMs,
12
+ ...(cpu != null ? { cpu } : {}),
13
+ ...(network != null ? { network } : {}),
14
+ cliVersion,
15
+ ...(machine ? { machine } : {}),
16
+ };
7
17
  }
@@ -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),
@@ -341,12 +373,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
341
373
  {
342
374
  title: "Run a snippet",
343
375
  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}`,
376
+ `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
377
  inputSchema: z.object({
346
378
  url: HttpUrl,
347
379
  snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
348
380
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
349
381
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
382
+ cpu: Cpu,
383
+ network: Network,
384
+ interactions: Interactions,
350
385
  runs: Runs,
351
386
  detail: Detail,
352
387
  baseline: Baseline,
@@ -360,6 +395,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
360
395
  viewport: z.enum(VIEWPORTS),
361
396
  navMs: z.number().describe("From the start of the navigation to the end of the wait"),
362
397
  pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
398
+ throttling: Throttling,
363
399
  result: SnippetResult,
364
400
  runs: RunsSummary,
365
401
  baseline: Comparison,
@@ -367,7 +403,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
367
403
  }),
368
404
  annotations: { readOnlyHint: true, openWorldHint: true },
369
405
  },
370
- async ({ url, snippet, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
406
+ async ({ url, snippet, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
371
407
  let path;
372
408
  try {
373
409
  path = resolveSnippetName(snippet);
@@ -375,8 +411,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
375
411
  return toolError(err.message);
376
412
  }
377
413
  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.`);
414
+ // With interactions, the runner installs it before the steps and reads it after them
415
+ if (isTrackingSnippet(path, source) && !interactions) {
416
+ return toolError(
417
+ `${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.`
418
+ );
380
419
  }
381
420
  let base;
382
421
  try {
@@ -387,15 +426,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
387
426
 
388
427
  try {
389
428
  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 }),
429
+ { viewport, snippet: path, waitMs, cpu, network },
430
+ (onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
392
431
  count,
393
432
  ctx
394
433
  );
395
434
  const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
396
435
  const { runId, current } = keepPage(run, run.results, conditions);
397
436
  const id = run.results[0].id;
398
- const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
437
+ const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, cpu, network, ran: [path] });
399
438
  return toolResult({
400
439
  url,
401
440
  runId,
@@ -404,6 +443,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
404
443
  viewport,
405
444
  navMs: run.navMs,
406
445
  pageErrors: run.pageErrors,
446
+ ...(run.throttling ? { throttling: run.throttling } : {}),
407
447
  result: shaped.results[0],
408
448
  ...(run.runs ? { runs: run.runs } : {}),
409
449
  ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
@@ -422,12 +462,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
422
462
  {
423
463
  title: "Run a workflow",
424
464
  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}`,
465
+ `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
466
  inputSchema: z.object({
427
467
  url: HttpUrl,
428
468
  workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
429
469
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
430
470
  waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
471
+ cpu: Cpu,
472
+ network: Network,
473
+ interactions: Interactions,
431
474
  runs: Runs,
432
475
  detail: Detail,
433
476
  baseline: Baseline,
@@ -441,6 +484,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
441
484
  viewport: z.enum(VIEWPORTS),
442
485
  navMs: z.number(),
443
486
  pageErrors: z.array(z.string()),
487
+ throttling: Throttling,
444
488
  results: z
445
489
  .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
446
490
  .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
@@ -450,7 +494,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
450
494
  }),
451
495
  annotations: { readOnlyHint: true, openWorldHint: true },
452
496
  },
453
- async ({ url, workflow, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
497
+ async ({ url, workflow, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
454
498
  let base;
455
499
  try {
456
500
  base = baselineFor({ baseline, thresholds });
@@ -459,15 +503,19 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
459
503
  }
460
504
  try {
461
505
  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 }),
506
+ { viewport, workflow, waitMs, cpu, network },
507
+ (onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
464
508
  count,
465
509
  ctx
466
510
  );
467
511
  // The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
468
512
  const results = run.results.map((r) =>
469
513
  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." }
514
+ ? {
515
+ ...r,
516
+ reason:
517
+ "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.",
518
+ }
471
519
  : r
472
520
  );
473
521
  const shaped = shapeResults(results, detail);
@@ -477,7 +525,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
477
525
  .filter((r) => r.status === "skipped")
478
526
  .map((r) => WORKFLOWS[workflow].steps.find((s) => s.id === r.id)?.path)
479
527
  .filter(Boolean);
480
- const nextSteps = skipped.length ? [trackingCall({ paths: skipped, url, viewport })] : [];
528
+ const nextSteps = skipped.length ? inputStepsCalls({ workflow, paths: skipped, url, viewport, waitMs, cpu, network }) : [];
481
529
  return toolResult({
482
530
  url,
483
531
  runId,
@@ -486,6 +534,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
486
534
  viewport,
487
535
  navMs: run.navMs,
488
536
  pageErrors: run.pageErrors,
537
+ ...(run.throttling ? { throttling: run.throttling } : {}),
489
538
  ...(run.runs ? { runs: run.runs } : {}),
490
539
  ...(base ? { baseline: compareWithBaseline(base, current) } : {}),
491
540
  ...(nextSteps.length ? { nextSteps } : {}),
@@ -558,23 +607,26 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
558
607
  {
559
608
  title: "Open a page",
560
609
  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}`,
610
+ `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
611
  inputSchema: z.object({
563
612
  url: HttpUrl,
564
613
  track: z.array(z.string()).min(1).describe("The tracking snippets to install, by name, alias or Category/Name"),
565
614
  viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
566
615
  waitMs: z.number().int().min(0).max(10000).default(1000).describe("Wait after the load event before installing the snippets, in ms"),
616
+ cpu: Cpu,
617
+ network: Network,
567
618
  }),
568
619
  outputSchema: z.object({
569
620
  sessionId: z.string(),
570
621
  url: z.string(),
571
622
  viewport: z.enum(VIEWPORTS),
572
623
  navMs: z.number(),
624
+ throttling: Throttling,
573
625
  tracking: z.array(z.object({ snippet: z.string(), status: z.string() })).describe("The installed snippets"),
574
626
  }),
575
627
  annotations: { readOnlyHint: true, openWorldHint: true },
576
628
  },
577
- withSession(async ({ url, track, viewport, waitMs }, ctx) => {
629
+ withSession(async ({ url, track, viewport, waitMs, cpu, network }, ctx) => {
578
630
  const items = [];
579
631
  for (const name of track) {
580
632
  let path;
@@ -600,6 +652,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
600
652
  throw err;
601
653
  }
602
654
  try {
655
+ // Before the load, as the runner applies it: the load and every interaction run throttled
656
+ await applyThrottling(session.context, session.page, { cpu, network });
603
657
  const navStart = Date.now();
604
658
  await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
605
659
  if (waitMs > 0) await session.page.waitForTimeout(waitMs);
@@ -609,7 +663,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
609
663
  if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
610
664
  session.stopListening();
611
665
  const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
612
- return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
666
+ const throttling = throttlingOf({ cpu, network });
667
+ return toolResult({ sessionId: session.id, url, viewport, navMs, ...(throttling ? { throttling } : {}), tracking });
613
668
  } catch (err) {
614
669
  await sessions.close(session.id);
615
670
  if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
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"];