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/runner.js CHANGED
@@ -1,8 +1,11 @@
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";
5
5
  import { nextSteps } from "./decision-tree.js";
6
+ import { sendHeadersToOrigin } from "./headers.js";
7
+ import { withOwnDomains } from "./own-domains.js";
8
+ import { applyThrottling, throttlingOf } from "./throttling.js";
6
9
 
7
10
  export const VIEWPORT_PRESETS = {
8
11
  mobile: { width: 375, height: 812 },
@@ -12,6 +15,12 @@ export const VIEWPORT_PRESETS = {
12
15
 
13
16
  const DEFAULT_NAV_TIMEOUT = 30000;
14
17
 
18
+ // On a loaded machine the browser can deliver the timing of a click later than the frames `settle`
19
+ // waits for. After a click or a key press, a tracking snippet that has no data yet is read again,
20
+ // every POLL_MS, until it has some or LATE_ENTRIES_MS have passed.
21
+ const LATE_ENTRIES_MS = 2000;
22
+ const POLL_MS = 100;
23
+
15
24
  // Snippets are IIFEs. Playwright evaluates a string as an expression, so we
16
25
  // trim trailing semicolons to keep the IIFE call as a single expression and
17
26
  // recover its return value in Node.
@@ -19,7 +28,7 @@ function toExpression(source) {
19
28
  return source.trim().replace(/;\s*$/, "");
20
29
  }
21
30
 
22
- async function evaluateItems(page, items) {
31
+ export async function evaluateItems(page, items) {
23
32
  const results = [];
24
33
  for (const item of items) {
25
34
  try {
@@ -56,17 +65,24 @@ async function evaluateAroundInteractions(page, items, interactions) {
56
65
  page,
57
66
  items.filter((item) => !tracking.includes(item))
58
67
  );
68
+ const hasInput = loadInteractionSteps(interactions).some(isInputStep);
59
69
  const collected = [];
60
- for (const result of installed) collected.push(await collectTrackingData(page, result));
70
+ for (const result of installed) collected.push(await collectTrackingData(page, result, hasInput));
61
71
 
62
72
  const byId = new Map([...collected, ...others].map((r) => [r.id, r]));
63
73
  return items.map((item) => byId.get(item.id));
64
74
  }
65
75
 
66
- async function collectTrackingData(page, result) {
76
+ export async function collectTrackingData(page, result, hasInput) {
67
77
  if (result.status !== "tracking" || !result.getDataFn) return result;
68
78
  try {
69
- const data = await page.evaluate(`(async () => await ${result.getDataFn}())()`);
79
+ const read = () => page.evaluate(`(async () => await ${result.getDataFn}())()`);
80
+ let data = await read();
81
+ const deadline = Date.now() + LATE_ENTRIES_MS;
82
+ while (hasInput && data?.status === "error" && Date.now() < deadline) {
83
+ await page.waitForTimeout(POLL_MS);
84
+ data = await read();
85
+ }
70
86
  if (data && typeof data === "object") return { id: result.id, ...data };
71
87
  return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
72
88
  } catch (err) {
@@ -84,19 +100,31 @@ export async function runSnippets({
84
100
  interactScript,
85
101
  interactions = interactScript,
86
102
  storageState,
103
+ headers,
104
+ cpu,
105
+ network,
106
+ ownDomains,
107
+ // Called at each of the three stages of a run, for callers that report progress (the MCP server)
108
+ onProgress = () => {},
87
109
  }) {
88
- const browser = await chromium.launch({ headless });
110
+ await onProgress(1, "Opening Chromium");
111
+ const browser = await launchChromium({ headless });
89
112
  try {
90
113
  const context = await browser.newContext({ viewport, storageState });
91
114
  const page = await context.newPage();
115
+ await sendHeadersToOrigin(context, page, url, headers);
116
+ await applyThrottling(context, page, { cpu, network });
92
117
  const pageErrors = [];
93
118
  page.on("pageerror", (err) => pageErrors.push(err.message));
94
119
  const navStart = Date.now();
95
120
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
96
121
  if (waitMs > 0) await page.waitForTimeout(waitMs);
97
122
  const navMs = Date.now() - navStart;
98
- const results = await evaluateAroundInteractions(page, items, interactions);
99
- return { url, navMs, results, pageErrors };
123
+ await onProgress(2, "Page loaded, running the snippets");
124
+ const prepared = items.map((item) => ({ ...item, source: withOwnDomains(item.source, ownDomains) }));
125
+ const results = await evaluateAroundInteractions(page, prepared, interactions);
126
+ await onProgress(3, "Snippets evaluated");
127
+ return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
100
128
  } finally {
101
129
  await browser.close();
102
130
  }
@@ -113,11 +141,19 @@ export async function runMeasurement({
113
141
  interactScript,
114
142
  interactions = interactScript,
115
143
  storageState,
144
+ headers,
145
+ cpu,
146
+ network,
147
+ ownDomains,
148
+ onProgress = () => {},
116
149
  }) {
117
- const browser = await chromium.launch({ headless });
150
+ await onProgress(1, "Opening Chromium");
151
+ const browser = await launchChromium({ headless });
118
152
  try {
119
153
  const context = await browser.newContext({ viewport, storageState });
120
154
  const page = await context.newPage();
155
+ await sendHeadersToOrigin(context, page, url, headers);
156
+ await applyThrottling(context, page, { cpu, network });
121
157
  const pageErrors = [];
122
158
  page.on("pageerror", (err) => pageErrors.push(err.message));
123
159
 
@@ -125,6 +161,7 @@ export async function runMeasurement({
125
161
  await page.goto(url, { waitUntil: "load", timeout: navTimeout });
126
162
  if (waitMs > 0) await page.waitForTimeout(waitMs);
127
163
  const navMs = Date.now() - navStart;
164
+ await onProgress(2, "Page loaded, running the workflow");
128
165
 
129
166
  // The steps of the user's script, or the workflow's own. A step that needs a click or a key press
130
167
  // is skipped when the interactions have none, so it does not fail for lack of data.
@@ -137,7 +174,7 @@ export async function runMeasurement({
137
174
  .map((step) => ({
138
175
  id: step.id,
139
176
  path: step.path,
140
- source: loadSnippet(step.path),
177
+ source: withOwnDomains(loadSnippet(step.path), ownDomains),
141
178
  }));
142
179
  const measured = await evaluateAroundInteractions(page, items, steps);
143
180
  const initialResults = workflow.steps.map(
@@ -160,7 +197,7 @@ export async function runMeasurement({
160
197
  const followItems = followUps.map((f) => ({
161
198
  id: f.id,
162
199
  path: f.path,
163
- source: loadSnippet(f.path),
200
+ source: withOwnDomains(loadSnippet(f.path), ownDomains),
164
201
  }));
165
202
  const raw = await evaluateItems(page, followItems);
166
203
  // A tracking snippet answers through its data function, which is read right away here
@@ -172,7 +209,8 @@ export async function runMeasurement({
172
209
  });
173
210
  }
174
211
 
175
- return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors };
212
+ await onProgress(3, "Workflow and follow-ups evaluated");
213
+ return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
176
214
  } finally {
177
215
  await browser.close();
178
216
  }
@@ -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,43 @@
1
+ // CPU and network throttling through CDP, as the Performance panel of DevTools applies it. The
2
+ // network presets copy the values of DevTools, so a measurement of the CLI compares with one made
3
+ // there. Throughputs are in bytes per second and latencies in milliseconds.
4
+ export const NETWORK_PRESETS = {
5
+ "fast-4g": { title: "Fast 4G", download: (9 * 1000 * 1000) / 8 * 0.9, upload: (1.5 * 1000 * 1000) / 8 * 0.9, latency: 60 * 2.75 },
6
+ "slow-4g": { title: "Slow 4G", download: (1.6 * 1000 * 1000) / 8 * 0.9, upload: (750 * 1000) / 8 * 0.9, latency: 150 * 3.75 },
7
+ "3g": { title: "3G", download: (500 * 1000) / 8 * 0.8, upload: (500 * 1000) / 8 * 0.8, latency: 400 * 5 },
8
+ };
9
+
10
+ // The factor given to --cpu, or null when it is not a number of at least 1
11
+ export function parseCpuFactor(raw) {
12
+ const factor = Number(raw);
13
+ return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
14
+ }
15
+
16
+ // The conditions of a run, as the payload reports them, or undefined without throttling
17
+ export function throttlingOf({ cpu, network }) {
18
+ if (cpu == null && network == null) return undefined;
19
+ return { ...(cpu != null ? { cpu } : {}), ...(network != null ? { network } : {}) };
20
+ }
21
+
22
+ // "CPU 4x, Slow 4G", for the header of the reports
23
+ export function describeThrottling({ cpu, network }) {
24
+ return [cpu != null ? `CPU ${cpu}x` : null, network != null ? (NETWORK_PRESETS[network]?.title ?? network) : null]
25
+ .filter(Boolean)
26
+ .join(", ");
27
+ }
28
+
29
+ export async function applyThrottling(context, page, { cpu, network }) {
30
+ if (cpu == null && network == null) return;
31
+ const cdp = await context.newCDPSession(page);
32
+ if (cpu != null) await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
33
+ if (network != null) {
34
+ const { download, upload, latency } = NETWORK_PRESETS[network];
35
+ await cdp.send("Network.enable");
36
+ await cdp.send("Network.emulateNetworkConditions", {
37
+ offline: false,
38
+ latency,
39
+ downloadThroughput: download,
40
+ uploadThroughput: upload,
41
+ });
42
+ }
43
+ }
@@ -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
+ };