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.
- package/README.md +96 -17
- package/package.json +7 -2
- package/snippets/CoreWebVitals/INP.js +134 -62
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +27 -4
- package/snippets/CoreWebVitals/LCP-Subparts.js +29 -2
- package/snippets/CoreWebVitals/WORKFLOWS.md +2 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +87 -36
- package/snippets/Interaction/Interactions.js +90 -38
- package/snippets/Interaction/Long-Animation-Frames.js +20 -1
- package/snippets/Loading/Cache-Strategy-Analysis.js +2 -1
- package/snippets/Loading/FCP.js +34 -7
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +51 -11
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +2 -1
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +2 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +26 -6
- package/snippets/Loading/Script-Loading.js +15 -3
- package/snippets/Loading/TTFB-Resources.js +2 -1
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +27 -5
- package/snippets/SCHEMA.md +76 -30
- package/snippets/_shared/helpers.js +72 -0
- package/src/bin.js +64 -24
- package/src/browser.js +22 -0
- package/src/crux.js +5 -3
- package/src/headers.js +35 -0
- package/src/mcp/documents.js +111 -0
- package/src/mcp/prompts.js +101 -0
- package/src/mcp/server.js +382 -0
- package/src/mcp/sessions.js +91 -0
- package/src/mcp/stdio.js +10 -0
- package/src/own-domains.js +16 -0
- package/src/reporters/human.js +8 -2
- package/src/reporters/markdown.js +3 -1
- package/src/runner.js +50 -12
- package/src/snippet-names.js +10 -1
- package/src/throttling.js +43 -0
- 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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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://
|
|
63
|
-
npx webperf-snippets https://
|
|
64
|
-
npx webperf-snippets https://
|
|
65
|
-
npx webperf-snippets https://
|
|
66
|
-
npx webperf-snippets https://
|
|
67
|
-
npx webperf-snippets https://
|
|
68
|
-
npx webperf-snippets https://
|
|
69
|
-
npx webperf-snippets https://
|
|
70
|
-
npx webperf-snippets https://
|
|
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
|
-
|
|
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
|
+
}
|