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 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.1.0" }
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.1.0",
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
+ }
@@ -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 results = await evaluateAroundInteractions(page, items, interactions);
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
+ }