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/runner.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/snippet-names.js
CHANGED
|
@@ -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
|
+
};
|