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 +33 -23
- package/package.json +2 -2
- package/snippets/Interaction/Scroll-Performance.js +5 -1
- package/snippets/Media/Image-Element-Audit.js +26 -3
- package/snippets/SCHEMA.md +3 -2
- package/src/baseline.js +19 -0
- package/src/bin.js +10 -6
- package/src/browser.js +39 -2
- package/src/conditions.js +14 -3
- package/src/mcp/next-steps.js +28 -8
- package/src/mcp/server.js +78 -20
- package/src/runner.js +9 -5
- package/src/runs.js +1 -1
- package/src/throttling.js +4 -1
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.
|
|
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.
|
|
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
|
|
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
|
-
###
|
|
545
|
+
### No stored token
|
|
536
546
|
|
|
537
|
-
The
|
|
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.
|
|
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
|
|
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(
|
|
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 =
|
|
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📋
|
|
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
|
})();
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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, ...
|
|
426
|
-
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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. 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
|
}
|
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),
|
|
@@ -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 () =>
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
? {
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
|
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 >=
|
|
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"];
|