webperf-snippets 1.0.0 → 1.2.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.
Files changed (36) hide show
  1. package/README.md +96 -17
  2. package/package.json +7 -2
  3. package/snippets/CoreWebVitals/INP.js +134 -62
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +27 -4
  5. package/snippets/CoreWebVitals/LCP-Subparts.js +29 -2
  6. package/snippets/CoreWebVitals/WORKFLOWS.md +2 -2
  7. package/snippets/Interaction/Input-Latency-Breakdown.js +87 -36
  8. package/snippets/Interaction/Interactions.js +90 -38
  9. package/snippets/Interaction/Long-Animation-Frames.js +20 -1
  10. package/snippets/Loading/Cache-Strategy-Analysis.js +2 -1
  11. package/snippets/Loading/FCP.js +34 -7
  12. package/snippets/Loading/First-And-Third-Party-Script-Info.js +51 -11
  13. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +2 -1
  14. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +2 -1
  15. package/snippets/Loading/Inline-Script-Info-and-Size.js +26 -6
  16. package/snippets/Loading/Script-Loading.js +15 -3
  17. package/snippets/Loading/TTFB-Resources.js +2 -1
  18. package/snippets/Loading/Third-Party-Impact-by-Domain.js +27 -5
  19. package/snippets/SCHEMA.md +76 -30
  20. package/snippets/_shared/helpers.js +72 -0
  21. package/src/bin.js +64 -24
  22. package/src/browser.js +22 -0
  23. package/src/crux.js +5 -3
  24. package/src/headers.js +35 -0
  25. package/src/mcp/documents.js +111 -0
  26. package/src/mcp/prompts.js +101 -0
  27. package/src/mcp/server.js +382 -0
  28. package/src/mcp/sessions.js +91 -0
  29. package/src/mcp/stdio.js +10 -0
  30. package/src/own-domains.js +16 -0
  31. package/src/reporters/human.js +8 -2
  32. package/src/reporters/markdown.js +3 -1
  33. package/src/runner.js +50 -12
  34. package/src/snippet-names.js +10 -1
  35. package/src/throttling.js +43 -0
  36. package/src/workflows/index.js +12 -0
package/src/bin.js CHANGED
@@ -4,29 +4,28 @@ import { parseArgs } from "node:util";
4
4
  import { loadSnippet } from "./load-snippet.js";
5
5
  import { resolveSnippetName } from "./snippet-names.js";
6
6
  import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
7
- import { cwvWorkflow } from "./workflows/cwv.js";
8
- import { auditWorkflow } from "./workflows/audit.js";
9
- import { loadingWorkflow } from "./workflows/loading.js";
10
- import { interactionWorkflow } from "./workflows/interaction.js";
7
+ import { WORKFLOWS } from "./workflows/index.js";
11
8
  import { RULES } from "./decision-tree.js";
12
9
  import { reportHuman } from "./reporters/human.js";
13
10
  import { reportJson } from "./reporters/json.js";
14
11
  import { reportMarkdown } from "./reporters/markdown.js";
15
12
  import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
16
13
  import { fetchCrux, formFactorFor } from "./crux.js";
17
-
18
- const WORKFLOWS = {
19
- "core-web-vitals": cwvWorkflow,
20
- audit: auditWorkflow,
21
- loading: loadingWorkflow,
22
- interaction: interactionWorkflow,
23
- };
14
+ import { MissingBrowserError } from "./browser.js";
15
+ import { parseHeader } from "./headers.js";
16
+ import { NETWORK_PRESETS, parseCpuFactor } from "./throttling.js";
17
+ import { parseOwnDomains } from "./own-domains.js";
24
18
 
25
19
 
