webperf-snippets 1.0.0 → 1.1.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.
@@ -0,0 +1,382 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import * as z from "zod/v4";
3
+ import { cliVersion } from "../report-to.js";
4
+ import { CATEGORIES, aliasesOf, listSnippets, resolveSnippetName } from "../snippet-names.js";
5
+ import { loadSnippet } from "../load-snippet.js";
6
+ import { isTrackingSnippet } from "../tracking.js";
7
+ import { runSnippets, runMeasurement, evaluateItems, collectTrackingData, VIEWPORT_PRESETS } from "../runner.js";
8
+ import { WORKFLOWS } from "../workflows/index.js";
9
+ import { RULES } from "../decision-tree.js";
10
+ import { fetchCrux } from "../crux.js";
11
+ import { runInteractions, isInputStep, settle } from "../interactions.js";
12
+ import { createSessions, SessionError } from "./sessions.js";
13
+ import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
14
+ import { registerResources } from "./documents.js";
15
+ import { registerPrompts } from "./prompts.js";
16
+
17
+ const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
18
+ const STAGES = 3;
19
+ const NAV_TIMEOUT_MS = 30000;
20
+
21
+ const HttpUrl = z
22
+ .string()
23
+ .refine((url) => /^https?:\/\//i.test(url) && URL.canParse(url), "url must be an http or https URL")
24
+ .describe("The page to measure; local development servers (http://localhost:3000) work too");
25
+
26
+ // Free-form beyond script and status: each snippet has its own fields (snippets/SCHEMA.md). zod writes
27
+ // that as `additionalProperties: {}`; `true` says the same in a way every client reads.
28
+ const SnippetResult = z
29
+ .looseObject({ script: z.string(), status: z.string() })
30
+ .meta({ additionalProperties: true })
31
+ .describe("The object the snippet returns");
32
+
33
+ const SessionId = z.string().describe("The id open_page returned");
34
+
35
+ const Step = z.discriminatedUnion("action", [
36
+ z.object({ action: z.literal("click"), selector: z.string() }),
37
+ z.object({ action: z.literal("hover"), selector: z.string() }),
38
+ z.object({ action: z.literal("type"), selector: z.string(), text: z.string() }),
39
+ z.object({ action: z.literal("scroll"), y: z.number().int().optional().describe("Pixels down, 300 by default") }),
40
+ z.object({ action: z.literal("wait"), ms: z.number().int().min(0).max(10000) }),
41
+ ]);
42
+
43
+ const SnippetInfo = z.object({
44
+ name: z.string().describe("Category/Name, the name other tools take"),
45
+ category: z.enum(CATEGORIES),
46
+ title: z.string(),
47
+ aliases: z.array(z.string()).describe("Short names that also select the snippet"),
48
+ tracking: z.boolean().describe("The snippet records interactions and answers after them"),
49
+ });
50
+
51
+ // Every snippet starts with a comment that names it: "// LCP Quick Check"
52
+ const titleOf = (source) => source.split("\n", 1)[0].replace(/^\/\/\s*/, "").trim();
53
+
54
+ // The result of a tool, as structured content for clients that read it and as the same JSON in text
55
+ // for those that do not
56
+ const toolResult = (data) => ({ structuredContent: data, content: [{ type: "text", text: JSON.stringify(data) }] });
57
+
58
+ // A failure the model reads and can act on: a wrong argument or a page that does not load. It is
59
+ // still a result of the tool, not an error of the protocol.
60
+ const toolError = (text) => ({ isError: true, content: [{ type: "text", text }] });
61
+
62
+ const firstLine = (message) => message.split("\n")[0];
63
+
64
+ // A client that wants progress sends a token with the call; the stages of a run go to it
65
+ function progressFor(ctx) {
66
+ const progressToken = ctx.mcpReq._meta?.progressToken;
67
+ return (progress, message) =>
68
+ progressToken === undefined
69
+ ? undefined
70
+ : ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress, total: STAGES, message } });
71
+ }
72
+
73
+ const CRUX_FORM_FACTORS = { phone: "PHONE", desktop: "DESKTOP", tablet: "TABLET" };
74
+ const CRUX_SETUP =
75
+ "get_crux needs a CrUX API key in the CRUX_API_KEY environment variable of the MCP server. Set it in the configuration of the client, for example: claude mcp add webperf -e CRUX_API_KEY=<key> -- npx -y webperf-snippets mcp. A key is free in the Google Cloud console (Chrome UX Report API).";
76
+
77
+ function describeSnippet(name) {
78
+ const source = loadSnippet(name);
79
+ return {
80
+ name,
81
+ category: name.split("/")[0],
82
+ title: titleOf(source),
83
+ aliases: aliasesOf(name),
84
+ tracking: isTrackingSnippet(name, source),
85
+ };
86
+ }
87
+
88
+ // The MCP server of the CLI: the same snippets and runner, offered as tools an agent calls by name.
89
+ // serveStdio creates one instance per connection, so the pages it keeps open belong to that client.
90
+ export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env } = {}) {
91
+ const server = new McpServer({ name: "webperf-snippets", version: cliVersion });
92
+ const sessions = createSessions({ launchBrowser, maxPages, idleMs });
93
+ // The client left (end of stdin, or a closed transport): close the pages and Chromium
94
+ server.server.onclose = () => sessions.closeAll();
95
+ registerResources(server);
96
+ registerPrompts(server);
97
+
98
+ // Runs a session tool, and turns the errors the model can act on into tool errors
99
+ const withSession = (handler) => async (args, ctx) => {
100
+ try {
101
+ return await handler(args, ctx);
102
+ } catch (err) {
103
+ if (err instanceof SessionError) return toolError(err.message);
104
+ throw err;
105
+ }
106
+ };
107
+
108
+ server.registerTool(
109
+ "list_snippets",
110
+ {
111
+ title: "List snippets",
112
+ description:
113
+ "Lists the web performance snippets this server can run, with their category, title and short aliases. Call it to find the name of the snippet to run.",
114
+ inputSchema: z.object({
115
+ category: z.enum(CATEGORIES).optional().describe("Only the snippets of this category"),
116
+ }),
117
+ outputSchema: z.object({ snippets: z.array(SnippetInfo) }),
118
+ annotations: { readOnlyHint: true, openWorldHint: false },
119
+ },
120
+ async ({ category }) => {
121
+ const snippets = listSnippets()
122
+ .filter((name) => !category || name.startsWith(`${category}/`))
123
+ .map(describeSnippet);
124
+ return toolResult({ snippets });
125
+ }
126
+ );
127
+
128
+ server.registerTool(
129
+ "run_snippet",
130
+ {
131
+ title: "Run a snippet",
132
+ description:
133
+ "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.",
134
+ inputSchema: z.object({
135
+ url: HttpUrl,
136
+ snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
137
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
138
+ waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
139
+ }),
140
+ outputSchema: z.object({
141
+ url: z.string(),
142
+ snippet: z.string().describe("Category/Name of the snippet that ran"),
143
+ viewport: z.enum(VIEWPORTS),
144
+ navMs: z.number().describe("From the start of the navigation to the end of the wait"),
145
+ pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
146
+ result: SnippetResult,
147
+ }),
148
+ annotations: { readOnlyHint: true, openWorldHint: true },
149
+ },
150
+ async ({ url, snippet, viewport, waitMs }, ctx) => {
151
+ let path;
152
+ try {
153
+ path = resolveSnippetName(snippet);
154
+ } catch (err) {
155
+ return toolError(err.message);
156
+ }
157
+ const source = loadSnippet(path);
158
+ if (isTrackingSnippet(path, source)) {
159
+ return toolError(`${path} records interactions: open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`);
160
+ }
161
+
162
+ try {
163
+ const run = await runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress: progressFor(ctx) });
164
+ const { id, ...result } = run.results[0];
165
+ return toolResult({ url, snippet: id, viewport, navMs: run.navMs, pageErrors: run.pageErrors, result });
166
+ } catch (err) {
167
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
168
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
169
+ return toolError(`The run failed: ${firstLine(err.message)}`);
170
+ }
171
+ }
172
+ );
173
+
174
+ server.registerTool(
175
+ "run_workflow",
176
+ {
177
+ title: "Run a workflow",
178
+ description:
179
+ "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.",
180
+ inputSchema: z.object({
181
+ url: HttpUrl,
182
+ workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
183
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
184
+ waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
185
+ }),
186
+ outputSchema: z.object({
187
+ url: z.string(),
188
+ workflow: z.string(),
189
+ viewport: z.enum(VIEWPORTS),
190
+ navMs: z.number(),
191
+ pageErrors: z.array(z.string()),
192
+ results: z
193
+ .array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
194
+ .describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
195
+ }),
196
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
+ },
198
+ async ({ url, workflow, viewport, waitMs }, ctx) => {
199
+ try {
200
+ const run = await runMeasurement({
201
+ url,
202
+ workflow: WORKFLOWS[workflow],
203
+ rules: RULES,
204
+ waitMs,
205
+ viewport: VIEWPORT_PRESETS[viewport],
206
+ onProgress: progressFor(ctx),
207
+ });
208
+ // The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
209
+ const results = run.results.map((r) =>
210
+ r.status === "skipped"
211
+ ? { ...r, reason: "This step measures clicks and key presses: open the page with open_page and this snippet in track, use it with interact, and read it with read_results." }
212
+ : r
213
+ );
214
+ return toolResult({ url, workflow, viewport, navMs: run.navMs, pageErrors: run.pageErrors, results });
215
+ } catch (err) {
216
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
217
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
218
+ return toolError(`The run failed: ${firstLine(err.message)}`);
219
+ }
220
+ }
221
+ );
222
+
223
+ server.registerTool(
224
+ "get_crux",
225
+ {
226
+ title: "Get CrUX field data",
227
+ description:
228
+ "Returns the p75 of LCP, CLS and INP that real Chrome users had on a URL over the last 28 days, from the Chrome UX Report, with their rating. Falls back to the origin when the URL has no data of its own. Compare it with the lab values of run_snippet: field data is what users get.",
229
+ inputSchema: z.object({
230
+ url: HttpUrl,
231
+ formFactor: z.enum(Object.keys(CRUX_FORM_FACTORS)).default("phone").describe("The devices the data describes"),
232
+ }),
233
+ outputSchema: z.object({
234
+ url: z.string(),
235
+ formFactor: z.string(),
236
+ scope: z.enum(["url", "origin"]).optional().describe("Whether the data is for the URL or for its whole origin"),
237
+ metrics: z
238
+ .record(z.string(), z.object({ p75: z.number(), rating: z.string(), unit: z.string() }))
239
+ .optional()
240
+ .describe("By metric name: LCP, CLS, INP"),
241
+ unavailable: z.string().optional().describe("Why there is no field data, such as a page with too little traffic"),
242
+ }),
243
+ annotations: { readOnlyHint: true, openWorldHint: true },
244
+ },
245
+ async ({ url, formFactor }) => {
246
+ const apiKey = env.CRUX_API_KEY;
247
+ if (!apiKey) return toolError(CRUX_SETUP);
248
+ const crux = await fetchCrux({
249
+ url,
250
+ apiKey,
251
+ formFactor: CRUX_FORM_FACTORS[formFactor],
252
+ ...(env.WEBPERF_CRUX_ENDPOINT ? { endpoint: env.WEBPERF_CRUX_ENDPOINT } : {}),
253
+ });
254
+ if (crux.failed) return toolError(`Could not read the CrUX data: ${crux.unavailable}.`);
255
+ if (crux.unavailable) return toolResult({ url, formFactor, unavailable: crux.unavailable });
256
+ return toolResult({ url, formFactor, scope: crux.scope, metrics: crux.metrics });
257
+ }
258
+ );
259
+
260
+ server.registerTool(
261
+ "open_page",
262
+ {
263
+ title: "Open a page",
264
+ description:
265
+ "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.",
266
+ inputSchema: z.object({
267
+ url: HttpUrl,
268
+ track: z.array(z.string()).min(1).describe("The tracking snippets to install, by name, alias or Category/Name"),
269
+ viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
270
+ waitMs: z.number().int().min(0).max(10000).default(1000).describe("Wait after the load event before installing the snippets, in ms"),
271
+ }),
272
+ outputSchema: z.object({
273
+ sessionId: z.string(),
274
+ url: z.string(),
275
+ viewport: z.enum(VIEWPORTS),
276
+ navMs: z.number(),
277
+ tracking: z.array(z.object({ snippet: z.string(), status: z.string() })).describe("The installed snippets"),
278
+ }),
279
+ annotations: { readOnlyHint: true, openWorldHint: true },
280
+ },
281
+ withSession(async ({ url, track, viewport, waitMs }, ctx) => {
282
+ const items = [];
283
+ for (const name of track) {
284
+ let path;
285
+ try {
286
+ path = resolveSnippetName(name);
287
+ } catch (err) {
288
+ return toolError(err.message);
289
+ }
290
+ const source = loadSnippet(path);
291
+ if (!isTrackingSnippet(path, source)) {
292
+ return toolError(`${path} does not record interactions: run it with run_snippet instead.`);
293
+ }
294
+ items.push({ id: path, path, source });
295
+ }
296
+
297
+ let session;
298
+ try {
299
+ session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: ctx.mcpReq.signal });
300
+ } catch (err) {
301
+ if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
302
+ throw err;
303
+ }
304
+ try {
305
+ const navStart = Date.now();
306
+ await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
307
+ if (waitMs > 0) await session.page.waitForTimeout(waitMs);
308
+ const navMs = Date.now() - navStart;
309
+ session.tracking = await evaluateItems(session.page, items);
310
+ session.stopListening();
311
+ const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
312
+ return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
313
+ } catch (err) {
314
+ await sessions.close(session.id);
315
+ if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
316
+ return toolError(`Opening the page failed: ${firstLine(err.message)}`);
317
+ }
318
+ })
319
+ );
320
+
321
+ server.registerTool(
322
+ "interact",
323
+ {
324
+ title: "Interact with an open page",
325
+ description:
326
+ "Runs steps on a page opened with open_page, as a person would: click, type, hover, scroll or wait. A click or a key press is what the tracking snippets record. A step whose element does not appear within 5 seconds stops the run.",
327
+ inputSchema: z.object({
328
+ sessionId: SessionId,
329
+ steps: z.array(Step).min(1).max(50),
330
+ }),
331
+ outputSchema: z.object({ sessionId: z.string(), steps: z.number().describe("Steps run"), url: z.string().describe("The URL after the steps") }),
332
+ // A click can submit a form or change data on the site, so this tool is not read-only
333
+ annotations: { readOnlyHint: false, openWorldHint: true },
334
+ },
335
+ withSession(async ({ sessionId, steps }) => {
336
+ const session = sessions.get(sessionId);
337
+ try {
338
+ await runInteractions(session.page, steps);
339
+ } catch (err) {
340
+ return toolError(`A step failed: ${firstLine(err.message)}`);
341
+ }
342
+ session.hadInput ||= steps.some(isInputStep);
343
+ await settle(session.page);
344
+ return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
345
+ })
346
+ );
347
+
348
+ server.registerTool(
349
+ "read_results",
350
+ {
351
+ title: "Read the tracking snippets",
352
+ description:
353
+ "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 answers with status error, which is a result, not a failure.",
354
+ inputSchema: z.object({ sessionId: SessionId }),
355
+ outputSchema: z.object({ sessionId: z.string(), pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
356
+ annotations: { readOnlyHint: true, openWorldHint: false },
357
+ },
358
+ withSession(async ({ sessionId }) => {
359
+ const session = sessions.get(sessionId);
360
+ const results = [];
361
+ for (const installed of session.tracking) {
362
+ const { id, ...result } = await collectTrackingData(session.page, installed, session.hadInput);
363
+ results.push(result);
364
+ }
365
+ return toolResult({ sessionId, pageErrors: session.pageErrors, results });
366
+ })
367
+ );
368
+
369
+ server.registerTool(
370
+ "close_page",
371
+ {
372
+ title: "Close a page",
373
+ description: "Closes a page opened with open_page. Closing a page that is already closed is not an error.",
374
+ inputSchema: z.object({ sessionId: SessionId }),
375
+ outputSchema: z.object({ sessionId: z.string(), closed: z.boolean().describe("The page was open and is now closed") }),
376
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
377
+ },
378
+ async ({ sessionId }) => toolResult({ sessionId, closed: await sessions.close(sessionId) })
379
+ );
380
+
381
+ return server;
382
+ }
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // A failure of a session tool the model can act on: an unknown or closed page, or too many open pages
4
+ export class SessionError extends Error {}
5
+
6
+ const duration = (ms) => (ms >= 60000 ? `${ms / 60000} minutes` : `${ms / 1000} seconds`);
7
+
8
+ // The pages an MCP connection keeps open between calls. They share one Chromium, launched with the
9
+ // first page, and each page has its own browser context, so pages share no cookies or storage. A page
10
+ // left unused for `idleMs` closes on its own, and closeAll() closes every page and Chromium.
11
+ export function createSessions({ launchBrowser, maxPages = 3, idleMs = 10 * 60 * 1000 }) {
12
+ let browser = null;
13
+ const open = new Map();
14
+ const closedWhileIdle = new Set();
15
+ let opening = 0;
16
+
17
+ const ids = () => [...open.keys()].join(", ") || "none";
18
+
19
+ function touch(session) {
20
+ clearTimeout(session.timer);
21
+ session.timer = setTimeout(() => {
22
+ closedWhileIdle.add(session.id);
23
+ close(session.id);
24
+ }, idleMs);
25
+ // The timer alone must not keep the process alive
26
+ session.timer.unref?.();
27
+ }
28
+
29
+ // Opens a context and a page. An aborted signal (the client cancelled the call) closes the context,
30
+ // which makes any navigation in progress fail.
31
+ async function create({ viewport, signal }) {
32
+ if (open.size + opening >= maxPages) {
33
+ throw new SessionError(`${maxPages} pages are open already (${ids()}). Close one with close_page first.`);
34
+ }
35
+ opening++;
36
+ try {
37
+ // A launch that fails (Chromium not installed yet) is not kept: the next page launches again
38
+ browser ??= launchBrowser().catch((err) => {
39
+ browser = null;
40
+ throw err;
41
+ });
42
+ const context = await (await browser).newContext({ viewport });
43
+ const page = await context.newPage();
44
+ const session = { id: randomUUID(), context, page, pageErrors: [], tracking: [], hadInput: false };
45
+ page.on("pageerror", (err) => session.pageErrors.push(err.message));
46
+ // Actions on a page that is still open answer within seconds, not the 30 s of Playwright
47
+ page.setDefaultTimeout(5000);
48
+ const cancel = () => close(session.id);
49
+ signal?.addEventListener("abort", cancel, { once: true });
50
+ session.stopListening = () => signal?.removeEventListener("abort", cancel);
51
+ open.set(session.id, session);
52
+ touch(session);
53
+ return session;
54
+ } finally {
55
+ opening--;
56
+ }
57
+ }
58
+
59
+ function get(id) {
60
+ const session = open.get(id);
61
+ if (session) {
62
+ touch(session);
63
+ return session;
64
+ }
65
+ if (closedWhileIdle.has(id)) {
66
+ throw new SessionError(`The page ${id} was closed after ${duration(idleMs)} without use. Open it again with open_page.`);
67
+ }
68
+ throw new SessionError(`No open page has the id ${id} (open: ${ids()}). Open one with open_page.`);
69
+ }
70
+
71
+ async function close(id) {
72
+ const session = open.get(id);
73
+ if (!session) return false;
74
+ open.delete(id);
75
+ clearTimeout(session.timer);
76
+ session.stopListening();
77
+ await session.context.close().catch(() => {});
78
+ return true;
79
+ }
80
+
81
+ async function closeAll() {
82
+ await Promise.all([...open.keys()].map(close));
83
+ if (browser) {
84
+ const launched = browser;
85
+ browser = null;
86
+ await (await launched).close().catch(() => {});
87
+ }
88
+ }
89
+
90
+ return { create, get, close, closeAll };
91
+ }
@@ -0,0 +1,10 @@
1
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
+ import { createServer } from "./server.js";
3
+
4
+ // Serves the MCP server over stdio: the client starts this process and exchanges JSON-RPC messages,
5
+ // one per line, through its standard input and output. Standard output carries nothing else, so
6
+ // nothing here or in the code it calls may write to it. serveStdio answers both eras of the
7
+ // protocol, and decides which one when the client connects.
8
+ export function startStdioServer() {
9
+ return serveStdio(() => createServer());
10
+ }
@@ -169,7 +169,11 @@ function renderResult(r, verbose) {
169
169
  out += rows
170
170
  .map(([name, info]) => {
171
171
  const status = info.overTarget ? styleText("red", "🔴") : styleText("green", "✓");
172
- return ` ${status} ${pad(name, 22)} ${pad(`${info.value}ms`, 8)} ${pad(`${info.percent}%`, 6)}`;
172
+ const row = ` ${status} ${pad(name, 22)} ${pad(`${info.value}ms`, 8)} ${pad(`${info.percent}%`, 6)}`;
173
+ // The Resource Load Time splits into the wait for the server and the download, when the snippet measures it
174
+ return info.waiting == null
175
+ ? row
176
+ : `${row}\n ${styleText("dim", `${info.waiting}ms waiting for the server, ${info.download}ms downloading`)}`;
173
177
  })
174
178
  .join("\n");
175
179
  if (r.details.slowestPhase) {
package/src/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chromium } from "playwright";
1
+ import { launchChromium } 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";
@@ -12,6 +12,12 @@ export const VIEWPORT_PRESETS = {
12
12
 
13
13
  const DEFAULT_NAV_TIMEOUT = 30000;
14
14
 
15
+ // On a loaded machine the browser can deliver the timing of a click later than the frames `settle`
16
+ // waits for. After a click or a key press, a tracking snippet that has no data yet is read again,
17
+ // every POLL_MS, until it has some or LATE_ENTRIES_MS have passed.
18
+ const LATE_ENTRIES_MS = 2000;
19
+ const POLL_MS = 100;
20
+
15
21
  // Snippets are IIFEs. Playwright evaluates a string as an expression, so we
16
22
  // trim trailing semicolons to keep the IIFE call as a single expression and
17
23
  // recover its return value in Node.
@@ -19,7 +25,7 @@ function toExpression(source) {
19
25
  return source.trim().replace(/;\s*$/, "");
20
26
  }
21
27
 
22
- async function evaluateItems(page, items) {
28
+ export async function evaluateItems(page, items) {
23
29
  const results = [];
24
30
  for (const item of items) {
25
31
  try {
@@ -56,17 +62,24 @@ async function evaluateAroundInteractions(page, items, interactions) {
56
62
  page,
57
63
  items.filter((item) => !tracking.includes(item))
58
64
  );
65
+ const hasInput = loadInteractionSteps(interactions).some(isInputStep);
59
66
  const collected = [];
60
- for (const result of installed) collected.push(await collectTrackingData(page, result));
67
+ for (const result of installed) collected.push(await collectTrackingData(page, result, hasInput));
61
68
 
62
69
  const byId = new Map([...collected, ...others].map((r) => [r.id, r]));
63
70
  return items.map((item) => byId.get(item.id));
64
71
  }
65
72
 
66
- async function collectTrackingData(page, result) {
73
+ export async function collectTrackingData(page, result, hasInput) {
67
74
  if (result.status !== "tracking" || !result.getDataFn) return result;
68
75
  try {
69
- const data = await page.evaluate(`(async () => await ${result.getDataFn}())()`);
76
+ const read = () => page.evaluate(`(async () => await ${result.getDataFn}())()`);
77
+ let data = await read();
78
+ const deadline = Date.now() + LATE_ENTRIES_MS;
79
+ while (hasInput && data?.status === "error" && Date.now() < deadline) {
80
+ await page.waitForTimeout(POLL_MS);
81
+ data = await read();
82
+ }
70
83
  if (data && typeof data === "object") return { id: result.id, ...data };
71
84
  return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
72
85
  } catch (err) {
@@ -84,8 +97,11 @@ export async function runSnippets({
84
97
  interactScript,
85
98
  interactions = interactScript,
86
99
  storageState,
100
+ // Called at each of the three stages of a run, for callers that report progress (the MCP server)
101
+ onProgress = () => {},
87
102
  }) {
88
- const browser = await chromium.launch({ headless });
103
+ await onProgress(1, "Opening Chromium");
104
+ const browser = await launchChromium({ headless });
89
105
  try {
90
106
  const context = await browser.newContext({ viewport, storageState });
91
107
  const page = await context.newPage();
@@ -95,7 +111,9 @@ export async function runSnippets({
95
111
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
96
112
  if (waitMs > 0) await page.waitForTimeout(waitMs);
97
113
  const navMs = Date.now() - navStart;
114
+ await onProgress(2, "Page loaded, running the snippets");
98
115
  const results = await evaluateAroundInteractions(page, items, interactions);
116
+ await onProgress(3, "Snippets evaluated");
99
117
  return { url, navMs, results, pageErrors };
100
118
  } finally {
101
119
  await browser.close();
@@ -113,8 +131,10 @@ export async function runMeasurement({
113
131
  interactScript,
114
132
  interactions = interactScript,
115
133
  storageState,
134
+ onProgress = () => {},
116
135
  }) {
117
- const browser = await chromium.launch({ headless });
136
+ await onProgress(1, "Opening Chromium");
137
+ const browser = await launchChromium({ headless });
118
138
  try {
119
139
  const context = await browser.newContext({ viewport, storageState });
120
140
  const page = await context.newPage();
@@ -125,6 +145,7 @@ export async function runMeasurement({
125
145
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
126
146
  if (waitMs > 0) await page.waitForTimeout(waitMs);
127
147
  const navMs = Date.now() - navStart;
148
+ await onProgress(2, "Page loaded, running the workflow");
128
149
 
129
150
  // The steps of the user's script, or the workflow's own. A step that needs a click or a key press
130
151
  // is skipped when the interactions have none, so it does not fail for lack of data.
@@ -172,6 +193,7 @@ export async function runMeasurement({
172
193
  });
173
194
  }
174
195
 
196
+ await onProgress(3, "Workflow and follow-ups evaluated");
175
197
  return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors };
176
198
  } finally {
177
199
  await browser.close();
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { SNIPPET_DIRS } from "./load-snippet.js";
4
4
 
5
5
  // The categories whose snippets run headlessly. DevTools-Overrides pairs are pasted by hand.
6
- const CATEGORIES = ["CoreWebVitals", "Loading", "Interaction", "Media", "Resources"];
6
+ export const CATEGORIES = ["CoreWebVitals", "Loading", "Interaction", "Media", "Resources"];
7
7
 
8
8
  // Short names for the snippets people run most. Any snippet also answers to its own name.
9
9
  const SNIPPET_ALIASES = {
@@ -42,6 +42,15 @@ const SNIPPET_ALIASES = {
42
42
  };
43
43
  let cache = null;
44
44
 
45
+ // The short names of a snippet, such as "render-blocking" for Loading/Find-render-blocking-resources.
46
+ // Its own name always works too, so it is not listed.
47
+ export function aliasesOf(path) {
48
+ const own = path.split("/")[1];
49
+ return Object.entries(SNIPPET_ALIASES)
50
+ .filter(([alias, target]) => target === path && alias !== own)
51
+ .map(([alias]) => alias);
52
+ }
53
+
45
54
  // Every runnable snippet as "Category/Name", read from the bundled copy or the workspace.
46
55
  export function listSnippets() {
47
56
  if (cache) return cache;
@@ -0,0 +1,12 @@
1
+ import { cwvWorkflow } from "./cwv.js";
2
+ import { auditWorkflow } from "./audit.js";
3
+ import { loadingWorkflow } from "./loading.js";
4
+ import { interactionWorkflow } from "./interaction.js";
5
+
6
+ // The workflows by the name --workflow and the run_workflow tool of the MCP server take
7
+ export const WORKFLOWS = {
8
+ "core-web-vitals": cwvWorkflow,
9
+ audit: auditWorkflow,
10
+ loading: loadingWorkflow,
11
+ interaction: interactionWorkflow,
12
+ };