webperf-snippets 1.10.0 → 1.12.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 +25 -7
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +20 -4
- package/snippets/CoreWebVitals/LCP-Trail.js +52 -40
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -0
- package/snippets/Interaction/Interactions.js +4 -0
- package/snippets/SCHEMA.md +33 -5
- package/src/baseline.js +51 -12
- package/src/bin.js +20 -19
- package/src/crux.js +11 -4
- package/src/mcp/baseline.js +26 -0
- package/src/mcp/queue.js +72 -0
- package/src/mcp/server.js +291 -59
- package/src/mcp/summary.js +30 -8
- package/src/runner.js +26 -1
- package/src/runs.js +22 -0
- package/src/snippet-names.js +90 -19
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.12.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -138,13 +138,13 @@ Get a key for the [Chrome UX Report API](https://developer.chrome.com/docs/crux/
|
|
|
138
138
|
|
|
139
139
|
With a key, the field data also gives what CrUX knows of the LCP when it is an image: the p75 of its sub-parts, TTFB, load delay, load duration and render delay. When the measurement ran LCP-Subparts, with `--snippet LCP-Subparts` or the `core-web-vitals` workflow over 2.5 s, a table puts each one next to its lab value, with a note where one side is at least 1.5 times the other and 100 ms apart: a slower server or network for users, an image the lab finds sooner, slower connections, slower devices, or a lab slower than users. The field sub-parts describe only the loads whose LCP is an image, and the table says which share that is; when the LCP of the lab is not an image, only TTFB and render delay compare. Each p75 is taken on its own, so the sub-parts do not add up to the p75 of LCP. A line gives the navigation types of the users, by share, and points to Back-Forward-Cache when over 5 % of the visits come back without the back/forward cache. The JSON output carries them in `crux.subParts`, `crux.lcpImageShare`, `crux.roundTripTime` and `crux.navigationTypes`.
|
|
140
140
|
|
|
141
|
-
`--crux-history <weeks>` adds the trend of the field data over the last 1 to 40 weeks, from the [CrUX History API](https://developer.chrome.com/docs/crux/history-api), with the same key. Each week is the p75 of the 28 days before it, so the weeks overlap and the series is smooth. For LCP, CLS and INP the section gives the first and the last week, the change between them and its direction, `stable` within 5 %, and the weeks as a sparkline, where a gap is a week with too little data:
|
|
141
|
+
`--crux-history <weeks>` adds the trend of the field data over the last 1 to 40 weeks, from the [CrUX History API](https://developer.chrome.com/docs/crux/history-api), with the same key. Each week is the p75 of the 28 days before it, so the weeks overlap and the series is smooth. For LCP, CLS and INP the section gives the first and the last week, the change between them and its direction, `stable` within 5 %, and the weeks as a sparkline, where a gap is a week with too little data. The bars span at least the smallest change `--baseline` counts for the metric (100 ms for LCP, 50 ms for INP, 0.05 for CLS), so a series that barely moves draws almost flat:
|
|
142
142
|
|
|
143
143
|
```
|
|
144
144
|
Field data trend (CrUX p75, 40 weeks, mobile, origin-level)
|
|
145
145
|
LCP 2.88s → 3.10s ▲ +7.7% worsening ▃▃▄▃▃▃▄▆▇██▇▆▄▂▁▂▃▃▄▅▆▆▆▆▆▆
|
|
146
146
|
CLS 0.2600 → 0.2100 ▼ −19.2% improving ▅▆▆▇▇█▇▆▆▅▄▃▃▃▃▃▂▂▁▂▂▂▂▂▂▂
|
|
147
|
-
INP 253ms → 251ms ▼ −0.8% stable
|
|
147
|
+
INP 253ms → 251ms ▼ −0.8% stable ▅▅▅▄▄▄▅▅▆▆▆▅▅▄▄▄▄▅▅▅▅▄▄▄▃▃▄▄▄▅▅
|
|
148
148
|
```
|
|
149
149
|
|
|
150
150
|
The JSON output carries it in `crux.history`, with the periods, the p75 of each week and the trend of each metric. Like the p75, it uses the page when it has data of its own and its origin otherwise, and says which in `scope`.
|
|
@@ -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.12.0" }
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -297,7 +297,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
|
|
|
297
297
|
}
|
|
298
298
|
```
|
|
299
299
|
|
|
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
|
|
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`.
|
|
301
301
|
|
|
302
302
|
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
303
|
|
|
@@ -351,6 +351,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
351
351
|
| `--verbose` | Show all items, including passing checks. |
|
|
352
352
|
| `--headed` | Show the browser window (debug). |
|
|
353
353
|
| `-h, --help` | Show help. |
|
|
354
|
+
| `-v, --version` | Show the version. |
|
|
354
355
|
|
|
355
356
|
### Snippet aliases
|
|
356
357
|
|
|
@@ -360,6 +361,11 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
360
361
|
| `CLS` | CoreWebVitals/CLS |
|
|
361
362
|
| `INP` | CoreWebVitals/INP |
|
|
362
363
|
| `LCP-Subparts` | CoreWebVitals/LCP-Subparts |
|
|
364
|
+
| `Largest-Contentful-Paint` | CoreWebVitals/LCP |
|
|
365
|
+
| `Cumulative-Layout-Shift` | CoreWebVitals/CLS |
|
|
366
|
+
| `Interaction-to-Next-Paint` | CoreWebVitals/INP |
|
|
367
|
+
| `First-Contentful-Paint` | Loading/FCP |
|
|
368
|
+
| `Time-to-First-Byte` | Loading/TTFB |
|
|
363
369
|
| `fonts` | Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold |
|
|
364
370
|
| `render-blocking` | Loading/Find-render-blocking-resources |
|
|
365
371
|
| `resource-hints` | Loading/Resource-Hints-Validation |
|
|
@@ -398,8 +404,8 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
|
|
|
398
404
|
| Tool | What it does |
|
|
399
405
|
| --------------- | ---------------------------------------------------------------------------- |
|
|
400
406
|
| `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
|
|
401
|
-
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `
|
|
402
|
-
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport` and `
|
|
407
|
+
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport`, `waitMs`, `runs`, `baseline` and `thresholds`. The snippets that record interactions run on a page opened with `open_page`. |
|
|
408
|
+
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport`, `waitMs`, `runs`, `baseline` and `thresholds`. |
|
|
403
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. |
|
|
404
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`. |
|
|
405
411
|
| `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
|
|
@@ -407,6 +413,16 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
|
|
|
407
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`. |
|
|
408
414
|
| `close_page` | Closes an open page. |
|
|
409
415
|
|
|
416
|
+
`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
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
An agent often sends several calls in one turn. The calls of a connection that make a page work (`run_snippet`, `run_workflow`, `open_page`, `interact`) run one at a time, in the order they arrive, so they do not compete for the CPU and the turn saves no time. A call keeps its turn only while it runs, so a page left open holds up nothing, and `read_results`, `close_page` and the tools that load no page answer at once. Waiting counts towards the limit of the client; when it asks for progress, a waiting call reports how many calls are ahead, when the queue moves and every 5 seconds. A call the client gives up on leaves the queue and its page never loads; if it was the one measuring, its Chromium closes and the next call starts. A page that stops answering fails its call after 2 minutes per run, plus the `wait` steps of `interact`, and closes. Two clients at the same time, or a client and the CLI, have no queue in common.
|
|
421
|
+
|
|
422
|
+
`run_snippet` and `run_workflow` compare a run with an earlier one, with the verdict of `--baseline`. `baseline` takes the `runId` of an earlier run of the connection, which every run of these two tools returns, or the path of a page the CLI wrote with `--json`, absolute or relative to the working directory of the server. The answer adds a `baseline` field: the one of the JSON of the CLI, with the `runId` or the `file` it compared with, plus `regressions`, the same lines `--fail-on-regression` prints. `thresholds` takes the values of `--threshold`, such as `["LCP=15%", "CLS=0.1"]`. A baseline that cannot be read or a threshold the server does not understand is a tool error, before the page loads, and a baseline of a matrix is refused, since a tool measures one page.
|
|
423
|
+
|
|
424
|
+
With five runs or more a side, a regression needs a Mann-Whitney U p-value under 0.05 on top of its threshold, as in the CLI, and each metric adds `stats` with the p-value, the shift and its 95 % interval. With fewer, a metric compares by its threshold and, against a baseline of several runs, by the range of those runs. A single run can be off by more than a threshold on a page that did not change, so read `warnings`, which also names each metric left without statistics and the values on each side after failed runs, and measure both sides with `runs: 5`, or `--runs 5` in the CLI, when the verdict matters. An earlier run of the connection works for a change made in the same session, and a file measured with the CLI for a comparison with `main`.
|
|
425
|
+
|
|
410
426
|
The server also offers resources, documents a client can attach to the context:
|
|
411
427
|
|
|
412
428
|
| Resource | What it has |
|
|
@@ -414,6 +430,7 @@ The server also offers resources, documents a client can attach to the context:
|
|
|
414
430
|
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result, the rules of the issue codes. |
|
|
415
431
|
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example, and the codes of its issues. |
|
|
416
432
|
| `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
|
|
433
|
+
| `webperf://runs/{runId}` | A run of `run_snippet` or `run_workflow` of the connection, in the shape of the JSON of the CLI. The list names each run with its snippet or workflow, its URL, its viewport, how many runs it measured when they are several, and its time. |
|
|
417
434
|
|
|
418
435
|
And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
|
|
419
436
|
|
|
@@ -524,6 +541,7 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
524
541
|
- **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
|
|
525
542
|
- **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
|
|
526
543
|
- **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions; use RUM for field data.
|
|
544
|
+
- **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds. `tracking` after a click means that the document recorded no interaction, as with a click inside an iframe: the run exits with code `0` unless it has a budget.
|
|
527
545
|
|
|
528
546
|
## Roadmap
|
|
529
547
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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",
|
|
@@ -169,9 +169,14 @@
|
|
|
169
169
|
observer.observe({
|
|
170
170
|
type: "event",
|
|
171
171
|
buffered: true,
|
|
172
|
-
durationThreshold: 16, //
|
|
172
|
+
durationThreshold: 16, // The lowest the browser accepts
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
// With no frame after it, an interaction that paints nothing is measured to the end of its handlers,
|
|
176
|
+
// under the 16 ms of `event` entries. `first-input` has no threshold, so the first one is still read;
|
|
177
|
+
// web-vitals does the same.
|
|
178
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
179
|
+
|
|
175
180
|
// Log INP summary
|
|
176
181
|
const logINP = () => {
|
|
177
182
|
const rating = valueToRating(inpValue);
|
|
@@ -231,7 +236,7 @@
|
|
|
231
236
|
console.log(` Presentation Delay: ${formatMs(phases.presentationDelay)}`);
|
|
232
237
|
|
|
233
238
|
// Visual bar
|
|
234
|
-
const total =
|
|
239
|
+
const total = phases.inputDelay + phases.processingTime + phases.presentationDelay;
|
|
235
240
|
const barWidth = 40;
|
|
236
241
|
const inputBar = "▓".repeat(Math.round((phases.inputDelay / total) * barWidth));
|
|
237
242
|
const processBar = "█".repeat(Math.round((phases.processingTime / total) * barWidth));
|
|
@@ -358,15 +363,26 @@
|
|
|
358
363
|
details,
|
|
359
364
|
};
|
|
360
365
|
}
|
|
366
|
+
const value = Math.round(inpValue);
|
|
361
367
|
return {
|
|
362
368
|
script: "INP",
|
|
363
369
|
status: "ok",
|
|
364
370
|
metric: "INP",
|
|
365
|
-
value
|
|
371
|
+
value,
|
|
366
372
|
unit: "ms",
|
|
367
373
|
rating,
|
|
368
374
|
thresholds: { good: 200, needsImprovement: 500 },
|
|
369
375
|
details,
|
|
376
|
+
// Under one frame there is no paint to measure to: the value is real, and says little about the page
|
|
377
|
+
...(inpValue < 16 && {
|
|
378
|
+
issues: [
|
|
379
|
+
{
|
|
380
|
+
code: "no-paint-measured",
|
|
381
|
+
severity: "info",
|
|
382
|
+
message: `Worst interaction: ${value} ms, under the 16 ms of a frame, so the page most likely painted nothing after it. If it should respond, check that the click or key press reaches an element that does.`,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
}),
|
|
370
386
|
};
|
|
371
387
|
};
|
|
372
388
|
|
|
@@ -492,7 +508,7 @@
|
|
|
492
508
|
});
|
|
493
509
|
|
|
494
510
|
console.log("%c⚡ INP Tracking Active", "font-weight: bold; font-size: 14px;");
|
|
495
|
-
console.log(" Interactions
|
|
511
|
+
console.log(" Interactions of 16ms or more are tracked, and the first one whatever it takes.");
|
|
496
512
|
console.log(
|
|
497
513
|
" Call %cgetINP()%c to see current INP value.",
|
|
498
514
|
"font-family: monospace; background: #f3f4f6; padding: 2px 4px;",
|
|
@@ -64,7 +64,12 @@
|
|
|
64
64
|
}
|
|
65
65
|
// @end-shared uniqueSelector
|
|
66
66
|
|
|
67
|
+
// The element of an entry is null once its node has left the DOM, as with a loading spinner that
|
|
68
|
+
// an app removes when it renders. The entry keeps its time, size and URL.
|
|
69
|
+
const REMOVED = "(removed from DOM)";
|
|
70
|
+
|
|
67
71
|
const getElementInfo = (element, entry) => {
|
|
72
|
+
if (!element) return entry.url ? { type: "Image", url: entry.url } : { type: "Removed element" };
|
|
68
73
|
const tag = element.tagName.toLowerCase();
|
|
69
74
|
if (tag === "img") return { type: "Image", url: entry.url || element.src };
|
|
70
75
|
if (tag === "video") return { type: "Video poster", url: entry.url || element.poster };
|
|
@@ -72,6 +77,19 @@
|
|
|
72
77
|
return { type: tag === "h1" || tag === "p" ? "Text block" : tag };
|
|
73
78
|
};
|
|
74
79
|
|
|
80
|
+
// One candidate per element, or per entry when its element was removed
|
|
81
|
+
const candidateKey = (entry) => entry.element ?? `${entry.startTime}|${entry.size}`;
|
|
82
|
+
|
|
83
|
+
// A data: URL keeps its media type, and another URL its file name
|
|
84
|
+
const shortUrl = (url) => {
|
|
85
|
+
if (url.startsWith("data:")) return `${url.split(",")[0]},…`;
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(url);
|
|
88
|
+
const file = u.pathname.split("/").pop()?.split("?")[0];
|
|
89
|
+
return u.hostname !== location.hostname ? `${u.hostname}/…/${file}` : file || url;
|
|
90
|
+
} catch { return url; }
|
|
91
|
+
};
|
|
92
|
+
|
|
75
93
|
const candidates = [];
|
|
76
94
|
|
|
77
95
|
const logTrail = () => {
|
|
@@ -89,12 +107,13 @@
|
|
|
89
107
|
// Current LCP element attribution
|
|
90
108
|
console.log("");
|
|
91
109
|
console.log("%cLCP Element:", "font-weight: bold;");
|
|
92
|
-
console.log(` Element: ${current.selector}`, current.element);
|
|
110
|
+
if (current.element) console.log(` Element: ${current.selector}`, current.element);
|
|
111
|
+
else console.log(` Element: ${REMOVED}${current.entry.id ? ` #${current.entry.id}` : ""}`);
|
|
93
112
|
|
|
94
113
|
const { type, url } = getElementInfo(current.element, current.entry);
|
|
95
114
|
console.log(` Type: ${type}`);
|
|
96
|
-
if (url) console.log(` URL: ${url}`);
|
|
97
|
-
if (current.element
|
|
115
|
+
if (url) console.log(` URL: ${shortUrl(url)}`);
|
|
116
|
+
if (current.element?.naturalWidth) {
|
|
98
117
|
console.log(
|
|
99
118
|
` Dimensions: ${current.element.naturalWidth}×${current.element.naturalHeight}`
|
|
100
119
|
);
|
|
@@ -111,14 +130,14 @@
|
|
|
111
130
|
console.log(
|
|
112
131
|
`%c ● ${index}. ${selector}`,
|
|
113
132
|
`color: ${color}; font-weight: ${isCurrent ? "bold" : "normal"};`,
|
|
114
|
-
`| ${(time / 1000).toFixed(2)}s — ${name}${isCurrent ? " ← LCP" : ""}`,
|
|
115
|
-
element
|
|
133
|
+
`| ${(time / 1000).toFixed(2)}s${element ? ` — ${name}` : ""}${isCurrent ? " ← LCP" : ""}`,
|
|
134
|
+
...(element ? [element] : [])
|
|
116
135
|
);
|
|
117
136
|
});
|
|
118
137
|
|
|
119
138
|
console.log("");
|
|
120
139
|
console.log(
|
|
121
|
-
"%c✓ Each candidate highlighted with a colored dashed outline",
|
|
140
|
+
"%c✓ Each candidate in the DOM highlighted with a colored dashed outline",
|
|
122
141
|
"color: #22c55e;"
|
|
123
142
|
);
|
|
124
143
|
console.groupEnd();
|
|
@@ -126,28 +145,30 @@
|
|
|
126
145
|
|
|
127
146
|
const observer = new PerformanceObserver((list) => {
|
|
128
147
|
const activationStart = getActivationStart();
|
|
129
|
-
const seen = new Set(candidates.map((c) => c.
|
|
148
|
+
const seen = new Set(candidates.map((c) => candidateKey(c.entry)));
|
|
130
149
|
|
|
131
150
|
for (const entry of list.getEntries()) {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
151
|
+
const key = candidateKey(entry);
|
|
152
|
+
if (seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
134
154
|
|
|
155
|
+
const { element } = entry;
|
|
135
156
|
const { color, name } = PALETTE[candidates.length % PALETTE.length];
|
|
136
157
|
|
|
137
|
-
element
|
|
138
|
-
|
|
158
|
+
if (element) {
|
|
159
|
+
element.style.outline = `3px dashed ${color}`;
|
|
160
|
+
element.style.outlineOffset = "2px";
|
|
161
|
+
}
|
|
139
162
|
|
|
140
163
|
candidates.push({
|
|
141
164
|
index: candidates.length + 1,
|
|
142
165
|
element,
|
|
143
|
-
selector: uniqueSelector(element),
|
|
144
|
-
color,
|
|
166
|
+
selector: element ? uniqueSelector(element) : REMOVED,
|
|
167
|
+
color: element ? color : "#9CA3AF",
|
|
145
168
|
name,
|
|
146
169
|
time: Math.max(0, entry.startTime - activationStart),
|
|
147
170
|
entry,
|
|
148
171
|
});
|
|
149
|
-
|
|
150
|
-
seen.add(element);
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
logTrail();
|
|
@@ -171,37 +192,28 @@
|
|
|
171
192
|
return { script: "LCP-Trail", status: "error", error: "No LCP entries yet" };
|
|
172
193
|
}
|
|
173
194
|
const trailActivationStart = getActivationStart();
|
|
174
|
-
const
|
|
175
|
-
const syncCandidates = [];
|
|
195
|
+
const byKey = new Map();
|
|
176
196
|
for (const entry of trailEntries) {
|
|
197
|
+
const key = candidateKey(entry);
|
|
198
|
+
if (byKey.has(key)) continue;
|
|
177
199
|
const el = entry.element;
|
|
178
|
-
if (!el || seenEls.has(el)) continue;
|
|
179
|
-
seenEls.add(el);
|
|
180
|
-
const selector = uniqueSelector(el);
|
|
181
|
-
const time = Math.round(Math.max(0, entry.startTime - trailActivationStart));
|
|
182
200
|
const { type, url } = getElementInfo(el, entry);
|
|
183
|
-
|
|
184
|
-
index:
|
|
185
|
-
selector,
|
|
186
|
-
|
|
201
|
+
byKey.set(key, {
|
|
202
|
+
index: byKey.size + 1,
|
|
203
|
+
selector: el ? uniqueSelector(el) : null,
|
|
204
|
+
...(el ? {} : { removed: true }),
|
|
205
|
+
...(!el && entry.id ? { id: entry.id } : {}),
|
|
206
|
+
time: Math.round(Math.max(0, entry.startTime - trailActivationStart)),
|
|
187
207
|
elementType: type,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const u = new URL(url);
|
|
192
|
-
return u.hostname !== location.hostname
|
|
193
|
-
? `${u.hostname}/…/${u.pathname.split("/").pop()?.split("?")[0]}`
|
|
194
|
-
: u.pathname.split("/").pop()?.split("?")[0] || url;
|
|
195
|
-
} catch { return url; }
|
|
196
|
-
})(),
|
|
197
|
-
} : {}),
|
|
208
|
+
sizePixels: entry.size,
|
|
209
|
+
...(url ? { url: shortUrl(url) } : {}),
|
|
198
210
|
});
|
|
199
211
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const lastCandidate =
|
|
204
|
-
const trailValue =
|
|
212
|
+
const syncCandidates = [...byKey.values()];
|
|
213
|
+
// The LCP is the last entry, whether or not its element is still in the DOM
|
|
214
|
+
const lastEntry = trailEntries.at(-1);
|
|
215
|
+
const lastCandidate = byKey.get(candidateKey(lastEntry));
|
|
216
|
+
const trailValue = Math.round(Math.max(0, lastEntry.startTime - trailActivationStart));
|
|
205
217
|
const trailRating = valueToRating(trailValue);
|
|
206
218
|
return {
|
|
207
219
|
script: "LCP-Trail",
|
|
@@ -92,6 +92,9 @@
|
|
|
92
92
|
const tracker = createInteractionTracker();
|
|
93
93
|
const observer = new PerformanceObserver((list) => tracker.add(list.getEntries()));
|
|
94
94
|
observer.observe({ type: "event", durationThreshold: 0, buffered: true });
|
|
95
|
+
// The browser gives no `event` entries under 16 ms, whatever the threshold, and an interaction that
|
|
96
|
+
// paints nothing can take less. `first-input` has no threshold, so the first one is still read.
|
|
97
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
95
98
|
|
|
96
99
|
// Interactions grouped by event type: "click", "keydown", "pointerdown", etc.
|
|
97
100
|
const groupByEventType = () => {
|
|
@@ -173,6 +173,10 @@
|
|
|
173
173
|
buffered: true,
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
// The browser gives no `event` entries under 16 ms, whatever the threshold, and an interaction that
|
|
177
|
+
// paints nothing can take less. `first-input` has no threshold, so the first one is still read.
|
|
178
|
+
observer.observe({ type: "first-input", buffered: true });
|
|
179
|
+
|
|
176
180
|
// Summary function
|
|
177
181
|
window.getInteractionSummary = () => {
|
|
178
182
|
const allInteractions = tracker.list().map((i) => ({
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -64,7 +64,7 @@ Every script must return an object matching this shape:
|
|
|
64
64
|
|
|
65
65
|
// Tracking scripts (status: "tracking")
|
|
66
66
|
message?: string; // Human-readable status message
|
|
67
|
-
getDataFn?: string; // window function to call for data: evaluate_script(
|
|
67
|
+
getDataFn?: string; // window function to call for data: evaluate_script(`() => ${getDataFn}()`). May be a dotted path such as "loafHelpers.getData"
|
|
68
68
|
|
|
69
69
|
// Error info (status: "error" or "unsupported")
|
|
70
70
|
error?: string;
|
|
@@ -169,7 +169,7 @@ return {
|
|
|
169
169
|
```
|
|
170
170
|
1. evaluate_script(INP.js) → { status: "tracking", getDataFn: "getINP" }
|
|
171
171
|
2. (user interacts with the page)
|
|
172
|
-
3. evaluate_script("getINP()")
|
|
172
|
+
3. evaluate_script("() => getINP()") → { script: "INP", status: "ok", value: 350, rating: "needs-improvement", ... }
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
**The window function must also return a structured object** matching the same schema, including when nothing has been recorded yet:
|
|
@@ -181,7 +181,7 @@ return {
|
|
|
181
181
|
|
|
182
182
|
### Pattern 4: Async scripts
|
|
183
183
|
|
|
184
|
-
Scripts that use `async/await` or `setTimeout`. The IIFE returns a Promise, which `evaluate_script`
|
|
184
|
+
Scripts that use `async/await` or `setTimeout`. The IIFE returns a Promise, which `evaluate_script` awaits with `format: "script"` (Chrome DevTools `awaitPromise`).
|
|
185
185
|
|
|
186
186
|
Keep the existing `async () => {}` wrapper. Add a `return` statement with structured data at the end. The agent receives the resolved value.
|
|
187
187
|
|
|
@@ -217,6 +217,8 @@ A script wrapped in `void (async () => {...})()` discards its return value. Neve
|
|
|
217
217
|
|
|
218
218
|
`details.element` is a CSS selector that matches only the LCP element, so `document.querySelector(details.element)` returns it: the id when it is unique, otherwise a path of tag, classes and `:nth-of-type()` from the nearest ancestor with a unique id, such as `section.intro:nth-of-type(2) > p.copy:nth-of-type(2)`. LCP-Subparts (`details.element`) and LCP-Trail (`finalElement` and each `selector`) build it the same way.
|
|
219
219
|
|
|
220
|
+
In LCP-Trail, a candidate whose element has left the DOM has `selector: null` and `removed: true`, with its `time`, `sizePixels`, `url` for an image and `id` when the element had one, and `finalElement` is `null` when the LCP element itself was removed. `value` is the time of the last entry either way.
|
|
221
|
+
|
|
220
222
|
#### CLS
|
|
221
223
|
|
|
222
224
|
Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
|
|
@@ -269,6 +271,24 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
|
|
|
269
271
|
|
|
270
272
|
If no interactions yet, `getINP()` returns `status: "tracking"` with a `message` and `getDataFn: "getINP"`: interact with the page and call it again.
|
|
271
273
|
|
|
274
|
+
An interaction that paints nothing has no frame to be measured to, so the browser measures it to the end of its handlers, often 0 ms, and gives no `event` entry under 16 ms at any `durationThreshold`. The first interaction of a page always has a `first-input` entry, which is how web-vitals reads it too, so `getINP()` returns it as `status: "ok"` with its value, and an `info` issue when the worst value is under 16 ms:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"script": "INP",
|
|
279
|
+
"status": "ok",
|
|
280
|
+
"metric": "INP",
|
|
281
|
+
"value": 0,
|
|
282
|
+
"unit": "ms",
|
|
283
|
+
"rating": "good",
|
|
284
|
+
"thresholds": { "good": 200, "needsImprovement": 500 },
|
|
285
|
+
"details": { "totalInteractions": 1, "worstEvent": "pointerdown → h1", "phases": { "inputDelay": 0, "processingTime": 0, "presentationDelay": 0 } },
|
|
286
|
+
"issues": [{ "code": "no-paint-measured", "severity": "info", "message": "Worst interaction: 0 ms, under the 16 ms of a frame, so the page most likely painted nothing after it. If it should respond, check that the click or key press reaches an element that does." }]
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Later interactions that short have no entry of any kind: they only count in `performance.interactionCount`, and INP does not depend on them. `tracking` after a click means that the document recorded no interaction at all, as with a click inside an iframe, which records its own; the CLI and the MCP server replace the `message` to say that a click or key press was sent.
|
|
291
|
+
|
|
272
292
|
The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
|
|
273
293
|
|
|
274
294
|
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "tracking"`, a `message`, `count: 0` and no items.
|
|
@@ -853,6 +873,8 @@ With no usage the script returns `count: 0`, no items and an `info` issue.
|
|
|
853
873
|
|
|
854
874
|
Tracking scripts return `status: "tracking"` first. The function named by `getDataFn` returns the full result. Items are capped at 50 (20 for frames) and sorted by relevance; `count` holds the total.
|
|
855
875
|
|
|
876
|
+
Interactions and Input-Latency-Breakdown read `first-input` as INP does, so an interaction that paints nothing counts when it is the first one, and the later ones under 16 ms have no entry.
|
|
877
|
+
|
|
856
878
|
#### Interactions (tracking)
|
|
857
879
|
|
|
858
880
|
```json
|
|
@@ -1202,6 +1224,12 @@ Every issue has a `code` that says what it is, so a comparison pairs the issues
|
|
|
1202
1224
|
|
|
1203
1225
|
An issue about one thing among several, one issue per thing, also names it in `target`: the URL of a resource, a CSS selector, or a name such as an event type or a CSS property. Two issues with the same code and different targets are two issues.
|
|
1204
1226
|
|
|
1227
|
+
### INP
|
|
1228
|
+
|
|
1229
|
+
| Code | Severity | When |
|
|
1230
|
+
|------|----------|------|
|
|
1231
|
+
| `no-paint-measured` | info | The worst interaction took under 16 ms, too short for a frame, so the page most likely painted nothing after it |
|
|
1232
|
+
|
|
1205
1233
|
### LCP-Image-Entropy
|
|
1206
1234
|
|
|
1207
1235
|
| Code | Severity | When |
|
|
@@ -1622,7 +1650,7 @@ Each issue names its video in `target`, except `no-videos`.
|
|
|
1622
1650
|
// Read the return value: the skill scripts have no console output
|
|
1623
1651
|
result = evaluate_script(scriptCode)
|
|
1624
1652
|
if result.status == "ok" → use result.value, result.rating, result.details, result.items
|
|
1625
|
-
if result.status == "tracking" → call evaluate_script(
|
|
1653
|
+
if result.status == "tracking" → call evaluate_script(`() => ${result.getDataFn}()`) after user interaction
|
|
1626
1654
|
if result.status == "error" → check result.error, the browser may not have loaded the page yet
|
|
1627
1655
|
if result.status == "unsupported" → browser does not support the required API (check: Chrome 107+?)
|
|
1628
1656
|
```
|
|
@@ -1637,7 +1665,7 @@ result = evaluate_script(INP_js)
|
|
|
1637
1665
|
// 2. Wait for/trigger user interactions
|
|
1638
1666
|
|
|
1639
1667
|
// 3. Collect data
|
|
1640
|
-
data = evaluate_script("getINP()")
|
|
1668
|
+
data = evaluate_script("() => getINP()")
|
|
1641
1669
|
// data = { status: "ok", value: 350, rating: "needs-improvement", ... }
|
|
1642
1670
|
```
|
|
1643
1671
|
|
package/src/baseline.js
CHANGED
|
@@ -23,7 +23,8 @@ export const DEFAULT_THRESHOLDS = {
|
|
|
23
23
|
// The baseline in a file, as { kind: "page" | "matrix", file, data }. Throws with what to do when
|
|
24
24
|
// the file is not an output of the CLI.
|
|
25
25
|
export function readBaseline(text, file) {
|
|
26
|
-
|
|
26
|
+
// Never this path as the place to save it: the file is something else, and an agent may follow the message
|
|
27
|
+
const howTo = "give a file saved with webperf-snippets <url> --json";
|
|
27
28
|
let data;
|
|
28
29
|
try {
|
|
29
30
|
data = JSON.parse(text);
|
|
@@ -49,18 +50,18 @@ export function loadBaseline(path) {
|
|
|
49
50
|
// (absolute), CLS=0.1 (CLS has only an absolute part, with no unit)
|
|
50
51
|
export function parseThreshold(raw) {
|
|
51
52
|
const match = /^([A-Za-z]+)=(\d+(?:\.\d+)?)(%|ms)?$/.exec(raw.trim());
|
|
52
|
-
if (!match) throw new Error(`Invalid
|
|
53
|
+
if (!match) throw new Error(`Invalid threshold "${raw}": give METRIC=VALUE with its unit, such as LCP=15%, LCP=200ms or CLS=0.1`);
|
|
53
54
|
const [, name, number, unit] = match;
|
|
54
55
|
const metric = name.toUpperCase();
|
|
55
|
-
if (!METRICS.includes(metric)) throw new Error(`Unknown metric "${name}" in
|
|
56
|
+
if (!METRICS.includes(metric)) throw new Error(`Unknown metric "${name}" in threshold "${raw}". Choose from: ${METRICS.join(", ")}`);
|
|
56
57
|
const value = Number(number);
|
|
57
58
|
if (metric === "CLS") {
|
|
58
|
-
if (unit) throw new Error(`Invalid
|
|
59
|
+
if (unit) throw new Error(`Invalid threshold "${raw}": CLS is a score, with no unit and no relative part, such as CLS=0.1`);
|
|
59
60
|
return { metric, absolute: value };
|
|
60
61
|
}
|
|
61
62
|
if (unit === "%") return { metric, relative: value / 100 };
|
|
62
63
|
if (unit === "ms") return { metric, absolute: value };
|
|
63
|
-
throw new Error(`Invalid
|
|
64
|
+
throw new Error(`Invalid threshold "${raw}": give the unit, such as ${metric}=15% or ${metric}=200ms`);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// The default thresholds with the parts that --threshold changes
|
|
@@ -153,8 +154,17 @@ function targetOf(target, pageUrl) {
|
|
|
153
154
|
// (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…"
|
|
154
155
|
const byMessage = (error) => `${error.id}\u0000${error.message.replace(/\d+(?:[.,]\d+)*/g, "#")}`;
|
|
155
156
|
|
|
157
|
+
// One snippet measured on both sides is the same result, whatever name it was asked by: the CLI names
|
|
158
|
+
// the result of --snippet as it was typed (an alias, its name or Category/Name), and the MCP server by
|
|
159
|
+
// its path. Its conditions name it by its path on both.
|
|
160
|
+
function baseResultsOf(base, current) {
|
|
161
|
+
const path = base.conditions?.snippet;
|
|
162
|
+
if (!path || path !== current.conditions?.snippet || current.results?.length !== 1) return base.results ?? [];
|
|
163
|
+
return base.results.map((r) => ({ ...r, id: current.results[0].id }));
|
|
164
|
+
}
|
|
165
|
+
|
|
156
166
|
function compareErrors(base, current) {
|
|
157
|
-
const baseErrors = blockingErrors(base
|
|
167
|
+
const baseErrors = blockingErrors(baseResultsOf(base, current));
|
|
158
168
|
const currentErrors = blockingErrors(current.results ?? []);
|
|
159
169
|
// The snippets whose errors on one side all lack a code pair by message on both
|
|
160
170
|
const uncoded = (errors) => {
|
|
@@ -194,8 +204,32 @@ function machineWarning(base, current) {
|
|
|
194
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`;
|
|
195
205
|
}
|
|
196
206
|
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
const valueCount = (payload, metric) => measured(payload, metric).values?.length ?? 1;
|
|
208
|
+
|
|
209
|
+
// Why five runs can leave four values, when a run failed; a run that missed a metric alone does not count
|
|
210
|
+
function afterFailedRuns(payload) {
|
|
211
|
+
const failed = payload.runs?.failed ?? 0;
|
|
212
|
+
return failed ? ` after ${failed} failed run${failed === 1 ? "" : "s"}` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The metrics compared on both sides that have no statistics, one line for each set of numbers. A
|
|
216
|
+
// metric that one side lacks has no comparison to back, and its verdict already says "not measured".
|
|
217
|
+
function statisticsWarnings(base, current, metrics) {
|
|
218
|
+
const metricsByNumbers = new Map();
|
|
219
|
+
for (const [metric, { stats, verdict }] of Object.entries(metrics)) {
|
|
220
|
+
if (stats || verdict === "not measured") continue;
|
|
221
|
+
const inBase = valueCount(base, metric);
|
|
222
|
+
const numbers = `the baseline has ${inBase} value${inBase === 1 ? "" : "s"}${afterFailedRuns(base)}, and this run ${valueCount(current, metric)}${afterFailedRuns(current)}`;
|
|
223
|
+
metricsByNumbers.set(numbers, [...(metricsByNumbers.get(numbers) ?? []), metric]);
|
|
224
|
+
}
|
|
225
|
+
return [...metricsByNumbers].map(
|
|
226
|
+
([numbers, names]) => `no statistics for ${names.join(", ")}: ${numbers}; they need ${RUNS_FOR_STATISTICS} a side, so measure both with ${RUNS_FOR_STATISTICS} runs or more`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// What differs in how both were measured, and what it cost. The comparison goes on: the warnings say
|
|
231
|
+
// how far to trust it.
|
|
232
|
+
function warningsOf(base, current, metrics) {
|
|
199
233
|
const warnings = [];
|
|
200
234
|
const b = base.conditions;
|
|
201
235
|
const c = current.conditions;
|
|
@@ -209,8 +243,12 @@ function warningsOf(base, current) {
|
|
|
209
243
|
if (describeLoad(base.throttling) !== describeLoad(current.throttling)) {
|
|
210
244
|
warnings.push(`the baseline was measured ${describeLoad(base.throttling)}, and this run ${describeLoad(current.throttling)}`);
|
|
211
245
|
}
|
|
212
|
-
|
|
213
|
-
|
|
246
|
+
// In words for both: the CLI asks for runs with --runs, and the tools of the MCP server with runs
|
|
247
|
+
if (runCount(base) < 2) warnings.push(`the baseline has 1 run, so only the thresholds apply; measure both with ${RUNS_FOR_STATISTICS} runs or more`);
|
|
248
|
+
else {
|
|
249
|
+
if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
|
|
250
|
+
warnings.push(...statisticsWarnings(base, current, metrics));
|
|
251
|
+
}
|
|
214
252
|
const machine = machineWarning(b?.machine, c?.machine);
|
|
215
253
|
if (machine) warnings.push(machine);
|
|
216
254
|
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
@@ -224,13 +262,14 @@ function warningsOf(base, current) {
|
|
|
224
262
|
// A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
|
|
225
263
|
// much to trust its values, in the header of the reports.
|
|
226
264
|
export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
265
|
+
const metrics = compareMetrics(base, current, thresholds);
|
|
227
266
|
return {
|
|
228
267
|
file,
|
|
229
268
|
url: base.url,
|
|
230
269
|
runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
|
|
231
|
-
metrics
|
|
270
|
+
metrics,
|
|
232
271
|
errors: compareErrors(base, current),
|
|
233
|
-
warnings: warningsOf(base, current),
|
|
272
|
+
warnings: warningsOf(base, current, metrics),
|
|
234
273
|
};
|
|
235
274
|
}
|
|
236
275
|
|