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 +22 -7
- package/package.json +1 -1
- package/snippets/Interaction/Scroll-Performance.js +5 -1
- package/src/bin.js +7 -6
- package/src/conditions.js +13 -3
- package/src/mcp/next-steps.js +28 -8
- package/src/mcp/server.js +72 -17
- package/src/runs.js +1 -1
- package/src/throttling.js +4 -1
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
426
|
-
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c
|
|
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
|
-
|
|
6
|
-
|
|
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
|
}
|
package/src/mcp/next-steps.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
? {
|
|
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 ?
|
|
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
|
-
|
|
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 >=
|
|
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"];
|