26
20
  const USAGE = `webperf-snippets <url> [options]
21
+ webperf-snippets mcp
27
22
 
28
23
  Run curated WebPerf Snippets headlessly via Playwright.
29
24
 
25
+ Commands:
26
+ mcp Start the MCP server over stdio, for AI agents
27
+ (claude mcp add webperf -- npx -y webperf-snippets mcp)
28
+
30
29
  Options:
31
30
  --workflow <name> Workflow to run (default: core-web-vitals)
32
31
  Workflows: core-web-vitals, audit, loading, interaction
@@ -41,6 +40,8 @@ Options:
41
40
  --markdown Output GitHub-renderable markdown (for PR comments)
42
41
  --viewport <preset> Viewport preset: mobile (default), tablet, desktop
43
42
  --wait <ms> Post-load wait before evaluating (default: 3000)
43
+ --cpu <factor> Slow the CPU down by this factor, such as 4 (no throttling by default)
44
+ --network <preset> Throttle the network as DevTools does: fast-4g, slow-4g, 3g
44
45
  --budget-lcp <ms> Exit 1 if LCP exceeds this value
45
46
  --budget-cls <score> Exit 1 if CLS exceeds this value
46
47
  --interact-script <path> JSON file with interactions to run after the load; the
@@ -48,6 +49,10 @@ Options:
48
49
  Actions: scroll, click, hover, type, wait
49
50
  --storage-state <path> Playwright storage state (cookies + localStorage) to
50
51
  measure pages that require authentication
52
+ --header "Name: value" Request header for the origin of the URL only, such as
53
+ the token of an access proxy. Repeat it for more headers
54
+ --own-domains <list> Domains of the site to count as first party, such as its
55
+ own CDN, comma-separated or repeated
51
56
  --crux-key <key> Add CrUX field data (p75) next to the measured values.
52
57
  Also read from the CRUX_API_KEY environment variable
53
58
  --report-to <url> POST the results to this https URL after the run
@@ -59,16 +64,19 @@ Options:
59
64
  -h, --help Show this help
60
65
 
61
66
  Examples:
62
- npx webperf-snippets https://web.dev
63
- npx webperf-snippets https://web.dev --workflow audit
64
- npx webperf-snippets https://web.dev --json
65
- npx webperf-snippets https://web.dev --snippet LCP-Subparts
66
- npx webperf-snippets https://web.dev --snippet render-blocking
67
- npx webperf-snippets https://web.dev --snippet fonts
68
- npx webperf-snippets https://web.dev --snippet Compression-Audit
69
- npx webperf-snippets https://web.dev --budget-lcp 2500
70
- npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
67
+ npx webperf-snippets https://example.com
68
+ npx webperf-snippets https://example.com --workflow audit
69
+ npx webperf-snippets https://example.com --json
70
+ npx webperf-snippets https://example.com --snippet LCP-Subparts
71
+ npx webperf-snippets https://example.com --snippet render-blocking
72
+ npx webperf-snippets https://example.com --snippet fonts
73
+ npx webperf-snippets https://example.com --snippet Compression-Audit
74
+ npx webperf-snippets https://example.com --budget-lcp 2500
75
+ npx webperf-snippets https://example.com --cpu 4 --network slow-4g
76
+ npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
77
+ npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
71
78
  npx webperf-snippets https://example.com/dashboard --storage-state auth.json
79
+ npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
72
80
  `;
73
81
 
