webperf-snippets 1.1.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 +42 -2
- package/package.json +1 -1
- package/src/bin.js +39 -3
- package/src/headers.js +35 -0
- package/src/own-domains.js +16 -0
- package/src/reporters/human.js +3 -1
- package/src/reporters/markdown.js +3 -1
- package/src/runner.js +21 -5
- package/src/throttling.js +43 -0
package/README.md
CHANGED
|
@@ -44,6 +44,14 @@ Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings,
|
|
|
44
44
|
npx webperf-snippets https://example.com --workflow loading
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
The steps that tell first-party from third-party resources count as first party the root domain of the page and its subdomains (`cdn.example.com` for `example.com`). A site that serves its own assets from another domain, such as its CDN, lists it with `--own-domains`, comma-separated or repeated:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net,static.example.org
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each domain counts with its root domain, so `cdn.example.net` makes every host under `example.net` first party. The CLI writes the list in the `OWN_DOMAINS` constant of each snippet, the same one you edit at the top of the snippet in the console. An empty domain or one with spaces exits with code `2`.
|
|
54
|
+
|
|
47
55
|
Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
|
|
48
56
|
|
|
49
57
|
```bash
|
|
@@ -97,6 +105,16 @@ npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
|
97
105
|
|
|
98
106
|
The file holds live session cookies. Keep it out of version control (add it to `.gitignore`) and use a throwaway account when you run it in CI. The CLI exits with code `2` if the path does not exist.
|
|
99
107
|
|
|
108
|
+
Pages behind an access proxy that reads a token from every request, such as the service tokens of Cloudflare Access, take the token as request headers with `--header`, once per header:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx webperf-snippets https://review.example.com \
|
|
112
|
+
--header "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
|
|
113
|
+
--header "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The headers go with every request to the origin of the URL and never to another origin, so third parties do not receive the token and their cross-origin requests need no CORS preflight. Each request to that origin is paused in the browser to add them, which adds a few milliseconds to its timing; when the proxy accepts a session cookie, `--storage-state` measures without that cost. A header without a name or a colon exits with code `2`. Read the values from environment variables, as above, to keep them out of the shell history.
|
|
117
|
+
|
|
100
118
|
Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
|
|
101
119
|
|
|
102
120
|
```bash
|
|
@@ -134,16 +152,32 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
134
152
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
135
153
|
"navMs": 1240,
|
|
136
154
|
"results": [],
|
|
137
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
155
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.2.0" }
|
|
138
156
|
}
|
|
139
157
|
```
|
|
140
158
|
|
|
141
|
-
For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
|
|
159
|
+
For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the name. With `--cpu` or `--network`, `meta` carries the conditions in `throttling`. `results` is the same list the `--json` output contains, so it can include URLs and details of the measured page; send it only to an endpoint you trust.
|
|
142
160
|
|
|
143
161
|
- The URL must be `https`. `http` is accepted only for `localhost`, so a key is never sent in clear text.
|
|
144
162
|
- `--api-key` is sent as `Authorization: Bearer <key>`. Prefer the `PERF_REVIEWS_API_KEY` environment variable, because a flag ends up in shell history and CI logs. The key is never printed.
|
|
145
163
|
- The exit code never depends on the report. A failed POST (error status, unreachable endpoint, or no answer within 5 seconds) prints a warning and the exit code stays the one the budgets and snippets decide. If the endpoint answers `{ "ok": false, "regressions": [...] }`, the regressions are printed as a warning, again without changing the exit code.
|
|
146
164
|
|
|
165
|
+
Measure under the conditions of a slower device and network with `--cpu` and `--network`. Without them the CLI runs at the speed of the machine, which on a laptop or a CI runner is faster than most phones:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`--cpu <factor>` slows the main thread down by that factor. `--network` takes the presets of DevTools, with the same values:
|
|
172
|
+
|
|
173
|
+
| Preset | Download | Upload | Latency |
|
|
174
|
+
| --------- | ----------- | ----------- | --------- |
|
|
175
|
+
| `fast-4g` | 8.1 Mbps | 1.35 Mbps | 165 ms |
|
|
176
|
+
| `slow-4g` | 1.44 Mbps | 675 kbps | 562.5 ms |
|
|
177
|
+
| `3g` | 400 kbps | 400 kbps | 2000 ms |
|
|
178
|
+
|
|
179
|
+
Chrome adds the latency to the body of each response, not to its headers. The values read from `responseStart`, such as the TTFB and its sub-parts, do not include it; the download of each resource, FCP, LCP and the load event do. The report names the conditions under its header, and the JSON output carries them in `throttling`, such as `{ "cpu": 4, "network": "slow-4g" }`. A CPU factor below `1` or an unknown preset exits with code `2`.
|
|
180
|
+
|
|
147
181
|
CI gating:
|
|
148
182
|
|
|
149
183
|
```bash
|
|
@@ -160,8 +194,12 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
160
194
|
| `--markdown` | Output GitHub-renderable markdown (for PR comments). |
|
|
161
195
|
| `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
|
|
162
196
|
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
|
|
197
|
+
| `--cpu <factor>` | Slow the main thread down by this factor, such as `4`. No throttling by default. |
|
|
198
|
+
| `--network <preset>` | Throttle the network with a preset of DevTools: `fast-4g`, `slow-4g`, `3g`. No throttling by default. |
|
|
163
199
|
| `--interact-script <path>` | JSON file with interactions to run after the page loads (`scroll`, `click`, `hover`, `type`, `wait`). The Interaction snippets are installed first and read afterwards. |
|
|
164
200
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
201
|
+
| `--header "Name: value"` | Request header for the origin of the URL only, such as the token of an access proxy. Repeat it for more headers. |
|
|
202
|
+
| `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
|
|
165
203
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
166
204
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
167
205
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -212,6 +250,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
212
250
|
claude mcp add webperf -- npx -y webperf-snippets mcp
|
|
213
251
|
```
|
|
214
252
|
|
|
253
|
+
It needs the Chromium of Playwright (`npx playwright install chromium`); without it, the tools answer with that command. The [MCP server documentation](https://webperf-snippets.net/MCP) has the configuration of other clients (Cursor, VS Code, Claude Desktop, Gemini CLI). The server is listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.nucliweb/webperf-snippets`.
|
|
254
|
+
|
|
215
255
|
| Tool | What it does |
|
|
216
256
|
| --------------- | ---------------------------------------------------------------------------- |
|
|
217
257
|
| `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
package/src/bin.js
CHANGED
|
@@ -12,6 +12,9 @@ import { reportMarkdown } from "./reporters/markdown.js";
|
|
|
12
12
|
import { cliVersion, reportTo, validateReportUrl } from "./report-to.js";
|
|
13
13
|
import { fetchCrux, formFactorFor } from "./crux.js";
|
|
14
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";
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
const USAGE = `webperf-snippets <url> [options]
|
|
@@ -37,6 +40,8 @@ Options:
|
|
|
37
40
|
--markdown Output GitHub-renderable markdown (for PR comments)
|
|
38
41
|
--viewport <preset> Viewport preset: mobile (default), tablet, desktop
|
|
39
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
|
|
40
45
|
--budget-lcp <ms> Exit 1 if LCP exceeds this value
|
|
41
46
|
--budget-cls <score> Exit 1 if CLS exceeds this value
|
|
42
47
|
--interact-script <path> JSON file with interactions to run after the load; the
|
|
@@ -44,6 +49,10 @@ Options:
|
|
|
44
49
|
Actions: scroll, click, hover, type, wait
|
|
45
50
|
--storage-state <path> Playwright storage state (cookies + localStorage) to
|
|
46
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
|
|
47
56
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
48
57
|
Also read from the CRUX_API_KEY environment variable
|
|
49
58
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -63,8 +72,11 @@ Examples:
|
|
|
63
72
|
npx webperf-snippets https://example.com --snippet fonts
|
|
64
73
|
npx webperf-snippets https://example.com --snippet Compression-Audit
|
|
65
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
|
|
66
77
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
67
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"
|
|
68
80
|
`;
|
|
69
81
|
|
|
70
82
|
function fail(message, code = 2) {
|
|
@@ -119,8 +131,12 @@ async function main() {
|
|
|
119
131
|
"budget-lcp": { type: "string" },
|
|
120
132
|
"budget-cls": { type: "string" },
|
|
121
133
|
viewport: { type: "string" },
|
|
134
|
+
cpu: { type: "string" },
|
|
135
|
+
network: { type: "string" },
|
|
122
136
|
"interact-script": { type: "string" },
|
|
123
137
|
"storage-state": { type: "string" },
|
|
138
|
+
header: { type: "string", multiple: true },
|
|
139
|
+
"own-domains": { type: "string", multiple: true },
|
|
124
140
|
"crux-key": { type: "string" },
|
|
125
141
|
"report-to": { type: "string" },
|
|
126
142
|
"api-key": { type: "string" },
|
|
@@ -154,6 +170,13 @@ async function main() {
|
|
|
154
170
|
fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
|
|
155
171
|
}
|
|
156
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
|
+
|
|
157
180
|
const reportUrl = values["report-to"];
|
|
158
181
|
if (reportUrl) {
|
|
159
182
|
const problem = validateReportUrl(reportUrl);
|
|
@@ -166,15 +189,28 @@ async function main() {
|
|
|
166
189
|
fail(`Storage state file not found: ${storageState}`);
|
|
167
190
|
}
|
|
168
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
|
+
|
|
169
205
|
let payload;
|
|
170
206
|
if (values.snippet) {
|
|
171
207
|
const items = buildSnippetItem(values);
|
|
172
|
-
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 });
|
|
173
209
|
} else {
|
|
174
210
|
const workflowName = values.workflow ?? "core-web-vitals";
|
|
175
211
|
const workflow = WORKFLOWS[workflowName];
|
|
176
212
|
if (!workflow) fail(`Unknown workflow: ${workflowName}`);
|
|
177
|
-
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 });
|
|
178
214
|
}
|
|
179
215
|
|
|
180
216
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
@@ -202,7 +238,7 @@ async function main() {
|
|
|
202
238
|
timestamp: new Date().toISOString(),
|
|
203
239
|
navMs: payload.navMs,
|
|
204
240
|
results: payload.results,
|
|
205
|
-
meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
|
|
241
|
+
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), cli_version: cliVersion },
|
|
206
242
|
},
|
|
207
243
|
});
|
|
208
244
|
if (!outcome.ok) {
|
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,16 @@
|
|
|
1
|
+
// The domains of the site that differ from the root domain of the page, such as its own CDN,
|
|
2
|
+
// which the snippets that tell first from third parties count as first party. In the console
|
|
3
|
+
// they are written at the top of each snippet; the CLI writes them there before it runs it.
|
|
4
|
+
const DECLARATION = "const OWN_DOMAINS = [];";
|
|
5
|
+
|
|
6
|
+
export function withOwnDomains(source, ownDomains) {
|
|
7
|
+
if (!ownDomains?.length) return source;
|
|
8
|
+
return source.replace(DECLARATION, `const OWN_DOMAINS = ${JSON.stringify(ownDomains)};`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The values given to --own-domains, each one a domain or a comma-separated list, or null when
|
|
12
|
+
// one of the domains is empty or has spaces. The snippets accept URLs and any case.
|
|
13
|
+
export function parseOwnDomains(values) {
|
|
14
|
+
const domains = values.flatMap((value) => value.split(",").map((domain) => domain.trim()));
|
|
15
|
+
return domains.every((domain) => domain !== "" && !/\s/.test(domain)) ? domains : null;
|
|
16
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { fieldDataView } from "../crux.js";
|
|
3
|
+
import { describeThrottling } from "../throttling.js";
|
|
3
4
|
|
|
4
5
|
const RATING_ICON = {
|
|
5
6
|
good: "🟢",
|
|
@@ -202,10 +203,11 @@ function renderFieldData(crux, results) {
|
|
|
202
203
|
return lines;
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux }) {
|
|
206
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling }) {
|
|
206
207
|
const lines = [];
|
|
207
208
|
lines.push(styleText(["bold"], `WebPerf Snippets — ${url}`));
|
|
208
209
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
210
|
+
if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
|
|
209
211
|
lines.push("");
|
|
210
212
|
for (const r of results) lines.push(renderResult(r, verbose));
|
|
211
213
|
if (crux) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fieldDataView } from "../crux.js";
|
|
2
|
+
import { describeThrottling } from "../throttling.js";
|
|
2
3
|
|
|
3
4
|
const RATING_ICON = {
|
|
4
5
|
good: "✅",
|
|
@@ -71,10 +72,11 @@ function renderFieldData(crux, results) {
|
|
|
71
72
|
];
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
|
|
75
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling }) {
|
|
75
76
|
const lines = [];
|
|
76
77
|
lines.push(`## WebPerf Results — ${url}`);
|
|
77
78
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
79
|
+
if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
|
|
78
80
|
lines.push("");
|
|
79
81
|
|
|
80
82
|
const initial = results.filter((r) => !r.reason);
|
package/src/runner.js
CHANGED
|
@@ -3,6 +3,9 @@ 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 },
|
|
@@ -97,6 +100,10 @@ export async function runSnippets({
|
|
|
97
100
|
interactScript,
|
|
98
101
|
interactions = interactScript,
|
|
99
102
|
storageState,
|
|
103
|
+
headers,
|
|
104
|
+
cpu,
|
|
105
|
+
network,
|
|
106
|
+
ownDomains,
|
|
100
107
|
// Called at each of the three stages of a run, for callers that report progress (the MCP server)
|
|
101
108
|
onProgress = () => {},
|
|
102
109
|
}) {
|
|
@@ -105,6 +112,8 @@ export async function runSnippets({
|
|
|
105
112
|
try {
|
|
106
113
|
const context = await browser.newContext({ viewport, storageState });
|
|
107
114
|
const page = await context.newPage();
|
|
115
|
+
await sendHeadersToOrigin(context, page, url, headers);
|
|
116
|
+
await applyThrottling(context, page, { cpu, network });
|
|
108
117
|
const pageErrors = [];
|
|
109
118
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
110
119
|
const navStart = Date.now();
|
|
@@ -112,9 +121,10 @@ export async function runSnippets({
|
|
|
112
121
|
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
|
113
122
|
const navMs = Date.now() - navStart;
|
|
114
123
|
await onProgress(2, "Page loaded, running the snippets");
|
|
115
|
-
const
|
|
124
|
+
const prepared = items.map((item) => ({ ...item, source: withOwnDomains(item.source, ownDomains) }));
|
|
125
|
+
const results = await evaluateAroundInteractions(page, prepared, interactions);
|
|
116
126
|
await onProgress(3, "Snippets evaluated");
|
|
117
|
-
return { url, navMs, results, pageErrors };
|
|
127
|
+
return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
118
128
|
} finally {
|
|
119
129
|
await browser.close();
|
|
120
130
|
}
|
|
@@ -131,6 +141,10 @@ export async function runMeasurement({
|
|
|
131
141
|
interactScript,
|
|
132
142
|
interactions = interactScript,
|
|
133
143
|
storageState,
|
|
144
|
+
headers,
|
|
145
|
+
cpu,
|
|
146
|
+
network,
|
|
147
|
+
ownDomains,
|
|
134
148
|
onProgress = () => {},
|
|
135
149
|
}) {
|
|
136
150
|
await onProgress(1, "Opening Chromium");
|
|
@@ -138,6 +152,8 @@ export async function runMeasurement({
|
|
|
138
152
|
try {
|
|
139
153
|
const context = await browser.newContext({ viewport, storageState });
|
|
140
154
|
const page = await context.newPage();
|
|
155
|
+
await sendHeadersToOrigin(context, page, url, headers);
|
|
156
|
+
await applyThrottling(context, page, { cpu, network });
|
|
141
157
|
const pageErrors = [];
|
|
142
158
|
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
143
159
|
|
|
@@ -158,7 +174,7 @@ export async function runMeasurement({
|
|
|
158
174
|
.map((step) => ({
|
|
159
175
|
id: step.id,
|
|
160
176
|
path: step.path,
|
|
161
|
-
source: loadSnippet(step.path),
|
|
177
|
+
source: withOwnDomains(loadSnippet(step.path), ownDomains),
|
|
162
178
|
}));
|
|
163
179
|
const measured = await evaluateAroundInteractions(page, items, steps);
|
|
164
180
|
const initialResults = workflow.steps.map(
|
|
@@ -181,7 +197,7 @@ export async function runMeasurement({
|
|
|
181
197
|
const followItems = followUps.map((f) => ({
|
|
182
198
|
id: f.id,
|
|
183
199
|
path: f.path,
|
|
184
|
-
source: loadSnippet(f.path),
|
|
200
|
+
source: withOwnDomains(loadSnippet(f.path), ownDomains),
|
|
185
201
|
}));
|
|
186
202
|
const raw = await evaluateItems(page, followItems);
|
|
187
203
|
// A tracking snippet answers through its data function, which is read right away here
|
|
@@ -194,7 +210,7 @@ export async function runMeasurement({
|
|
|
194
210
|
}
|
|
195
211
|
|
|
196
212
|
await onProgress(3, "Workflow and follow-ups evaluated");
|
|
197
|
-
return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors };
|
|
213
|
+
return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
198
214
|
} finally {
|
|
199
215
|
await browser.close();
|
|
200
216
|
}
|
|
@@ -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
|
+
}
|