webperf-snippets 1.11.0 → 1.13.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.
@@ -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 turn that result into `status: "error"`, with an `error` that says a click or key press was sent, so the CLI exits with code `1`.
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.
@@ -806,6 +826,49 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
806
826
  }
807
827
  ```
808
828
 
829
+ #### CSS-Complexity
830
+
831
+ Reads the CSSOM of every stylesheet of the page: of the document, adopted, in open shadow roots and imported with `@import`. One item per stylesheet, the most embedded bytes first, with `read: "cssom"`; `read: "fetched"` for a stylesheet of another origin whose rules the page cannot read and `fetch` can, as with `Access-Control-Allow-Origin` and a `<link>` without `crossorigin`, parsed with the same CSSOM, its `@import` rules included; or `read: "unreadable"` and only its `source` when neither works, which sets `corsLimitedAnalysis: true`. `source` is the URL of the sheet, or a label such as `<style> #1` or `adopted stylesheet #1`. A stylesheet a browser extension links to the page (`chrome-extension:`, `moz-extension:`, `safari-web-extension:`) is left out and counted in `details.extensionCount`; the `<style>` elements and shadow roots an extension injects cannot be told apart from those of the page, so in a browser with extensions, as with Chrome DevTools MCP connected to the user's Chrome, the counts can include them.
832
+
833
+ `details` counts what the CSSOM keeps of the CSS as written, as [css-analyzer](https://github.com/projectwallace/css-analyzer) counts its text: rules (style rules, nested and inside at-rules), selectors (each one of a list), declarations (also those of `@font-face` and `@keyframes`), embedded content (`data:` URLs, in characters, and by MIME type), `@import` rules, empty rules, `!important` declarations, `@font-face` and `@keyframes` rules. `selectorUniqueness` is unique selectors over all selectors, without their context, so a selector repeated in two `@media` counts as repeated (1 without selectors). `unusedKeyframes` lists the `@keyframes` that no `animation` or `animation-name` of the stylesheets or of a `style` attribute names. `maxSelectorComplexity` and `commonSelectorComplexity` (the mode, or the mean of the tied values) give the complexity of the selectors as css-analyzer computes it, and `complexSelectors` the 5 most complex, as `{ selectorText, complexity }`: `selectorText` is the selector of a rule, not of an element of the page. The CSSOM drops comments, hacks, invalid declarations and rules with unsupported selectors, and writes a few shorthands as longhands, so these counts can differ slightly from those of the text.
834
+
835
+ ```json
836
+ {
837
+ "script": "CSS-Complexity",
838
+ "status": "ok",
839
+ "count": 2,
840
+ "corsLimitedAnalysis": false,
841
+ "details": {
842
+ "stylesheetCount": 2,
843
+ "extensionCount": 0,
844
+ "unreadableCount": 0,
845
+ "fetchedCount": 1,
846
+ "ruleCount": 812,
847
+ "selectorCount": 1104,
848
+ "declarationCount": 2210,
849
+ "embeddedBytes": 41210,
850
+ "embeddedCount": 12,
851
+ "embeddedTypes": [{ "type": "font/woff2", "count": 2, "bytes": 38090 }, { "type": "image/svg+xml", "count": 10, "bytes": 3120 }],
852
+ "importCount": 1,
853
+ "emptyRuleCount": 14,
854
+ "selectorUniqueness": 0.81,
855
+ "importantCount": 51,
856
+ "importantPercent": 2.3,
857
+ "fontFaceCount": 8,
858
+ "keyframesCount": 9,
859
+ "unusedKeyframes": ["spin-old"],
860
+ "maxSelectorComplexity": 18,
861
+ "commonSelectorComplexity": 1,
862
+ "complexSelectors": [{ "selectorText": ".nav > ul li:not(.active) a[href^=\"http\"]", "complexity": 11 }]
863
+ },
864
+ "items": [
865
+ { "source": "https://example.com/app.css", "read": "cssom", "ruleCount": 790, "embeddedBytes": 41210, "importCount": 1, "importantCount": 48 },
866
+ { "source": "https://cdn.example.com/vendor.css", "read": "fetched", "ruleCount": 22, "embeddedBytes": 0, "importCount": 0, "importantCount": 3 }
867
+ ],
868
+ "issues": [{ "code": "import", "severity": "warning", "message": "https://example.com/app.css imports https://cdn.example.com/vendor.css with @import: ...", "target": "https://cdn.example.com/vendor.css" }]
869
+ }
870
+ ```
871
+
809
872
  #### CSS-Media-Queries-Analysis
810
873
 
811
874
  One item per distinct media query that targets viewports wider than `details.minWidth`, largest first. `details` gives the totals, `totalUnique` and `uniquenessRatio`, and `bySource` breaks the rules down by stylesheet.
@@ -853,6 +916,8 @@ With no usage the script returns `count: 0`, no items and an `info` issue.
853
916
 
854
917
  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
918
 
919
+ 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.
920
+
856
921
  #### Interactions (tracking)
857
922
 
858
923
  ```json
@@ -1202,6 +1267,12 @@ Every issue has a `code` that says what it is, so a comparison pairs the issues
1202
1267
 
1203
1268
  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
1269
 
1270
+ ### INP
1271
+
1272
+ | Code | Severity | When |
1273
+ |------|----------|------|
1274
+ | `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 |
1275
+
1205
1276
  ### LCP-Image-Entropy
1206
1277
 
1207
1278
  | Code | Severity | When |
@@ -1237,6 +1308,21 @@ An issue about one thing among several, one issue per thing, also names it in `t
1237
1308
  | `could-defer` | info | Blocking scripts other than the last three of `<body>`, which could try `defer` |
1238
1309
  | `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the total is a lower bound |
1239
1310
 
1311
+ ### CSS-Complexity
1312
+
1313
+ | Code | Severity | When |
1314
+ |------|----------|------|
1315
+ | `embedded-font` | warning | A stylesheet embeds a font as a `data:` URL; `target` is the stylesheet |
1316
+ | `embedded-content` | warning | The `data:` URLs of all the CSS add up to more than 36.6 kB, the 90th percentile of homepages in The CSS Selection 2026 |
1317
+ | `import` | warning | An `@import` rule, one issue per rule; `target` is the URL it imports |
1318
+ | `empty-rules` | info | One or more style rules without declarations |
1319
+ | `duplicate-selectors` | info | Fewer than 66% of the selectors are unique, the threshold of css-code-quality |
1320
+ | `important-ratio` | info | More than 13% of the declarations use `!important`, the 90th percentile of homepages |
1321
+ | `font-face-count` | info | More than 48 `@font-face` rules, the 90th percentile of homepages |
1322
+ | `unused-keyframes` | info | A `@keyframes` that no animation of the stylesheets or of a `style` attribute uses, only when every stylesheet could be read; `target` is its name |
1323
+ | `selector-complexity` | info | A selector among the 5 most complex with a complexity above 29, the 90th percentile of the most complex selector of homepages; `target` is the selector of the rule |
1324
+ | `unreadable-stylesheets` | info | Cross-origin stylesheets whose rules the page cannot read and `fetch` cannot either |
1325
+
1240
1326
  ### CSS-Media-Queries-Analysis
1241
1327
 
1242
1328
  | Code | Severity | When |
package/src/baseline.js CHANGED
@@ -204,8 +204,32 @@ 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
- // What differs in how both were measured. The comparison goes on: the warnings say how far to trust it.
208
- function warningsOf(base, current) {
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) {
209
233
  const warnings = [];
210
234
  const b = base.conditions;
211
235
  const c = current.conditions;
@@ -221,7 +245,10 @@ function warningsOf(base, current) {
221
245
  }
222
246
  // In words for both: the CLI asks for runs with --runs, and the tools of the MCP server with runs
223
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`);
224
- else if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
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
+ }
225
252
  const machine = machineWarning(b?.machine, c?.machine);
226
253
  if (machine) warnings.push(machine);
227
254
  if (b && c && b.cliVersion !== c.cliVersion) {
@@ -235,13 +262,14 @@ function warningsOf(base, current) {
235
262
  // A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
236
263
  // much to trust its values, in the header of the reports.
237
264
  export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
265
+ const metrics = compareMetrics(base, current, thresholds);
238
266
  return {
239
267
  file,
240
268
  url: base.url,
241
269
  runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
242
- metrics: compareMetrics(base, current, thresholds),
270
+ metrics,
243
271
  errors: compareErrors(base, current),
244
- warnings: warningsOf(base, current),
272
+ warnings: warningsOf(base, current, metrics),
245
273
  };
246
274
  }
247
275
 
package/src/bin.js CHANGED
@@ -40,7 +40,7 @@ Options:
40
40
  --workflow <name> Workflow to run (default: core-web-vitals)
41
41
  Workflows: core-web-vitals, audit, loading, interaction
42
42
  --snippet <name> Run a single snippet by name, alias or Category/Name path
43
- Any of the 56 snippets, such as Compression-Audit
43
+ Any of the 57 snippets, such as Compression-Audit
44
44
  Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
45
45
  render-blocking, resource-hints, preload-scripts,
46
46
  priority-hints, critical-css, ttfb,
@@ -89,6 +89,7 @@ Options:
89
89
  --verbose Show all items, even for passing checks
90
90
  --headed Show the browser window (debug)
91
91
  -h, --help Show this help
92
+ -v, --version Show the version
92
93
 
93
94
  Examples:
94
95
  npx webperf-snippets https://example.com
@@ -182,6 +183,7 @@ async function main() {
182
183
  verbose: { type: "boolean" },
183
184
  headed: { type: "boolean" },
184
185
  help: { type: "boolean", short: "h" },
186
+ version: { type: "boolean", short: "v" },
185
187
  },
186
188
  allowPositionals: true,
187
189
  });
@@ -196,6 +198,11 @@ async function main() {
196
198
  return;
197
199
  }
198
200
 
201
+ if (values.version) {
202
+ process.stdout.write(`${cliVersion}\n`);
203
+ return;
204
+ }
205
+
199
206
  const url = positionals[0];
200
207
  const matrixPath = values.matrix;
201
208
  if (url && matrixPath) fail("Use a URL or --matrix, not both");
@@ -0,0 +1,72 @@
1
+ // The reason of an abort is whatever the client sent, often a string, or nothing: a caller gets an error
2
+ const gaveUp = () => new Error("The caller gave up while it waited for its turn");
3
+
4
+ // One task at a time, in the order they arrive. A caller that gives up while it waits leaves the line
5
+ // at once, so it never runs and holds up no one. A task that fails passes the turn like any other, and
6
+ // its failure stays with its caller.
7
+ //
8
+ // A waiting caller hears how many tasks are ahead when it starts to wait, whenever the line moves, and
9
+ // every `reportEveryMs` while it does not: a client can restart its timeout on each report, and a task
10
+ // ahead can take minutes.
11
+ export function createQueue({ reportEveryMs = 5000 } = {}) {
12
+ const waiting = [];
13
+ let taken = false;
14
+
15
+ // A report is a courtesy: one that fails, as one to a client that left, changes nothing
16
+ const report = async ({ onWait }, ahead) => {
17
+ try {
18
+ await onWait?.(ahead);
19
+ } catch {
20
+ // nothing to tell, and no one to tell it to
21
+ }
22
+ };
23
+ const reportAll = () => waiting.forEach((waiter, i) => report(waiter, i + 1));
24
+
25
+ function take({ signal, onWait }) {
26
+ if (signal?.aborted) return Promise.reject(gaveUp());
27
+ if (!taken) {
28
+ taken = true;
29
+ return Promise.resolve();
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ const waiter = { onWait };
33
+ const leave = () => {
34
+ waiting.splice(waiting.indexOf(waiter), 1);
35
+ clearInterval(timer);
36
+ signal?.removeEventListener("abort", cancel);
37
+ };
38
+ const cancel = () => {
39
+ leave();
40
+ reject(gaveUp());
41
+ reportAll();
42
+ };
43
+ waiter.enter = () => {
44
+ leave();
45
+ resolve();
46
+ };
47
+ const timer = setInterval(() => report(waiter, waiting.indexOf(waiter) + 1), reportEveryMs);
48
+ // The timer alone must not keep the process alive
49
+ timer.unref?.();
50
+ signal?.addEventListener("abort", cancel, { once: true });
51
+ waiting.push(waiter);
52
+ report(waiter, waiting.length);
53
+ });
54
+ }
55
+
56
+ function pass() {
57
+ taken = waiting.length > 0;
58
+ waiting[0]?.enter();
59
+ reportAll();
60
+ }
61
+
62
+ async function run(task, options = {}) {
63
+ await take(options);
64
+ try {
65
+ return await task();
66
+ } finally {
67
+ pass();
68
+ }
69
+ }
70
+
71
+ return { run };
72
+ }
package/src/mcp/server.js CHANGED
@@ -10,6 +10,7 @@ import { RULES } from "../decision-tree.js";
10
10
  import { fetchCrux, fetchCruxHistory } from "../crux.js";
11
11
  import { runInteractions, isInputStep, settle } from "../interactions.js";
12
12
  import { createSessions, SessionError } from "./sessions.js";
13
+ import { createQueue } from "./queue.js";
13
14
  import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
14
15
  import { registerResources } from "./documents.js";
15
16
  import { registerPrompts } from "./prompts.js";
@@ -24,6 +25,10 @@ import { MAX_RUNS, measureRuns } from "../runs.js";
24
25
  const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
25
26
  const STAGES = 3;
26
27
  const NAV_TIMEOUT_MS = 30000;
28
+ // How long a page load can keep its turn: far beyond the load and the wait, which have their own limits
29
+ // (30 s and 10 s), because a run that reaches this is not slow. A page whose main thread never yields
30
+ // answers no evaluation, and Playwright has no timeout for one, so it would hold up the queue for ever.
31
+ const CALL_LIMIT_MS = 120000;
27
32
 
28
33
  const HttpUrl = z
29
34
  .string()
@@ -111,6 +116,7 @@ const NextSteps = z
111
116
  const DETAIL_NOTE =
112
117
  "Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
113
118
  const BASELINE_NOTE = "With baseline, it also compares the run with an earlier one, as --baseline of the CLI, and says what regressed.";
119
+ const QUEUE_NOTE = "Measurements run one at a time, so several in one turn save no time.";
114
120
 
115
121
  // A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
116
122
  // selector, the URL of a script. The tools that return what a page holds say how to read it, and so
@@ -162,12 +168,44 @@ function progressFor(ctx, runs) {
162
168
  });
163
169
  }
164
170
 
171
+ // The report of a call that waits for its turn, for a client that wants progress: how many calls are
172
+ // ahead. Progress has to grow with each report, and the stages of a run start at 1, so it stays below 1.
173
+ function waitProgressFor(ctx, total) {
174
+ const progressToken = ctx.mcpReq._meta?.progressToken;
175
+ if (progressToken === undefined) return undefined;
176
+ let reports = 0;
177
+ return (ahead) => {
178
+ reports++;
179
+ const message = `Waiting for its turn: ${ahead} ${ahead === 1 ? "call" : "calls"} ahead`;
180
+ return ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress: reports / (reports + 1), ...(total ? { total } : {}), message } });
181
+ };
182
+ }
183
+
184
+ const stoppedAnswering = (limitMs) => `The page did not answer within ${limitMs / 1000} seconds`;
185
+
186
+ // A page that stopped answering is closed, and the runs left of the call would only hold up the queue
187
+ class PageStoppedError extends Error {}
188
+ const endsTheCall = (err) => isMissingBrowser(err) || err instanceof PageStoppedError;
189
+
165
190
  // A page load measured once, or `count` times with the median run as the answer, as --runs of the CLI.
166
- // When the client gives up (a cancel, its timeout, or leaving), the runs left never start.
167
- async function measurePage(measure, count, ctx) {
191
+ // `measure` gets the signal of its run, which closes its Chromium. When the client gives up (a cancel,
192
+ // its timeout, or leaving), the run in progress ends and the runs left never start.
193
+ async function measurePage(measure, count, ctx, limitMs) {
168
194
  const progress = progressFor(ctx, count);
169
- if (count === 1) return measure(progress(1));
170
- const { payload, runs, errors } = await measureRuns((i) => measure(progress(i)), count, { isFatal: isMissingBrowser, signal: ctx.mcpReq.signal });
195
+ const measureRun = async (run) => {
196
+ const limit = AbortSignal.timeout(limitMs);
197
+ let measured;
198
+ try {
199
+ measured = await measure(progress(run), AbortSignal.any([ctx.mcpReq.signal, limit]));
200
+ } catch (err) {
201
+ if (!limit.aborted) throw err;
202
+ }
203
+ // The runner keeps a failed evaluation as a result, and closing Chromium fails every one
204
+ if (limit.aborted) throw new PageStoppedError(stoppedAnswering(limitMs));
205
+ return measured;
206
+ };
207
+ if (count === 1) return measureRun(1);
208
+ const { payload, runs, errors } = await measureRuns(measureRun, count, { isFatal: endsTheCall, signal: ctx.mcpReq.signal });
171
209
  if (!payload) throw new Error(`Every run failed: ${[...new Set(errors.map(firstLine))].join("; ")}`);
172
210
  return { ...payload, runs };
173
211
  }
@@ -224,10 +262,18 @@ function registerRuns(server, runs) {
224
262
 
225
263
  // The MCP server of the CLI: the same snippets and runner, offered as tools an agent calls by name.
226
264
  // serveStdio creates one instance per connection, so the pages it keeps open belong to that client.
227
- export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env, machine = machineOf } = {}) {
265
+ export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env, machine = machineOf, callLimitMs = CALL_LIMIT_MS, reportEveryMs } = {}) {
228
266
  const server = new McpServer({ name: "webperf-snippets", version: cliVersion }, { instructions: INSTRUCTIONS });
229
267
  const sessions = createSessions({ launchBrowser, maxPages, idleMs });
230
268
  const runs = createRuns();
269
+ // An agent sends several calls in one turn and the client runs them at once. The calls that make a page
270
+ // work take turns (run_snippet, run_workflow, open_page, interact): a load or a click competes for the
271
+ // CPU with any other, which inflates what each page reports. A call keeps its turn only while it runs,
272
+ // so it can wait only for something that ends on its own: never for a later call of the agent, and
273
+ // never for a page left open, which keeps no turn. read_results and close_page take no turn: they ask
274
+ // little of Chromium, and they are how an agent gets out of a page.
275
+ const queue = createQueue({ reportEveryMs });
276
+ const inTurn = (ctx, total, task) => queue.run(task, { signal: ctx.mcpReq.signal, onWait: waitProgressFor(ctx, total) });
231
277
  // The client left (end of stdin, or a closed transport): close the pages and Chromium
232
278
  server.server.onclose = () => sessions.closeAll();
233
279
  registerResources(server);
@@ -235,10 +281,13 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
235
281
  registerRuns(server, runs);
236
282
 
237
283
  // The machine goes in the conditions of every run, as in the JSON of the CLI, so a baseline measured
238
- // on another one warns. It is measured once, with the first run, before its Chromium starts.
284
+ // on another one warns. It is measured once, in the turn of the first run, before its Chromium starts.
239
285
  let machineOfServer;
240
286
  const conditionsOf = (conditions) => measurementConditions({ ...conditions, machine: (machineOfServer ??= machine()) });
241
287
 
288
+ const measureInTurn = (conditions, measure, count, ctx) =>
289
+ inTurn(ctx, STAGES * count, async () => ({ conditions: conditionsOf(conditions), run: await measurePage(measure, count, ctx, callLimitMs) }));
290
+
242
291
  // A run of a page load, kept with its page in the shape of the JSON of the CLI: its runId is the
243
292
  // baseline of a later run, and the resources list it
244
293
  const keepPage = (run, results, conditions) => {
@@ -292,7 +341,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
292
341
  {
293
342
  title: "Run a snippet",
294
343
  description:
295
- `Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
344
+ `Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run on a page opened with open_page instead. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
296
345
  inputSchema: z.object({
297
346
  url: HttpUrl,
298
347
  snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
@@ -337,8 +386,12 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
337
386
  }
338
387
 
339
388
  try {
340
- const conditions = conditionsOf({ viewport, snippet: path, waitMs });
341
- const run = await measurePage((onProgress) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress }), count, ctx);
389
+ 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 }),
392
+ count,
393
+ ctx
394
+ );
342
395
  const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
343
396
  const { runId, current } = keepPage(run, run.results, conditions);
344
397
  const id = run.results[0].id;
@@ -369,7 +422,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
369
422
  {
370
423
  title: "Run a workflow",
371
424
  description:
372
- `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. ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
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}`,
373
426
  inputSchema: z.object({
374
427
  url: HttpUrl,
375
428
  workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
@@ -405,9 +458,9 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
405
458
  return toolError(err.message);
406
459
  }
407
460
  try {
408
- const conditions = conditionsOf({ viewport, workflow, waitMs });
409
- const run = await measurePage(
410
- (onProgress) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress }),
461
+ 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 }),
411
464
  count,
412
465
  ctx
413
466
  );
@@ -537,27 +590,33 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
537
590
  items.push({ id: path, path, source });
538
591
  }
539
592
 
540
- let session;
541
- try {
542
- session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: ctx.mcpReq.signal });
543
- } catch (err) {
544
- if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
545
- throw err;
546
- }
547
- try {
548
- const navStart = Date.now();
549
- await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
550
- if (waitMs > 0) await session.page.waitForTimeout(waitMs);
551
- const navMs = Date.now() - navStart;
552
- session.tracking = await evaluateItems(session.page, items);
553
- session.stopListening();
554
- const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
555
- return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
556
- } catch (err) {
557
- await sessions.close(session.id);
558
- if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
559
- return toolError(`Opening the page failed: ${firstLine(err.message)}`);
560
- }
593
+ return inTurn(ctx, undefined, async () => {
594
+ const limit = AbortSignal.timeout(callLimitMs);
595
+ let session;
596
+ try {
597
+ session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: AbortSignal.any([ctx.mcpReq.signal, limit]) });
598
+ } catch (err) {
599
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
600
+ throw err;
601
+ }
602
+ try {
603
+ const navStart = Date.now();
604
+ await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
605
+ if (waitMs > 0) await session.page.waitForTimeout(waitMs);
606
+ const navMs = Date.now() - navStart;
607
+ session.tracking = await evaluateItems(session.page, items);
608
+ // evaluateItems keeps a failed evaluation as a result, and closing the page fails every one
609
+ if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
610
+ session.stopListening();
611
+ const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
612
+ return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
613
+ } catch (err) {
614
+ await sessions.close(session.id);
615
+ if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
616
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
617
+ return toolError(`Opening the page failed: ${firstLine(err.message)}`);
618
+ }
619
+ });
561
620
  })
562
621
  );
563
622
 
@@ -575,16 +634,28 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
575
634
  // A click can submit a form or change data on the site, so this tool is not read-only
576
635
  annotations: { readOnlyHint: false, openWorldHint: true },
577
636
  },
578
- withSession(async ({ sessionId, steps }) => {
579
- const session = sessions.get(sessionId);
580
- try {
581
- await runInteractions(session.page, steps);
582
- } catch (err) {
583
- return toolError(`A step failed: ${firstLine(err.message)}`);
584
- }
585
- session.hadInput ||= steps.some(isInputStep);
586
- await settle(session.page);
587
- return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
637
+ withSession(async ({ sessionId, steps }, ctx) => {
638
+ // A page that is not open answers now, not after a turn it does not need
639
+ sessions.get(sessionId);
640
+ return inTurn(ctx, undefined, async () => {
641
+ const session = sessions.get(sessionId);
642
+ // A wait step is time the agent asked for. Closing the page fails whatever it stopped answering.
643
+ const limitMs = callLimitMs + steps.reduce((sum, step) => sum + (step.ms ?? 0), 0);
644
+ const limit = AbortSignal.timeout(limitMs);
645
+ const closePage = () => sessions.close(sessionId);
646
+ limit.addEventListener("abort", closePage, { once: true });
647
+ try {
648
+ await runInteractions(session.page, steps);
649
+ session.hadInput ||= steps.some(isInputStep);
650
+ await settle(session.page);
651
+ if (!limit.aborted) return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
652
+ } catch (err) {
653
+ if (!limit.aborted) return toolError(`A step failed: ${firstLine(err.message)}`);
654
+ } finally {
655
+ limit.removeEventListener("abort", closePage);
656
+ }
657
+ return toolError(`${stoppedAnswering(limitMs)}, and it was closed. Open it again with open_page.`);
658
+ });
588
659
  })
589
660
  );
590
661
 
@@ -593,7 +664,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
593
664
  {
594
665
  title: "Read the tracking snippets",
595
666
  description:
596
- `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
667
+ `Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. After a click or key press that the document did not record, as one inside an iframe, it is an error that says so: interact with an element of the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
597
668
  inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
598
669
  outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
599
670
  annotations: { readOnlyHint: true, openWorldHint: false },
package/src/runner.js CHANGED
@@ -21,6 +21,13 @@ const DEFAULT_NAV_TIMEOUT = 30000;
21
21
  const LATE_ENTRIES_MS = 2000;
22
22
  const POLL_MS = 100;
23
23
 
24
+ // A snippet still tracking after that wait has seen no interaction at all, and the browser agrees: a
25
+ // click that paints nothing is recorded too. The measurement failed, so the result is an error, which
26
+ // exits with 1, instead of the message of the snippet (click on the page, then call the function
27
+ // again), which would ask for what the run has just done.
28
+ const NOTHING_RECORDED =
29
+ "A click or key press was sent, and this document recorded no interaction. Check that it reaches an element of this document: an interaction inside an iframe is recorded by the iframe.";
30
+
24
31
  // Snippets are IIFEs. Playwright evaluates a string as an expression, so we
25
32
  // trim trailing semicolons to keep the IIFE call as a single expression and
26
33
  // recover its return value in Node.
@@ -83,13 +90,28 @@ export async function collectTrackingData(page, result, hasInput) {
83
90
  await page.waitForTimeout(POLL_MS);
84
91
  data = await read();
85
92
  }
86
- if (data && typeof data === "object") return { id: result.id, ...data };
93
+ if (data && typeof data === "object") {
94
+ if (hasInput && data.status === "tracking") {
95
+ return { id: result.id, script: data.script, status: "error", error: NOTHING_RECORDED };
96
+ }
97
+ return { id: result.id, ...data };
98
+ }
87
99
  return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
88
100
  } catch (err) {
89
101
  return { id: result.id, status: "error", error: `${result.getDataFn}() failed: ${err.message}` };
90
102
  }
91
103
  }
92
104
 
105
+ // An aborted signal closes Chromium, which fails whatever the run is waiting for, a page that stopped
106
+ // answering included: Playwright has no timeout for an evaluation. Returns what stops listening.
107
+ function closeOnAbort(browser, signal) {
108
+ if (!signal) return () => {};
109
+ const close = () => browser.close().catch(() => {});
110
+ if (signal.aborted) close();
111
+ signal.addEventListener("abort", close, { once: true });
112
+ return () => signal.removeEventListener("abort", close);
113
+ }
114
+
93
115
  export async function runSnippets({
94
116
  url,
95
117
  items,
@@ -106,9 +128,11 @@ export async function runSnippets({
106
128
  ownDomains,
107
129
  // Called at each of the three stages of a run, for callers that report progress (the MCP server)
108
130
  onProgress = () => {},
131
+ signal,
109
132
  }) {
110
133
  await onProgress(1, "Opening Chromium");
111
134
  const browser = await launchChromium({ headless });
135
+ const stopListening = closeOnAbort(browser, signal);
112
136
  try {
113
137
  const context = await browser.newContext({ viewport, storageState });
114
138
  const page = await context.newPage();
@@ -126,6 +150,7 @@ export async function runSnippets({
126
150
  await onProgress(3, "Snippets evaluated");
127
151
  return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
128
152
  } finally {
153
+ stopListening();
129
154
  await browser.close();
130
155
  }
131
156
  }
@@ -146,9 +171,11 @@ export async function runMeasurement({
146
171
  network,
147
172
  ownDomains,
148
173
  onProgress = () => {},
174
+ signal,
149
175
  }) {
150
176
  await onProgress(1, "Opening Chromium");
151
177
  const browser = await launchChromium({ headless });
178
+ const stopListening = closeOnAbort(browser, signal);
152
179
  try {
153
180
  const context = await browser.newContext({ viewport, storageState });
154
181
  const page = await context.newPage();
@@ -212,6 +239,7 @@ export async function runMeasurement({
212
239
  await onProgress(3, "Workflow and follow-ups evaluated");
213
240
  return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
214
241
  } finally {
242
+ stopListening();
215
243
  await browser.close();
216
244
  }
217
245
  }