74
82
  function fail(message, code = 2) {
@@ -104,6 +112,13 @@ function checkBudgets(results, values) {
104
112
  }
105
113
 
106
114
  async function main() {
115
+ // The MCP server only loads its SDK when it is asked for
116
+ if (process.argv[2] === "mcp") {
117
+ const { startStdioServer } = await import("./mcp/stdio.js");
118
+ await startStdioServer();
119
+ return;
120
+ }
121
+
107
122
  let parsed;
108
123
  try {
109
124
  parsed = parseArgs({
@@ -116,8 +131,12 @@ async function main() {
116
131
  "budget-lcp": { type: "string" },
117
132
  "budget-cls": { type: "string" },
118
133
  viewport: { type: "string" },
134
+ cpu: { type: "string" },
135
+ network: { type: "string" },
119
136
  "interact-script": { type: "string" },
120
137
  "storage-state": { type: "string" },
138
+ header: { type: "string", multiple: true },
139
+ "own-domains": { type: "string", multiple: true },
121
140
  "crux-key": { type: "string" },
122
141
  "report-to": { type: "string" },
123
142
  "api-key": { type: "string" },
@@ -151,6 +170,13 @@ async function main() {
151
170
  fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
152
171
  }
153
172
 
173
+ const cpu = values.cpu != null ? parseCpuFactor(values.cpu) : undefined;
174
+ if (cpu === null) fail(`Invalid CPU factor "${values.cpu}". Use a number of at least 1, such as 4`);
175
+ const network = values.network;
176
+ if (network != null && !NETWORK_PRESETS[network]) {
177
+ fail(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
178
+ }
179
+
154
180
  const reportUrl = values["report-to"];
155
181
  if (reportUrl) {
156
182
  const problem = validateReportUrl(reportUrl);
@@ -163,15 +189,28 @@ async function main() {
163
189
  fail(`Storage state file not found: ${storageState}`);
164
190
  }
165
191
 
192
+ const headers = {};
193
+ for (const raw of values.header ?? []) {
194
+ const header = parseHeader(raw);
195
+ if (!header) fail(`Invalid header "${raw}". Use --header "Name: value"`);
196
+ headers[header[0]] = header[1];
197
+ }
198
+
199
+ const invalidOwnDomains = (values["own-domains"] ?? []).find((raw) => !parseOwnDomains([raw]));
200
+ if (invalidOwnDomains != null) {
201
+ fail(`Invalid --own-domains "${invalidOwnDomains}". Use domains separated by commas, such as cdn.example.net,static.example.org`);
202
+ }
203
+ const ownDomains = values["own-domains"] && parseOwnDomains(values["own-domains"]);
204
+
166
205
  let payload;
167
206
  if (values.snippet) {
168
207
  const items = buildSnippetItem(values);
169
- payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
208
+ payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
170
209
  } else {
171
210
  const workflowName = values.workflow ?? "core-web-vitals";
172
211
  const workflow = WORKFLOWS[workflowName];
173
212
  if (!workflow) fail(`Unknown workflow: ${workflowName}`);
174
- payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
213
+ payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState, headers, cpu, network, ownDomains });
175
214
  }
176
215
 
177
216
  const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
@@ -199,7 +238,7 @@ async function main() {
199
238
  timestamp: new Date().toISOString(),
200
239
  navMs: payload.navMs,
201
240
  results: payload.results,
202
- meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
241
+ meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), cli_version: cliVersion },
203
242
  },
204
243
  });
205
244
  if (!outcome.ok) {
@@ -228,6 +267,7 @@ async function main() {
228
267
  }
229
268
 
230
269
  main().catch((err) => {
231
- process.stderr.write(`Error: ${err.stack ?? err.message}\n`);
270
+ // A missing Chromium is a step of the setup, not a bug: the message says what to run, without a stack
271
+ process.stderr.write(err instanceof MissingBrowserError ? `${err.message}\n` : `Error: ${err.stack ?? err.message}\n`);
232
272
  process.exit(1);
233
273
  });
package/src/browser.js ADDED
@@ -0,0 +1,22 @@
1
+ import { chromium } from "playwright";
2
+
3
+ // Chromium for Playwright is a download of its own: npx installs this package and its peer playwright,
4
+ // not the browser. Without it, Playwright fails with the path of the missing executable, and its advice
5
+ // (npx playwright install) downloads every browser; this message names the only one the CLI uses.
6
+ export const MISSING_BROWSER = "Chromium for Playwright is not installed. Install it once with: npx playwright install chromium";
7
+
8
+ export class MissingBrowserError extends Error {
9
+ constructor() {
10
+ super(MISSING_BROWSER);
11
+ }
12
+ }
13
+
14
+ export const isMissingBrowser = (err) => err instanceof MissingBrowserError || /Executable doesn't exist/.test(err?.message ?? "");
15
+
16
+ export async function launchChromium(options) {
17
+ try {
18
+ return await chromium.launch(options);
19
+ } catch (err) {
20
+ throw isMissingBrowser(err) ? new MissingBrowserError() : err;
21
+ }
22
+ }
package/src/crux.js CHANGED
@@ -40,7 +40,9 @@ async function query({ endpoint, apiKey, body, timeoutMs }) {
40
40
  }
41
41
 
42
42
  // Never throws, and no message it returns contains the key (it travels in the
43
- // query string, so the request URL is never logged or put in an error).
43
+ // query string, so the request URL is never logged or put in an error). A page
44
+ // with no field data is an answer ({ unavailable }); an API error or no answer is
45
+ // a failure ({ unavailable, failed: true }).
44
46
  export async function fetchCrux({
45
47
  url,
46
48
  apiKey,
@@ -56,13 +58,13 @@ export async function fetchCrux({
56
58
  answer = await query({ endpoint, apiKey, body: { origin: new URL(url).origin, formFactor }, timeoutMs });
57
59
  }
58
60
  if (answer.notFound) return { unavailable: "not in the CrUX dataset" };
59
- if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}` };
61
+ if (answer.status) return { unavailable: `the CrUX API answered ${answer.status}`, failed: true };
60
62
  const metrics = parseMetrics(answer.record);
61
63
  if (Object.keys(metrics).length === 0) return { unavailable: "no Core Web Vitals in the CrUX record" };
62
64
  return { scope, formFactor, metrics };
63
65
  } catch (err) {
64
66
  const reason = err.name === "TimeoutError" ? `no answer within ${timeoutMs}ms` : (err.cause?.code ?? "request failed");
65
- return { unavailable: reason };
67
+ return { unavailable: reason, failed: true };
66
68
  }
67
69
  }
68
70
 
package/src/headers.js ADDED
@@ -0,0 +1,35 @@
1
+ // Extra request headers, for pages behind an access proxy that reads a token from each request.
2
+ // They go only to the origin of the measured URL: sent to every origin, as Playwright's
3
+ // extraHTTPHeaders does, they would hand the token to third parties, and a custom header on a
4
+ // cross-origin fetch needs a CORS preflight that most third parties do not answer.
5
+ //
6
+ // The Fetch domain of CDP pauses only the requests that match the pattern, inside the browser,
7
+ // so third-party requests are not intercepted and keep their timings. Each request to the origin
8
+ // waits for the round trip to Node, which adds a few milliseconds to its timing.
9
+
10
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
11
+
12
+ // "Name: value" as given to --header, or null when it is not a valid header
13
+ export function parseHeader(raw) {
14
+ const colon = raw.indexOf(":");
15
+ if (colon === -1) return null;
16
+ const name = raw.slice(0, colon).trim();
17
+ if (!HEADER_NAME.test(name)) return null;
18
+ return [name, raw.slice(colon + 1).trim()];
19
+ }
20
+
21
+ export async function sendHeadersToOrigin(context, page, url, headers) {
22
+ if (!headers || Object.keys(headers).length === 0) return;
23
+ const extra = Object.entries(headers);
24
+ const replaced = new Set(extra.map(([name]) => name.toLowerCase()));
25
+ const cdp = await context.newCDPSession(page);
26
+ cdp.on("Fetch.requestPaused", ({ requestId, request }) => {
27
+ const kept = Object.entries(request.headers).filter(([name]) => !replaced.has(name.toLowerCase()));
28
+ const all = [...kept, ...extra].map(([name, value]) => ({ name, value }));
29
+ // The request is gone when the page navigates away while it is paused
30
+ cdp.send("Fetch.continueRequest", { requestId, headers: all }).catch(() => {});
31
+ });
32
+ await cdp.send("Fetch.enable", {
33
+ patterns: [{ urlPattern: `${new URL(url).origin}/*`, requestStage: "Request" }],
34
+ });
35
+ }
@@ -0,0 +1,111 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ResourceTemplate, ResourceNotFoundError } from "@modelcontextprotocol/server";
4
+ import { SNIPPET_DIRS } from "../load-snippet.js";
5
+ import { CATEGORIES } from "../snippet-names.js";
6
+
7
+ const MARKDOWN = "text/markdown";
8
+
9
+ // A document of snippets/, from the copy bundled with the package or from the repository
10
+ function readDocument(relativePath) {
11
+ for (const dir of SNIPPET_DIRS) {
12
+ try {
13
+ return readFileSync(join(dir, relativePath), "utf8");
14
+ } catch {
15
+ // try the next directory
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ // Splits SCHEMA.md into the general contract and the section of each snippet ("#### LCP" up to the next
22
+ // heading of that level or above). The general contract is everything outside "## Script-Specific Schemas".
23
+ export function splitSchema(markdown) {
24
+ const lines = markdown.split("\n");
25
+ const start = lines.findIndex((l) => l.startsWith("## Script-Specific Schemas"));
26
+ const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
27
+ const general = [...lines.slice(0, start), ...lines.slice(end)].join("\n");
28
+
29
+ const sections = new Map();
30
+ let name = null;
31
+ let current = [];
32
+ const flush = () => name && sections.set(name, current.join("\n").trim());
33
+ for (const line of lines.slice(start, end)) {
34
+ if (line.startsWith("#### ")) {
35
+ flush();
36
+ name = line.slice(5).split(" ")[0];
37
+ current = [line];
38
+ } else if (/^#{1,3} /.test(line)) {
39
+ flush();
40
+ name = null;
41
+ } else if (name) {
42
+ current.push(line);
43
+ }
44
+ }
45
+ flush();
46
+ return { general, sections };
47
+ }
48
+
49
+ let schema = null;
50
+ export function schemaSection(snippet) {
51
+ schema ??= splitSchema(readDocument("SCHEMA.md") ?? "");
52
+ return snippet === undefined ? schema.general : schema.sections.get(snippet);
53
+ }
54
+
55
+ function schemaSnippets() {
56
+ schemaSection();
57
+ return [...schema.sections.keys()];
58
+ }
59
+
60
+ const markdown = (uri, text) => ({ contents: [{ uri: uri.href, mimeType: MARKDOWN, text }] });
61
+
62
+ export function registerResources(server) {
63
+ server.registerResource(
64
+ "schema",
65
+ "webperf://schema",
66
+ {
67
+ title: "Snippet result contract",
68
+ description: "What every snippet returns: the base shape, the statuses, the execution patterns and how to read a result. The fields of each snippet are in webperf://schema/{snippet}.",
69
+ mimeType: MARKDOWN,
70
+ },
71
+ async (uri) => markdown(uri, schemaSection())
72
+ );
73
+
74
+ server.registerResource(
75
+ "snippet-schema",
76
+ new ResourceTemplate("webperf://schema/{snippet}", {
77
+ list: async () => ({
78
+ resources: schemaSnippets().map((snippet) => ({ uri: `webperf://schema/${snippet}`, name: `${snippet} result`, mimeType: MARKDOWN })),
79
+ }),
80
+ }),
81
+ {
82
+ title: "Result of one snippet",
83
+ description: "The fields one snippet returns, with an example, from snippets/SCHEMA.md.",
84
+ mimeType: MARKDOWN,
85
+ },
86
+ async (uri, { snippet }) => {
87
+ const section = schemaSection(snippet);
88
+ if (!section) throw new ResourceNotFoundError(uri.href);
89
+ return markdown(uri, section);
90
+ }
91
+ );
92
+
93
+ server.registerResource(
94
+ "workflows",
95
+ new ResourceTemplate("webperf://workflows/{category}", {
96
+ list: async () => ({
97
+ resources: CATEGORIES.map((category) => ({ uri: `webperf://workflows/${category}`, name: `${category} workflows`, mimeType: MARKDOWN })),
98
+ }),
99
+ }),
100
+ {
101
+ title: "Workflows and decision tree",
102
+ description: "The snippets to run for common questions of a category, and what to run next after each one depending on its result.",
103
+ mimeType: MARKDOWN,
104
+ },
105
+ async (uri, { category }) => {
106
+ const text = CATEGORIES.includes(category) ? readDocument(`${category}/WORKFLOWS.md`) : null;
107
+ if (!text) throw new ResourceNotFoundError(uri.href);
108
+ return markdown(uri, text);
109
+ }
110
+ );
111
+ }
@@ -0,0 +1,101 @@
1
+ import * as z from "zod/v4";
2
+ import { schemaSection } from "./documents.js";
3
+
4
+ // Prompts are templates the user picks (in Claude Code, as /mcp__webperf__audit_lcp). Each one says
5
+ // which tools to chain and in what order, and embeds the schema sections the answer needs, so the model
6
+ // reads the results with the same reference the skills use.
7
+ const text = (value) => ({ role: "user", content: { type: "text", text: value } });
8
+ const schemaOf = (snippet) => ({
9
+ role: "user",
10
+ content: { type: "resource", resource: { uri: `webperf://schema/${snippet}`, mimeType: "text/markdown", text: schemaSection(snippet) } },
11
+ });
12
+
13
+ const Url = z.string().describe("The page to audit");
14
+ const Viewport = z.enum(["mobile", "desktop"]).optional().describe("mobile by default");
15
+ const QUOTE = "Quote the values the tools return; do not estimate or round them up.";
16
+
17
+ export function registerPrompts(server) {
18
+ server.registerPrompt(
19
+ "audit_lcp",
20
+ {
21
+ title: "Audit LCP",
22
+ description: "Measures the Largest Contentful Paint of a page in the lab and in the field, finds its slowest sub-part and proposes fixes.",
23
+ argsSchema: z.object({ url: Url, viewport: Viewport }),
24
+ },
25
+ ({ url, viewport = "mobile" }) => ({
26
+ messages: [
27
+ text(
28
+ [
29
+ `Audit the Largest Contentful Paint of ${url} on ${viewport}.`,
30
+ "",
31
+ `1. Call run_workflow with url "${url}", workflow "core-web-vitals" and viewport "${viewport}". It returns LCP and CLS, and adds LCP-Subparts with the reason when LCP is over 2.5 s.`,
32
+ `2. Call get_crux for the same URL with formFactor "${viewport === "desktop" ? "desktop" : "phone"}", to compare the lab with what real users get. If it answers that there is no key, say so and go on with the lab data.`,
33
+ '3. If LCP-Subparts did not run, call run_snippet with snippet "LCP-Subparts".',
34
+ "4. Report the LCP value and rating in the lab and in the field, the LCP element, the slowest sub-part and what it means, and the fixes for that sub-part, the most effective first.",
35
+ "",
36
+ `${QUOTE} The result of each snippet is described below.`,
37
+ ].join("\n")
38
+ ),
39
+ schemaOf("LCP"),
40
+ schemaOf("LCP-Subparts"),
41
+ ],
42
+ })
43
+ );
44
+
45
+ server.registerPrompt(
46
+ "debug_inp",
47
+ {
48
+ title: "Debug INP",
49
+ description: "Opens a page, clicks on it like a user and finds the slowest interaction, its target and the phase that makes it slow.",
50
+ argsSchema: z.object({
51
+ url: Url,
52
+ selector: z.string().optional().describe("A CSS selector of the element to click, such as #buy"),
53
+ viewport: Viewport,
54
+ }),
55
+ },
56
+ ({ url, selector, viewport = "mobile" }) => ({
57
+ messages: [
58
+ text(
59
+ [
60
+ `Find what makes the interactions slow on ${url} (${viewport}).`,
61
+ "",
62
+ `1. Call open_page with url "${url}", viewport "${viewport}" and track ["INP", "Interactions", "Input-Latency-Breakdown"].`,
63
+ selector
64
+ ? `2. Call interact with a click on "${selector}". Add a few more clicks or key presses if the page has other controls a user would use.`
65
+ : "2. Call interact with clicks on the controls a user would use first: main buttons, links that open menus, form fields. If you cannot tell which ones, ask me.",
66
+ "3. Call read_results. INP gives the worst interaction and its phases (input delay, processing, presentation); Input-Latency-Breakdown says which phase dominates for each type of event.",
67
+ "4. Call close_page.",
68
+ "5. Report the worst interaction, its target, the dominant phase and the fixes for that phase.",
69
+ "",
70
+ `${QUOTE} The result of INP is described below.`,
71
+ ].join("\n")
72
+ ),
73
+ schemaOf("INP"),
74
+ ],
75
+ })
76
+ );
77
+
78
+ server.registerPrompt(
79
+ "audit_page",
80
+ {
81
+ title: "Audit a page",
82
+ description: "Runs the audit and loading workflows on a page and summarizes the issues by severity, with fixes.",
83
+ argsSchema: z.object({ url: Url, viewport: Viewport }),
84
+ },
85
+ ({ url, viewport = "mobile" }) => ({
86
+ messages: [
87
+ text(
88
+ [
89
+ `Audit the loading performance of ${url} on ${viewport}.`,
90
+ "",
91
+ `1. Call run_workflow with url "${url}", workflow "audit" and viewport "${viewport}".`,
92
+ '2. Call run_workflow with the same URL and workflow "loading".',
93
+ "3. Summarize the issues the snippets report, errors first, then warnings, each with the snippet that found it and the fix. Leave out the checks that passed.",
94
+ "",
95
+ `${QUOTE} If a result is unclear, read the resource webperf://schema/{snippet} of that snippet.`,
96
+ ].join("\n")
97
+ ),
98
+ ],
99
+ })
100
+ );
101
+ }