webperf-snippets 1.1.0 → 1.3.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 +103 -2
- package/package.json +1 -1
- package/src/bin.js +204 -20
- package/src/blocking.js +12 -0
- package/src/crux.js +6 -6
- package/src/headers.js +35 -0
- package/src/matrix.js +227 -0
- package/src/own-domains.js +16 -0
- package/src/ratings.js +16 -0
- package/src/reporters/human.js +53 -2
- package/src/reporters/markdown.js +46 -2
- package/src/reporters/matrix-view.js +36 -0
- package/src/runner.js +21 -5
- package/src/runs.js +65 -0
- package/src/throttling.js +80 -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,90 @@ 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.3.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`, and with `--runs`, the summary of the runs in `runs`. `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
|
+
|
|
181
|
+
One page load is one sample, and lab values vary from one load to the next. `--runs <n>` measures the page n times, from 1 to 9, one after the other, and reports the median run, as WebPageTest does: every result comes from that run, so they agree with each other (the sub-parts of the LCP add up to its LCP):
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB and the navigation time that it measures, and `--median-by <metric>` picks another one (`LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`). With an even count it takes the lower of the two middle runs, so the report is always a run that happened. The report adds the median, min and max of each metric across the runs, and the JSON output carries them in `runs`:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
"runs": {
|
|
191
|
+
"count": 5,
|
|
192
|
+
"failed": 0,
|
|
193
|
+
"medianRun": 3,
|
|
194
|
+
"medianBy": "LCP",
|
|
195
|
+
"summary": {
|
|
196
|
+
"LCP": { "median": 2140, "min": 1980, "max": 2610, "values": [2610, 2010, 2140, 1980, 2290], "unit": "ms" }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`medianRun` counts from 1, and `values` keeps the order of the runs. The budgets compare the median of each metric, not one run. A run that fails, such as a navigation that does not finish, counts in `failed` and is left out of the median; when every run fails, the command exits with code `1`. Each run is reported on stderr as it starts, so stdout keeps only the report. An invalid count or metric exits with code `2`.
|
|
202
|
+
|
|
203
|
+
Measure several pages, on several devices and in several variants, in one command with `--matrix <file>`. The file lists them, and the CLI measures every URL on every device and in every variant, each combination `runs` times, one load after the other:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"urls": {
|
|
208
|
+
"home": "https://example.com/",
|
|
209
|
+
"article": "https://example.com/news/some-article"
|
|
210
|
+
},
|
|
211
|
+
"devices": {
|
|
212
|
+
"mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" },
|
|
213
|
+
"high-end": { "viewport": "mobile", "cpu": 1, "network": { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 } },
|
|
214
|
+
"desktop": { "viewport": "desktop" }
|
|
215
|
+
},
|
|
216
|
+
"variants": {
|
|
217
|
+
"no-consent": {},
|
|
218
|
+
"consent": { "storageState": "states/consent.json" }
|
|
219
|
+
},
|
|
220
|
+
"runs": 3
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npx webperf-snippets --matrix matrix.json --budget-lcp 2500
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- `urls`: names and URLs. An array of URLs also works, and names each one by its path and query (`/`, `/news/`), with the host when the URLs have more than one.
|
|
229
|
+
- `devices`: the `viewport` preset, the `cpu` factor and the `network`, a preset (`fast-4g`, `slow-4g`, `3g`) or a network of its own in kilobits per second and milliseconds, for the ones DevTools has no preset for, such as 5G. An array of viewport presets (`["mobile", "desktop"]`) is a shortcut for devices without throttling. Without `devices`, the matrix measures a mid-range phone, with CPU 4x and Slow 4G, as Lighthouse does.
|
|
230
|
+
- `variants`: the conditions of each variant, `storageState` (relative to the file), `headers` and `ownDomains`. Without `variants`, the combinations are the pages on the devices: the report has no Variant column, and the `id` of a combination is its page and device (`/news/ · mobile`).
|
|
231
|
+
- `runs` (1 to 9) and `medianBy`, which `--runs` and `--median-by` replace.
|
|
232
|
+
|
|
233
|
+
The options of the command line apply to every combination: `--workflow`, `--snippet`, `--wait`, `--interact-script`, `--header`, `--own-domains`, `--storage-state`, `--cpu`, `--network` and the budgets. The file replaces `cpu`, `network` and `storageState` where it sets them, and its `headers` and `ownDomains` add to the ones of the command line, the variant winning on the same header. A matrix file is meant to be versioned, so pass secrets, such as the token of an access proxy, with `--header` and an environment variable; a header of the file whose name looks like a secret raises a warning. A key the CLI does not know is an error, so a typo does not measure without its condition.
|
|
234
|
+
|
|
235
|
+
The report is a table with one row per combination and the rated median of each metric, followed by the errors that decide the exit code; `--verbose` adds the report of the median run of each combination. `--markdown` gives the same table and folds the report of each combination into a `<details>` block, to keep a pull request comment readable. With `--json`, it is `{ "matrix", "runs", "combinations" }`, each combination being the report of its median run with its `id`, `name`, `device`, `variant` (when the file defines variants) and `runs`. The budgets compare the median of each combination. The command exits with code `1` when a combination exceeds a budget, has an error or fails every run, and with code `2` for an invalid file, before any page load, naming the place of the problem in the JSON. `--viewport` does not apply, since each device sets its own.
|
|
236
|
+
|
|
237
|
+
With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
|
|
238
|
+
|
|
147
239
|
CI gating:
|
|
148
240
|
|
|
149
241
|
```bash
|
|
@@ -160,8 +252,15 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
160
252
|
| `--markdown` | Output GitHub-renderable markdown (for PR comments). |
|
|
161
253
|
| `--viewport <preset>` | Viewport preset: `mobile` (default), `tablet`, `desktop`. |
|
|
162
254
|
| `--wait <ms>` | Post-load wait before evaluating snippets. Default: `3000`. |
|
|
255
|
+
| `--cpu <factor>` | Slow the main thread down by this factor, such as `4`. No throttling by default. |
|
|
256
|
+
| `--network <preset>` | Throttle the network with a preset of DevTools: `fast-4g`, `slow-4g`, `3g`. No throttling by default. |
|
|
163
257
|
| `--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
258
|
| `--storage-state <path>` | Playwright storage state (cookies and localStorage) for pages that require authentication. |
|
|
259
|
+
| `--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. |
|
|
260
|
+
| `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
|
|
261
|
+
| `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
|
|
262
|
+
| `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB and `navMs` measured. |
|
|
263
|
+
| `--matrix <file>` | Measure every URL of a JSON file on every device and in every variant it lists, and report a table of the medians. |
|
|
165
264
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
166
265
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
167
266
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
@@ -212,6 +311,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
|
|
|
212
311
|
claude mcp add webperf -- npx -y webperf-snippets mcp
|
|
213
312
|
```
|
|
214
313
|
|
|
314
|
+
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`.
|
|
315
|
+
|
|
215
316
|
| Tool | What it does |
|
|
216
317
|
| --------------- | ---------------------------------------------------------------------------- |
|
|
217
318
|
| `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.3.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
|
@@ -6,15 +6,22 @@ import { resolveSnippetName } from "./snippet-names.js";
|
|
|
6
6
|
import { runSnippets, runMeasurement, VIEWPORT_PRESETS } from "./runner.js";
|
|
7
7
|
import { WORKFLOWS } from "./workflows/index.js";
|
|
8
8
|
import { RULES } from "./decision-tree.js";
|
|
9
|
-
import { reportHuman } from "./reporters/human.js";
|
|
9
|
+
import { reportHuman, reportMatrixHuman } from "./reporters/human.js";
|
|
10
10
|
import { reportJson } from "./reporters/json.js";
|
|
11
|
-
import { reportMarkdown } from "./reporters/markdown.js";
|
|
11
|
+
import { reportMarkdown, reportMatrixMarkdown } 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";
|
|
18
|
+
import { MEDIAN_METRICS, summarizeRuns } from "./runs.js";
|
|
19
|
+
import { loadMatrix } from "./matrix.js";
|
|
20
|
+
import { blockingErrors } from "./blocking.js";
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
const USAGE = `webperf-snippets <url> [options]
|
|
24
|
+
webperf-snippets --matrix <file> [options]
|
|
18
25
|
webperf-snippets mcp
|
|
19
26
|
|
|
20
27
|
Run curated WebPerf Snippets headlessly via Playwright.
|
|
@@ -37,6 +44,8 @@ Options:
|
|
|
37
44
|
--markdown Output GitHub-renderable markdown (for PR comments)
|
|
38
45
|
--viewport <preset> Viewport preset: mobile (default), tablet, desktop
|
|
39
46
|
--wait <ms> Post-load wait before evaluating (default: 3000)
|
|
47
|
+
--cpu <factor> Slow the CPU down by this factor, such as 4 (no throttling by default)
|
|
48
|
+
--network <preset> Throttle the network as DevTools does: fast-4g, slow-4g, 3g
|
|
40
49
|
--budget-lcp <ms> Exit 1 if LCP exceeds this value
|
|
41
50
|
--budget-cls <score> Exit 1 if CLS exceeds this value
|
|
42
51
|
--interact-script <path> JSON file with interactions to run after the load; the
|
|
@@ -44,6 +53,16 @@ Options:
|
|
|
44
53
|
Actions: scroll, click, hover, type, wait
|
|
45
54
|
--storage-state <path> Playwright storage state (cookies + localStorage) to
|
|
46
55
|
measure pages that require authentication
|
|
56
|
+
--header "Name: value" Request header for the origin of the URL only, such as
|
|
57
|
+
the token of an access proxy. Repeat it for more headers
|
|
58
|
+
--own-domains <list> Domains of the site to count as first party, such as its
|
|
59
|
+
own CDN, comma-separated or repeated
|
|
60
|
+
--runs <n> Measure the page n times (1 to 9), one after the other, and
|
|
61
|
+
report the median run
|
|
62
|
+
--matrix <file> Measure every URL of a JSON file on every device and in every
|
|
63
|
+
variant it lists, and report a table of the medians
|
|
64
|
+
--median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
|
|
65
|
+
navMs (default: the first of LCP, FCP, TTFB, navMs measured)
|
|
47
66
|
--crux-key <key> Add CrUX field data (p75) next to the measured values.
|
|
48
67
|
Also read from the CRUX_API_KEY environment variable
|
|
49
68
|
--report-to <url> POST the results to this https URL after the run
|
|
@@ -63,8 +82,13 @@ Examples:
|
|
|
63
82
|
npx webperf-snippets https://example.com --snippet fonts
|
|
64
83
|
npx webperf-snippets https://example.com --snippet Compression-Audit
|
|
65
84
|
npx webperf-snippets https://example.com --budget-lcp 2500
|
|
85
|
+
npx webperf-snippets https://example.com --cpu 4 --network slow-4g
|
|
86
|
+
npx webperf-snippets https://example.com --workflow loading --own-domains cdn.example.net
|
|
87
|
+
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
88
|
+
npx webperf-snippets --matrix matrix.json --runs 3 --budget-lcp 2500
|
|
66
89
|
npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
|
|
67
90
|
npx webperf-snippets https://example.com/dashboard --storage-state auth.json
|
|
91
|
+
npx webperf-snippets https://review.example.com --header "CF-Access-Client-Id: $CF_ID" --header "CF-Access-Client-Secret: $CF_SECRET"
|
|
68
92
|
`;
|
|
69
93
|
|
|
70
94
|
function fail(message, code = 2) {
|
|
@@ -82,11 +106,25 @@ function buildSnippetItem(values) {
|
|
|
82
106
|
return [{ id: values.snippet, path, source: loadSnippet(path) }];
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
|
|
109
|
+
// The count given to --runs, or null when it is not a whole number from 1 to 9
|
|
110
|
+
function parseRunCount(raw) {
|
|
111
|
+
return /^[1-9]$/.test(raw.trim()) ? Number(raw) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkBudgets({ results, runs }, values) {
|
|
86
115
|
const violations = [];
|
|
87
116
|
const lcpBudget = values["budget-lcp"] ? Number(values["budget-lcp"]) : null;
|
|
88
117
|
const clsBudget = values["budget-cls"] ? Number(values["budget-cls"]) : null;
|
|
89
118
|
|
|
119
|
+
// With several runs the budgets compare the median of each metric, not one sample
|
|
120
|
+
if (runs) {
|
|
121
|
+
const lcp = runs.summary.LCP?.median;
|
|
122
|
+
const cls = runs.summary.CLS?.median;
|
|
123
|
+
if (lcp != null && lcpBudget != null && lcp > lcpBudget) violations.push(`LCP ${lcp}ms exceeds budget ${lcpBudget}ms`);
|
|
124
|
+
if (cls != null && clsBudget != null && cls > clsBudget) violations.push(`CLS ${cls} exceeds budget ${clsBudget}`);
|
|
125
|
+
return violations;
|
|
126
|
+
}
|
|
127
|
+
|
|
90
128
|
for (const r of results) {
|
|
91
129
|
if (r.status !== "ok") continue;
|
|
92
130
|
if (r.metric === "LCP" && lcpBudget != null && r.value > lcpBudget) {
|
|
@@ -119,8 +157,15 @@ async function main() {
|
|
|
119
157
|
"budget-lcp": { type: "string" },
|
|
120
158
|
"budget-cls": { type: "string" },
|
|
121
159
|
viewport: { type: "string" },
|
|
160
|
+
cpu: { type: "string" },
|
|
161
|
+
network: { type: "string" },
|
|
122
162
|
"interact-script": { type: "string" },
|
|
123
163
|
"storage-state": { type: "string" },
|
|
164
|
+
header: { type: "string", multiple: true },
|
|
165
|
+
"own-domains": { type: "string", multiple: true },
|
|
166
|
+
matrix: { type: "string" },
|
|
167
|
+
runs: { type: "string" },
|
|
168
|
+
"median-by": { type: "string" },
|
|
124
169
|
"crux-key": { type: "string" },
|
|
125
170
|
"report-to": { type: "string" },
|
|
126
171
|
"api-key": { type: "string" },
|
|
@@ -142,10 +187,13 @@ async function main() {
|
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
const url = positionals[0];
|
|
145
|
-
|
|
190
|
+
const matrixPath = values.matrix;
|
|
191
|
+
if (url && matrixPath) fail("Use a URL or --matrix, not both");
|
|
192
|
+
if (!url && !matrixPath) {
|
|
146
193
|
process.stdout.write(USAGE);
|
|
147
194
|
process.exit(2);
|
|
148
195
|
}
|
|
196
|
+
if (matrixPath && values.viewport) fail("--viewport does not apply to --matrix: set the viewport of each device in the file");
|
|
149
197
|
|
|
150
198
|
const waitMs = values.wait ? Number(values.wait) : 3000;
|
|
151
199
|
const viewportName = values.viewport ?? "mobile";
|
|
@@ -154,6 +202,13 @@ async function main() {
|
|
|
154
202
|
fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
|
|
155
203
|
}
|
|
156
204
|
|
|
205
|
+
const cpu = values.cpu != null ? parseCpuFactor(values.cpu) : undefined;
|
|
206
|
+
if (cpu === null) fail(`Invalid CPU factor "${values.cpu}". Use a number of at least 1, such as 4`);
|
|
207
|
+
const network = values.network;
|
|
208
|
+
if (network != null && !NETWORK_PRESETS[network]) {
|
|
209
|
+
fail(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
157
212
|
const reportUrl = values["report-to"];
|
|
158
213
|
if (reportUrl) {
|
|
159
214
|
const problem = validateReportUrl(reportUrl);
|
|
@@ -166,15 +221,55 @@ async function main() {
|
|
|
166
221
|
fail(`Storage state file not found: ${storageState}`);
|
|
167
222
|
}
|
|
168
223
|
|
|
224
|
+
const headers = {};
|
|
225
|
+
for (const raw of values.header ?? []) {
|
|
226
|
+
const header = parseHeader(raw);
|
|
227
|
+
if (!header) fail(`Invalid header "${raw}". Use --header "Name: value"`);
|
|
228
|
+
headers[header[0]] = header[1];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const invalidOwnDomains = (values["own-domains"] ?? []).find((raw) => !parseOwnDomains([raw]));
|
|
232
|
+
if (invalidOwnDomains != null) {
|
|
233
|
+
fail(`Invalid --own-domains "${invalidOwnDomains}". Use domains separated by commas, such as cdn.example.net,static.example.org`);
|
|
234
|
+
}
|
|
235
|
+
const ownDomains = values["own-domains"] && parseOwnDomains(values["own-domains"]);
|
|
236
|
+
|
|
237
|
+
const runCount = values.runs != null ? parseRunCount(values.runs) : undefined;
|
|
238
|
+
if (runCount === null) fail(`Invalid --runs "${values.runs}". Use a whole number from 1 to 9`);
|
|
239
|
+
const medianBy = values["median-by"];
|
|
240
|
+
if (medianBy != null && !MEDIAN_METRICS.includes(medianBy)) {
|
|
241
|
+
fail(`Unknown --median-by "${medianBy}". Choose from: ${MEDIAN_METRICS.join(", ")}`);
|
|
242
|
+
}
|
|
243
|
+
if (medianBy != null && runCount == null && !matrixPath) fail("--median-by needs --runs");
|
|
244
|
+
|
|
245
|
+
// What each page load runs, a snippet or a workflow, under the conditions of the load
|
|
246
|
+
const items = values.snippet ? buildSnippetItem(values) : null;
|
|
247
|
+
const workflow = values.snippet ? null : WORKFLOWS[values.workflow ?? "core-web-vitals"];
|
|
248
|
+
if (!values.snippet && !workflow) fail(`Unknown workflow: ${values.workflow}`);
|
|
249
|
+
const measureWith = (conditions) => {
|
|
250
|
+
const options = { waitMs, headless: !values.headed, interactScript, ...conditions };
|
|
251
|
+
return items ? runSnippets({ ...options, items }) : runMeasurement({ ...options, workflow, rules: RULES });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (matrixPath) {
|
|
255
|
+
let matrix;
|
|
256
|
+
try {
|
|
257
|
+
matrix = loadMatrix(matrixPath, { cpu, network, storageState, headers, ownDomains, runs: runCount, medianBy });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
fail(err.message);
|
|
260
|
+
}
|
|
261
|
+
await runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const conditions = { url, viewport, storageState, headers, cpu, network, ownDomains };
|
|
169
266
|
let payload;
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript, storageState });
|
|
267
|
+
if (runCount == null) {
|
|
268
|
+
payload = await measureWith(conditions);
|
|
173
269
|
} else {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript, storageState });
|
|
270
|
+
const median = await measureRuns(() => measureWith(conditions), runCount, medianBy, (i) => `[${i}/${runCount}] ${url} · run ${i}/${runCount}`);
|
|
271
|
+
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
272
|
+
payload = { ...median.payload, runs: median.runs };
|
|
178
273
|
}
|
|
179
274
|
|
|
180
275
|
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
@@ -202,7 +297,7 @@ async function main() {
|
|
|
202
297
|
timestamp: new Date().toISOString(),
|
|
203
298
|
navMs: payload.navMs,
|
|
204
299
|
results: payload.results,
|
|
205
|
-
meta: { viewport: viewportName, waitMs, cli_version: cliVersion },
|
|
300
|
+
meta: { viewport: viewportName, waitMs, ...(payload.throttling ? { throttling: payload.throttling } : {}), ...(payload.runs ? { runs: payload.runs } : {}), cli_version: cliVersion },
|
|
206
301
|
},
|
|
207
302
|
});
|
|
208
303
|
if (!outcome.ok) {
|
|
@@ -213,21 +308,110 @@ async function main() {
|
|
|
213
308
|
}
|
|
214
309
|
|
|
215
310
|
// Exit codes.
|
|
216
|
-
const violations = checkBudgets(payload
|
|
311
|
+
const violations = checkBudgets(payload, values);
|
|
217
312
|
if (violations.length > 0) {
|
|
218
313
|
if (!values.json) {
|
|
219
314
|
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
220
315
|
}
|
|
221
316
|
process.exit(1);
|
|
222
317
|
}
|
|
318
|
+
process.exit(blockingErrors(payload.results).length > 0 ? 1 : 0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Measures `count` runs one after the other, never in parallel: they would compete for the CPU and
|
|
322
|
+
// skew the timings. Each run is reported on stderr as it starts, so stdout keeps only the report. A
|
|
323
|
+
// run that fails is kept as { error } and left out of the median.
|
|
324
|
+
async function measureRuns(measure, count, medianBy, progress) {
|
|
325
|
+
const runs = [];
|
|
326
|
+
for (let i = 1; i <= count; i++) {
|
|
327
|
+
process.stderr.write(`${progress(i)}\n`);
|
|
328
|
+
try {
|
|
329
|
+
runs.push(await measure());
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err instanceof MissingBrowserError) throw err;
|
|
332
|
+
runs.push({ error: err.message });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return summarizeRuns(runs, { medianBy });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Every combination of the matrix, in order, each one `matrix.runs` times. Each combination reports
|
|
339
|
+
// its median run, and the exit code follows the same rules as one page, for every combination.
|
|
340
|
+
async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, reportUrl }) {
|
|
341
|
+
for (const warning of matrix.warnings) process.stderr.write(`Warning: ${warning}\n`);
|
|
342
|
+
const viewportOf = new Map(matrix.combinations.map((c) => [c.id, c.viewport]));
|
|
343
|
+
|
|
344
|
+
const total = matrix.combinations.length * matrix.runs;
|
|
345
|
+
const width = String(total).length;
|
|
346
|
+
let started = 0;
|
|
347
|
+
const combinations = [];
|
|
348
|
+
for (const c of matrix.combinations) {
|
|
349
|
+
const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
|
|
350
|
+
const median = await measureRuns(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
|
|
351
|
+
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
352
|
+
combinations.push(
|
|
353
|
+
median.payload
|
|
354
|
+
? { ...identity, ...median.payload, runs: median.runs }
|
|
355
|
+
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs } }
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The field data of a URL depends on the form factor, not on the variant: one query for each pair
|
|
360
|
+
const cruxKey = values["crux-key"] || process.env.CRUX_API_KEY;
|
|
361
|
+
if (cruxKey) {
|
|
362
|
+
const queries = new Map();
|
|
363
|
+
for (const c of combinations.filter((c) => !c.error)) {
|
|
364
|
+
const formFactor = formFactorFor(viewportOf.get(c.id));
|
|
365
|
+
const key = `${c.url} ${formFactor}`;
|
|
366
|
+
if (!queries.has(key)) queries.set(key, fetchCrux({ url: c.url, apiKey: cruxKey, formFactor }));
|
|
367
|
+
const crux = await queries.get(key);
|
|
368
|
+
if (crux) c.crux = crux;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
223
371
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
372
|
+
const report = { matrix: matrixPath, runs: matrix.runs, combinations, verbose: values.verbose };
|
|
373
|
+
const output = values.markdown ? reportMatrixMarkdown(report) : values.json ? reportJson({ matrix: matrixPath, runs: matrix.runs, combinations }) : reportMatrixHuman(report);
|
|
374
|
+
process.stdout.write(output + "\n");
|
|
375
|
+
|
|
376
|
+
// One report for each combination, with the body of one page and the device, variant and runs of
|
|
377
|
+
// the combination. The headers are conditions of the load, never part of the body.
|
|
378
|
+
if (reportUrl) {
|
|
379
|
+
for (const c of combinations.filter((c) => !c.error)) {
|
|
380
|
+
const outcome = await reportTo({
|
|
381
|
+
url: reportUrl,
|
|
382
|
+
apiKey: values["api-key"] || process.env.PERF_REVIEWS_API_KEY,
|
|
383
|
+
body: {
|
|
384
|
+
url: c.url,
|
|
385
|
+
workflow: values.snippet ? null : (values.workflow ?? "core-web-vitals"),
|
|
386
|
+
...(values.snippet ? { snippet: values.snippet } : {}),
|
|
387
|
+
timestamp: new Date().toISOString(),
|
|
388
|
+
navMs: c.navMs,
|
|
389
|
+
results: c.results,
|
|
390
|
+
meta: {
|
|
391
|
+
viewport: viewportOf.get(c.id),
|
|
392
|
+
device: c.device,
|
|
393
|
+
...(c.variant ? { variant: c.variant } : {}),
|
|
394
|
+
waitMs,
|
|
395
|
+
...(c.throttling ? { throttling: c.throttling } : {}),
|
|
396
|
+
runs: c.runs,
|
|
397
|
+
cli_version: cliVersion,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
if (!outcome.ok) {
|
|
402
|
+
process.stderr.write(`Warning: could not report ${c.id} to ${new URL(reportUrl).origin} (${outcome.warning})\n`);
|
|
403
|
+
} else if (outcome.regressions.length > 0) {
|
|
404
|
+
process.stderr.write(`Regressions detected in ${c.id}:\n${outcome.regressions.map((r) => ` - ${typeof r === "string" ? r : JSON.stringify(r)}`).join("\n")}\n`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const violations = combinations.filter((c) => !c.error).flatMap((c) => checkBudgets(c, values).map((v) => `${v} (${c.id})`));
|
|
410
|
+
if (!values.json) {
|
|
411
|
+
for (const v of violations) process.stderr.write(`Budget violation: ${v}\n`);
|
|
412
|
+
}
|
|
413
|
+
const failed = combinations.some((c) => c.error || blockingErrors(c.results).length > 0);
|
|
414
|
+
process.exit(violations.length > 0 || failed ? 1 : 0);
|
|
231
415
|
}
|
|
232
416
|
|
|
233
417
|
main().catch((err) => {
|
package/src/blocking.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The results that make the command exit with 1: a snippet that failed, and an issue of severity
|
|
2
|
+
// error in a step the workflow runs itself. A follow-up (a result with a reason) only informs: its
|
|
3
|
+
// error issues show in the report and leave the exit code to the budgets and to the steps.
|
|
4
|
+
export function blockingErrors(results) {
|
|
5
|
+
const errors = [];
|
|
6
|
+
for (const r of results) {
|
|
7
|
+
if (r.status === "error") errors.push({ id: r.id, message: r.error });
|
|
8
|
+
if (r.reason || !Array.isArray(r.issues)) continue;
|
|
9
|
+
for (const issue of r.issues) if (issue.severity === "error") errors.push({ id: r.id, message: issue.message });
|
|
10
|
+
}
|
|
11
|
+
return errors;
|
|
12
|
+
}
|
package/src/crux.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import { rate } from "./ratings.js";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord";
|
|
2
4
|
const CRUX_TIMEOUT_MS = 10000;
|
|
3
5
|
|
|
4
|
-
// Good / needs-improvement upper bounds from web.dev/articles/vitals.
|
|
5
6
|
const METRICS = [
|
|
6
|
-
{ name: "LCP", key: "largest_contentful_paint", unit: "ms"
|
|
7
|
-
{ name: "CLS", key: "cumulative_layout_shift", unit: "score"
|
|
8
|
-
{ name: "INP", key: "interaction_to_next_paint", unit: "ms"
|
|
7
|
+
{ name: "LCP", key: "largest_contentful_paint", unit: "ms" },
|
|
8
|
+
{ name: "CLS", key: "cumulative_layout_shift", unit: "score" },
|
|
9
|
+
{ name: "INP", key: "interaction_to_next_paint", unit: "ms" },
|
|
9
10
|
];
|
|
10
11
|
|
|
11
12
|
const FORM_FACTOR_LABEL = { PHONE: "mobile", DESKTOP: "desktop", TABLET: "tablet" };
|
|
12
13
|
|
|
13
|
-
const rate = (value, { good, poor }) => (value <= good ? "good" : value <= poor ? "needs-improvement" : "poor");
|
|
14
14
|
|
|
15
15
|
// The viewport preset decides which population the field data describes.
|
|
16
16
|
export function formFactorFor(viewportName) {
|
|
@@ -22,7 +22,7 @@ function parseMetrics(record) {
|
|
|
22
22
|
for (const m of METRICS) {
|
|
23
23
|
// CLS p75 comes as a string ("0.08"); LCP and INP as numbers.
|
|
24
24
|
const p75 = Number(record?.metrics?.[m.key]?.percentiles?.p75);
|
|
25
|
-
if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(
|
|
25
|
+
if (Number.isFinite(p75)) metrics[m.name] = { p75, rating: rate(m.name, p75), unit: m.unit };
|
|
26
26
|
}
|
|
27
27
|
return metrics;
|
|
28
28
|
}
|
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
|
+
}
|
package/src/matrix.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// A matrix file: several pages, measured on several devices and in several variants, each
|
|
2
|
+
// combination one or more times. This module reads, validates and expands the file into the
|
|
3
|
+
// combinations to measure, with the conditions of each one already combined with the flags of the
|
|
4
|
+
// command line. It opens no browser, so an invalid file fails before any page load.
|
|
5
|
+
//
|
|
6
|
+
// {
|
|
7
|
+
// "urls": { "home": "https://example.com/" }, or an array of URLs
|
|
8
|
+
// "devices": { "mobile": { "viewport": "mobile", "cpu": 4, "network": "slow-4g" } },
|
|
9
|
+
// "variants": { "consent": { "storageState": "consent.json", "headers": {}, "ownDomains": [] } },
|
|
10
|
+
// "runs": 3,
|
|
11
|
+
// "medianBy": "LCP"
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// The conditions of the file replace cpu, network and storageState of the command line where they
|
|
15
|
+
// set them; headers and own domains add to the ones of the command line, which carries the secrets.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { VIEWPORT_PRESETS } from "./runner.js";
|
|
19
|
+
import { resolveNetwork } from "./throttling.js";
|
|
20
|
+
import { parseHeader } from "./headers.js";
|
|
21
|
+
import { parseOwnDomains } from "./own-domains.js";
|
|
22
|
+
import { MEDIAN_METRICS } from "./runs.js";
|
|
23
|
+
|
|
24
|
+
const KEYS = {
|
|
25
|
+
matrix: ["urls", "devices", "variants", "runs", "medianBy"],
|
|
26
|
+
device: ["viewport", "cpu", "network"],
|
|
27
|
+
variant: ["storageState", "headers", "ownDomains"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Without devices, a mid-range phone, as Lighthouse measures by default
|
|
31
|
+
const DEFAULT_DEVICE = { viewport: "mobile", cpu: 4, network: "slow-4g" };
|
|
32
|
+
|
|
33
|
+
// A header whose name says it carries a secret, which a versioned file should not hold
|
|
34
|
+
const LOOKS_SECRET = /secret|token|authorization|key|cookie/i;
|
|
35
|
+
|
|
36
|
+
class MatrixError extends Error {
|
|
37
|
+
constructor(problem) {
|
|
38
|
+
super(`Invalid matrix: ${problem}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
|
|
44
|
+
function checkKeys(object, known, at) {
|
|
45
|
+
for (const key of Object.keys(object)) {
|
|
46
|
+
if (!known.includes(key)) {
|
|
47
|
+
throw new MatrixError(`unknown key "${at ? `${at}.` : ""}${key}". Known keys: ${known.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The URLs by name. An array names each URL by its path and query, with the host when there are
|
|
53
|
+
// several, which is what a reader recognises in a table of 5 rows or of 50.
|
|
54
|
+
function readUrls(urls) {
|
|
55
|
+
if (urls === undefined) throw new MatrixError("urls is required");
|
|
56
|
+
const entries = Array.isArray(urls) ? urls.map((url, i) => [`[${i}]`, url]) : isObject(urls) ? Object.entries(urls).map(([name, url]) => [`.${name}`, url, name]) : null;
|
|
57
|
+
if (!entries) throw new MatrixError("urls must be an object of names and URLs, or an array of URLs");
|
|
58
|
+
if (entries.length === 0) throw new MatrixError("urls has no URL");
|
|
59
|
+
|
|
60
|
+
const parsed = entries.map(([at, url, name]) => {
|
|
61
|
+
let parsedUrl;
|
|
62
|
+
try {
|
|
63
|
+
parsedUrl = new URL(url);
|
|
64
|
+
} catch {
|
|
65
|
+
parsedUrl = null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof url !== "string" || !parsedUrl || !["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
68
|
+
throw new MatrixError(`urls${at} "${url}" is not an http or https URL`);
|
|
69
|
+
}
|
|
70
|
+
return { url, parsedUrl, name };
|
|
71
|
+
});
|
|
72
|
+
if (!Array.isArray(urls)) return parsed.map(({ name, url }) => ({ name, url }));
|
|
73
|
+
|
|
74
|
+
const severalHosts = new Set(parsed.map(({ parsedUrl }) => parsedUrl.host)).size > 1;
|
|
75
|
+
const named = parsed.map(({ url, parsedUrl }) => ({
|
|
76
|
+
name: `${severalHosts ? parsedUrl.host : ""}${parsedUrl.pathname}${parsedUrl.search}`,
|
|
77
|
+
url,
|
|
78
|
+
}));
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const { name, url } of named) {
|
|
81
|
+
if (seen.has(name)) {
|
|
82
|
+
const suggestion = name.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home";
|
|
83
|
+
throw new MatrixError(`two URLs of urls are named "${name}". Name them with an object, such as { "${suggestion}": "${url}" }`);
|
|
84
|
+
}
|
|
85
|
+
seen.add(name);
|
|
86
|
+
}
|
|
87
|
+
return named;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkViewport(viewport, at) {
|
|
91
|
+
if (!VIEWPORT_PRESETS[viewport]) {
|
|
92
|
+
throw new MatrixError(`${at} "${viewport}" is not a preset. Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The devices by name. An array of viewport presets is a shortcut for devices without throttling.
|
|
97
|
+
function readDevices(devices, flags) {
|
|
98
|
+
if (devices === undefined) {
|
|
99
|
+
return [{ name: "mobile", viewport: DEFAULT_DEVICE.viewport, cpu: flags.cpu ?? DEFAULT_DEVICE.cpu, network: flags.network ?? DEFAULT_DEVICE.network }];
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(devices) ? devices.length === 0 : isObject(devices) && Object.keys(devices).length === 0) {
|
|
102
|
+
throw new MatrixError("devices has no device");
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(devices)) {
|
|
105
|
+
devices.forEach((viewport, i) => checkViewport(viewport, `devices[${i}]`));
|
|
106
|
+
return devices.map((viewport) => ({ name: viewport, viewport, cpu: flags.cpu, network: flags.network }));
|
|
107
|
+
}
|
|
108
|
+
if (!isObject(devices)) throw new MatrixError("devices must be an object of names and devices, or an array of viewport presets");
|
|
109
|
+
|
|
110
|
+
return Object.entries(devices).map(([name, device]) => {
|
|
111
|
+
const at = `devices.${name}`;
|
|
112
|
+
if (!isObject(device)) throw new MatrixError(`${at} must be an object, such as { "viewport": "mobile", "cpu": 4, "network": "slow-4g" }`);
|
|
113
|
+
checkKeys(device, KEYS.device, at);
|
|
114
|
+
const viewport = device.viewport ?? "mobile";
|
|
115
|
+
checkViewport(viewport, `${at}.viewport`);
|
|
116
|
+
if (device.cpu !== undefined && !(typeof device.cpu === "number" && device.cpu >= 1)) {
|
|
117
|
+
throw new MatrixError(`${at}.cpu must be a number of at least 1, such as 4`);
|
|
118
|
+
}
|
|
119
|
+
if (device.network !== undefined) {
|
|
120
|
+
try {
|
|
121
|
+
resolveNetwork(device.network);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw new MatrixError(`${at}.network: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { name, viewport, cpu: device.cpu ?? flags.cpu, network: device.network ?? flags.network };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The variants by name. Without variants, one variant with no name and no conditions of its own,
|
|
131
|
+
// so the combinations are the pages on the devices and the reports leave the variant out.
|
|
132
|
+
function readVariants(variants, base, warnings) {
|
|
133
|
+
if (variants === undefined) return [{ headers: {}, ownDomains: [] }];
|
|
134
|
+
if (!isObject(variants)) throw new MatrixError("variants must be an object of names and variants");
|
|
135
|
+
if (Object.keys(variants).length === 0) throw new MatrixError("variants has no variant");
|
|
136
|
+
|
|
137
|
+
return Object.entries(variants).map(([name, variant]) => {
|
|
138
|
+
const at = `variants.${name}`;
|
|
139
|
+
if (!isObject(variant)) throw new MatrixError(`${at} must be an object, such as { "storageState": "consent.json" }`);
|
|
140
|
+
checkKeys(variant, KEYS.variant, at);
|
|
141
|
+
|
|
142
|
+
let storageState;
|
|
143
|
+
if (variant.storageState !== undefined) {
|
|
144
|
+
storageState = resolve(base, String(variant.storageState));
|
|
145
|
+
if (!existsSync(storageState)) throw new MatrixError(`${at}.storageState "${variant.storageState}" not found at ${storageState}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const headers = {};
|
|
149
|
+
if (variant.headers !== undefined) {
|
|
150
|
+
if (!isObject(variant.headers)) throw new MatrixError(`${at}.headers must be an object of header names and values`);
|
|
151
|
+
for (const [header, value] of Object.entries(variant.headers)) {
|
|
152
|
+
if (!parseHeader(`${header}: `)) throw new MatrixError(`${at}.headers "${header}" is not a valid header`);
|
|
153
|
+
if (typeof value !== "string") throw new MatrixError(`${at}.headers "${header}" must be a string`);
|
|
154
|
+
if (LOOKS_SECRET.test(header)) {
|
|
155
|
+
warnings.push(`${at}.headers.${header} looks like a secret, and a matrix file is usually versioned. Pass it with --header "${header}: $VARIABLE" instead.`);
|
|
156
|
+
}
|
|
157
|
+
headers[header] = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let ownDomains = [];
|
|
162
|
+
if (variant.ownDomains !== undefined) {
|
|
163
|
+
const parsed = Array.isArray(variant.ownDomains) && variant.ownDomains.every((d) => typeof d === "string") ? parseOwnDomains(variant.ownDomains) : null;
|
|
164
|
+
if (!parsed) throw new MatrixError(`${at}.ownDomains must be a list of domains without spaces`);
|
|
165
|
+
ownDomains = parsed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { name, storageState, headers, ownDomains };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The headers of the command line and of the variant; the variant wins on the same header, whatever its case
|
|
173
|
+
function mergeHeaders(fromFlags = {}, fromVariant) {
|
|
174
|
+
const names = new Set(Object.keys(fromVariant).map((name) => name.toLowerCase()));
|
|
175
|
+
const kept = Object.entries(fromFlags).filter(([name]) => !names.has(name.toLowerCase()));
|
|
176
|
+
return Object.fromEntries([...kept, ...Object.entries(fromVariant)]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The file at `path`, with the flags of the command line: cpu, network, storageState, headers,
|
|
180
|
+
// ownDomains, runs and medianBy. Returns the runs, the metric of the median, the combinations in the
|
|
181
|
+
// order they run (URL, then device, then variant) and the warnings to print.
|
|
182
|
+
export function loadMatrix(path, flags = {}) {
|
|
183
|
+
if (!existsSync(path)) throw new Error(`Matrix file not found: ${path}`);
|
|
184
|
+
let matrix;
|
|
185
|
+
try {
|
|
186
|
+
matrix = JSON.parse(readFileSync(path, "utf8"));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new MatrixError(`the file is not valid JSON (${err.message})`);
|
|
189
|
+
}
|
|
190
|
+
if (!isObject(matrix)) throw new MatrixError("the file must hold an object, such as { \"urls\": [\"https://example.com/\"] }");
|
|
191
|
+
checkKeys(matrix, KEYS.matrix);
|
|
192
|
+
|
|
193
|
+
if (matrix.runs !== undefined && !(Number.isInteger(matrix.runs) && matrix.runs >= 1 && matrix.runs <= 9)) {
|
|
194
|
+
throw new MatrixError("runs must be a whole number from 1 to 9");
|
|
195
|
+
}
|
|
196
|
+
if (matrix.medianBy !== undefined && !MEDIAN_METRICS.includes(matrix.medianBy)) {
|
|
197
|
+
throw new MatrixError(`medianBy "${matrix.medianBy}" is not a metric. Choose from: ${MEDIAN_METRICS.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const warnings = [];
|
|
201
|
+
const urls = readUrls(matrix.urls);
|
|
202
|
+
const devices = readDevices(matrix.devices, flags);
|
|
203
|
+
const variants = readVariants(matrix.variants, dirname(resolve(path)), warnings);
|
|
204
|
+
|
|
205
|
+
const combinations = [];
|
|
206
|
+
for (const { name, url } of urls) {
|
|
207
|
+
for (const device of devices) {
|
|
208
|
+
for (const variant of variants) {
|
|
209
|
+
combinations.push({
|
|
210
|
+
id: [name, device.name, variant.name].filter(Boolean).join(" · "),
|
|
211
|
+
name,
|
|
212
|
+
url,
|
|
213
|
+
device: device.name,
|
|
214
|
+
...(variant.name ? { variant: variant.name } : {}),
|
|
215
|
+
viewport: device.viewport,
|
|
216
|
+
cpu: device.cpu,
|
|
217
|
+
network: device.network,
|
|
218
|
+
storageState: variant.storageState ?? flags.storageState,
|
|
219
|
+
headers: mergeHeaders(flags.headers, variant.headers),
|
|
220
|
+
ownDomains: [...(flags.ownDomains ?? []), ...variant.ownDomains],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { runs: flags.runs ?? matrix.runs ?? 1, medianBy: flags.medianBy ?? matrix.medianBy, combinations, warnings };
|
|
227
|
+
}
|
|
@@ -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/ratings.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Good / needs-improvement upper bounds from web.dev/articles/vitals, and the ones web.dev gives for
|
|
2
|
+
// FCP and TTFB, the same the snippets rate with.
|
|
3
|
+
export const THRESHOLDS = {
|
|
4
|
+
LCP: { good: 2500, poor: 4000 },
|
|
5
|
+
FCP: { good: 1800, poor: 3000 },
|
|
6
|
+
TTFB: { good: 800, poor: 1800 },
|
|
7
|
+
CLS: { good: 0.1, poor: 0.25 },
|
|
8
|
+
INP: { good: 200, poor: 500 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// "good", "needs-improvement" or "poor", or undefined for a value without thresholds
|
|
12
|
+
export function rate(metric, value) {
|
|
13
|
+
const thresholds = THRESHOLDS[metric];
|
|
14
|
+
if (!thresholds) return undefined;
|
|
15
|
+
return value <= thresholds.good ? "good" : value <= thresholds.poor ? "needs-improvement" : "poor";
|
|
16
|
+
}
|
package/src/reporters/human.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { styleText } from "node:util";
|
|
2
2
|
import { fieldDataView } from "../crux.js";
|
|
3
|
+
import { describeThrottling } from "../throttling.js";
|
|
4
|
+
import { describeRuns } from "../runs.js";
|
|
5
|
+
import { matrixView } from "./matrix-view.js";
|
|
3
6
|
|
|
4
7
|
const RATING_ICON = {
|
|
5
8
|
good: "🟢",
|
|
@@ -202,10 +205,22 @@ function renderFieldData(crux, results) {
|
|
|
202
205
|
return lines;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
|
|
208
|
+
// The runs line, and the median, min and max of each metric across the runs
|
|
209
|
+
function renderRuns(runs) {
|
|
210
|
+
const lines = [styleText("dim", describeRuns(runs))];
|
|
211
|
+
for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
|
|
212
|
+
const cell = (label, value) => pad(`${label} ${formatValue(value, unit)}`, 16);
|
|
213
|
+
lines.push(styleText("dim", ` ${pad(metric, 6)}${cell("median", median)}${cell("min", min)}${cell("max", max).trimEnd()}`));
|
|
214
|
+
}
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function reportHuman({ url, navMs, results, pageErrors, verbose, crux, throttling, runs, title = `WebPerf Snippets — ${url}` }) {
|
|
206
219
|
const lines = [];
|
|
207
|
-
lines.push(styleText(["bold"],
|
|
220
|
+
lines.push(styleText(["bold"], title));
|
|
208
221
|
lines.push(styleText("dim", `Navigated in ${navMs}ms`));
|
|
222
|
+
if (throttling) lines.push(styleText("dim", `Throttling: ${describeThrottling(throttling)}`));
|
|
223
|
+
if (runs) lines.push(...renderRuns(runs));
|
|
209
224
|
lines.push("");
|
|
210
225
|
for (const r of results) lines.push(renderResult(r, verbose));
|
|
211
226
|
if (crux) {
|
|
@@ -219,3 +234,39 @@ export function reportHuman({ url, navMs, results, pageErrors, verbose, crux })
|
|
|
219
234
|
}
|
|
220
235
|
return lines.join("\n");
|
|
221
236
|
}
|
|
237
|
+
|
|
238
|
+
// One row per combination with the rated median of each metric, and below it the errors that decide
|
|
239
|
+
// the exit code. With verbose, the report of the median run of each combination follows.
|
|
240
|
+
export function reportMatrixHuman(report) {
|
|
241
|
+
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
242
|
+
const columns = [["name", "Page"], ["device", "Device"], ...(hasVariants ? [["variant", "Variant"]] : [])];
|
|
243
|
+
const widths = columns.map(([key, label]) => Math.max(...report.combinations.map((c) => c[key].length), label.length) + 3);
|
|
244
|
+
const identity = (values) => values.map((value, i) => pad(value, widths[i])).join("");
|
|
245
|
+
|
|
246
|
+
const lines = [];
|
|
247
|
+
lines.push(styleText(["bold"], `WebPerf Snippets — ${file} (${summary})`));
|
|
248
|
+
lines.push("");
|
|
249
|
+
lines.push(styleText("dim", ` ${identity(columns.map(([, label]) => label))}${metrics.map((m) => pad(m, 14)).join("").trimEnd()}`));
|
|
250
|
+
for (const { combination: c, cells } of rows) {
|
|
251
|
+
const row = ` ${identity(columns.map(([key]) => c[key]))}`;
|
|
252
|
+
if (!cells) {
|
|
253
|
+
lines.push(row + styleText("red", "✗ every run failed"));
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const drawn = cells.map((cell) => (cell ? pad(paint(cell.rating, `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}`), 14) : pad("·", 14)));
|
|
257
|
+
lines.push(row + drawn.join("").trimEnd());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (errors.length) {
|
|
261
|
+
lines.push("");
|
|
262
|
+
lines.push(styleText("red", "Errors:"));
|
|
263
|
+
for (const e of errors) lines.push(` ${styleText("red", "✗")} ${e}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (report.verbose) {
|
|
267
|
+
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
268
|
+
lines.push("", reportHuman({ ...c, verbose: true, title: `${c.id} — ${c.url}` }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return lines.join("\n");
|
|
272
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { fieldDataView } from "../crux.js";
|
|
2
|
+
import { describeThrottling } from "../throttling.js";
|
|
3
|
+
import { describeRuns } from "../runs.js";
|
|
4
|
+
import { matrixView } from "./matrix-view.js";
|
|
2
5
|
|
|
3
6
|
const RATING_ICON = {
|
|
4
7
|
good: "✅",
|
|
@@ -71,11 +74,24 @@ function renderFieldData(crux, results) {
|
|
|
71
74
|
];
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
// The median, min and max of each metric across the runs
|
|
78
|
+
function renderRuns(runs) {
|
|
79
|
+
const lines = ["| Metric | Median | Min | Max |", "|--------|--------|-----|-----|"];
|
|
80
|
+
for (const [metric, { median, min, max, unit }] of Object.entries(runs.summary)) {
|
|
81
|
+
lines.push(`| ${metric} | ${formatValue(median, unit)} | ${formatValue(min, unit)} | ${formatValue(max, unit)} |`);
|
|
82
|
+
}
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// With a null title, the report has no heading, as inside the details block of a matrix
|
|
87
|
+
export function reportMarkdown({ url, navMs, results, pageErrors, crux, throttling, runs, title = `WebPerf Results — ${url}` }) {
|
|
75
88
|
const lines = [];
|
|
76
|
-
lines.push(`##
|
|
89
|
+
if (title) lines.push(`## ${title}`);
|
|
77
90
|
lines.push(`> Navigated in ${navMs}ms`);
|
|
91
|
+
if (throttling) lines.push(">", `> Throttling: ${describeThrottling(throttling)}`);
|
|
92
|
+
if (runs) lines.push(">", `> ${describeRuns(runs)}`);
|
|
78
93
|
lines.push("");
|
|
94
|
+
if (runs) lines.push(...renderRuns(runs), "");
|
|
79
95
|
|
|
80
96
|
const initial = results.filter((r) => !r.reason);
|
|
81
97
|
if (initial.length > 0) {
|
|
@@ -106,3 +122,31 @@ export function reportMarkdown({ url, navMs, results, pageErrors, crux }) {
|
|
|
106
122
|
|
|
107
123
|
return lines.join("\n");
|
|
108
124
|
}
|
|
125
|
+
|
|
126
|
+
// A table with the rated median of each combination, the errors that decide the exit code, and the
|
|
127
|
+
// report of the median run of each combination folded into a details block, to keep a pull request
|
|
128
|
+
// comment readable.
|
|
129
|
+
export function reportMatrixMarkdown(report) {
|
|
130
|
+
const { file, summary, hasVariants, metrics, rows, errors } = matrixView(report);
|
|
131
|
+
const headings = ["Page", "Device", ...(hasVariants ? ["Variant"] : []), ...metrics];
|
|
132
|
+
const row = (parts) => `|${parts.map((part) => (part ? ` ${part} ` : " ")).join("|")}|`;
|
|
133
|
+
|
|
134
|
+
const lines = [`## WebPerf Results — ${file}`, `> ${summary}`, ""];
|
|
135
|
+
lines.push(row(headings));
|
|
136
|
+
lines.push(`|${headings.map((h) => "-".repeat(h.length + 2)).join("|")}|`);
|
|
137
|
+
for (const { combination: c, cells } of rows) {
|
|
138
|
+
const drawn = cells
|
|
139
|
+
? cells.map((cell) => (cell ? `${RATING_ICON[cell.rating] ?? "·"} ${formatValue(cell.median, cell.unit)}` : "·"))
|
|
140
|
+
: ["❌ every run failed", ...metrics.slice(1).map(() => "")];
|
|
141
|
+
lines.push(row([c.name, c.device, ...(hasVariants ? [c.variant] : []), ...drawn]));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (errors.length) {
|
|
145
|
+
lines.push("", "### Errors", ...errors.map((e) => `- ${e}`));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const c of report.combinations.filter((c) => !c.error)) {
|
|
149
|
+
lines.push("", `<details><summary>${c.id}</summary>`, "", reportMarkdown({ ...c, title: null }), "", "</details>");
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { rate } from "../ratings.js";
|
|
3
|
+
import { blockingErrors } from "../blocking.js";
|
|
4
|
+
|
|
5
|
+
// The order of the metric columns of the matrix table
|
|
6
|
+
const METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP"];
|
|
7
|
+
|
|
8
|
+
// What the human and markdown reports of a matrix show, so they only differ in how they draw it:
|
|
9
|
+
// the title, the metric columns, one row per combination with the rated median of each metric, and
|
|
10
|
+
// the errors that decide the exit code.
|
|
11
|
+
export function matrixView({ matrix, runs, combinations }) {
|
|
12
|
+
const measured = combinations.filter((c) => !c.error);
|
|
13
|
+
const medianBys = [...new Set(measured.map((c) => c.runs.medianBy))];
|
|
14
|
+
const metrics = METRICS.filter((metric) => measured.some((c) => c.runs.summary[metric]));
|
|
15
|
+
const median = medianBys.length ? `, median by ${medianBys.join(", ")}` : "";
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
file: basename(matrix),
|
|
19
|
+
// A matrix without variants has no Variant column
|
|
20
|
+
hasVariants: combinations.some((c) => c.variant),
|
|
21
|
+
summary: `${combinations.length} combinations, ${runs} run${runs === 1 ? "" : "s"} each${median}`,
|
|
22
|
+
metrics,
|
|
23
|
+
rows: combinations.map((c) => ({
|
|
24
|
+
combination: c,
|
|
25
|
+
cells: c.error
|
|
26
|
+
? null
|
|
27
|
+
: metrics.map((metric) => {
|
|
28
|
+
const value = c.runs.summary[metric];
|
|
29
|
+
return value ? { median: value.median, unit: value.unit, rating: rate(metric, value.median) } : null;
|
|
30
|
+
}),
|
|
31
|
+
})),
|
|
32
|
+
errors: combinations.flatMap((c) =>
|
|
33
|
+
c.error ? [`${c.id}: ${c.error}`] : blockingErrors(c.results).map((e) => `${c.id}: ${e.id}: ${e.message}`)
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
}
|
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
|
}
|
package/src/runs.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Several runs of the same measurement, reduced to one: the median run, as WebPageTest reports it.
|
|
2
|
+
// Every result of the report comes from that run, so they agree with each other (the sub-parts of
|
|
3
|
+
// the LCP add up to its LCP), and a summary gives the median, min and max of each metric.
|
|
4
|
+
|
|
5
|
+
// The metrics the snippets report, and navMs, which every run has
|
|
6
|
+
export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
|
|
7
|
+
|
|
8
|
+
// Without --median-by, the median follows the first of these that the runs measured
|
|
9
|
+
const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "navMs"];
|
|
10
|
+
|
|
11
|
+
const SUMMARY_METRICS = MEDIAN_METRICS.filter((metric) => metric !== "navMs");
|
|
12
|
+
|
|
13
|
+
// The value of a metric in a run: its first result with that metric, as LCP comes before its
|
|
14
|
+
// follow-up LCP-Subparts, which reports the same metric
|
|
15
|
+
function valueOf(run, metric) {
|
|
16
|
+
if (metric === "navMs") return run.navMs;
|
|
17
|
+
const result = run.results.find((r) => r.metric === metric && r.status === "ok" && typeof r.value === "number");
|
|
18
|
+
return result?.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const measures = (runs, metric) => runs.some((run) => typeof valueOf(run, metric) === "number");
|
|
22
|
+
|
|
23
|
+
// The middle element of numbers sorted from low to high. With an even count it takes the lower of
|
|
24
|
+
// the two middle ones, so the median is always a run that happened, never an average of two.
|
|
25
|
+
const middle = (sorted) => sorted[Math.floor((sorted.length - 1) / 2)];
|
|
26
|
+
|
|
27
|
+
// "3 runs, median by LCP: run 2 (1 failed)", for the header of the reports
|
|
28
|
+
export function describeRuns({ count, failed, medianRun, medianBy, medianByRequested }) {
|
|
29
|
+
const by = medianByRequested ? `${medianBy} (no run measured ${medianByRequested})` : medianBy;
|
|
30
|
+
return `${count} run${count === 1 ? "" : "s"}, median by ${by}: run ${medianRun}${failed ? ` (${failed} failed)` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The runs as the CLI made them, in order, a run that failed being { error }. Returns the payload of
|
|
34
|
+
// the median run and the `runs` field of the report, or nulls and the errors when every run failed.
|
|
35
|
+
export function summarizeRuns(runs, { medianBy: requested } = {}) {
|
|
36
|
+
const completed = runs.filter((run) => !run.error);
|
|
37
|
+
if (completed.length === 0) return { payload: null, runs: null, errors: runs.map((run) => run.error) };
|
|
38
|
+
|
|
39
|
+
const medianBy = requested && measures(completed, requested) ? requested : DEFAULT_ORDER.find((metric) => measures(completed, metric));
|
|
40
|
+
const ranked = completed
|
|
41
|
+
.filter((run) => typeof valueOf(run, medianBy) === "number")
|
|
42
|
+
.sort((a, b) => valueOf(a, medianBy) - valueOf(b, medianBy));
|
|
43
|
+
const payload = middle(ranked);
|
|
44
|
+
|
|
45
|
+
const summary = {};
|
|
46
|
+
for (const metric of SUMMARY_METRICS) {
|
|
47
|
+
if (!measures(completed, metric)) continue;
|
|
48
|
+
const values = runs.map((run) => (run.error ? null : (valueOf(run, metric) ?? null)));
|
|
49
|
+
const sorted = values.filter((value) => value !== null).sort((a, b) => a - b);
|
|
50
|
+
const unit = completed.flatMap((run) => run.results).find((r) => r.metric === metric && r.unit)?.unit;
|
|
51
|
+
summary[metric] = { median: middle(sorted), min: sorted[0], max: sorted[sorted.length - 1], values, unit };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
payload,
|
|
56
|
+
runs: {
|
|
57
|
+
count: runs.length,
|
|
58
|
+
failed: runs.length - completed.length,
|
|
59
|
+
medianRun: runs.indexOf(payload) + 1,
|
|
60
|
+
medianBy,
|
|
61
|
+
...(requested && requested !== medianBy ? { medianByRequested: requested } : {}),
|
|
62
|
+
summary,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
//
|
|
5
|
+
// A network can also be given by its own values, for the ones DevTools has no preset for, such as
|
|
6
|
+
// 5G or fibre: { downloadKbps, uploadKbps, latencyMs }, in kilobits per second and milliseconds.
|
|
7
|
+
export const NETWORK_PRESETS = {
|
|
8
|
+
"fast-4g": { title: "Fast 4G", download: (9 * 1000 * 1000) / 8 * 0.9, upload: (1.5 * 1000 * 1000) / 8 * 0.9, latency: 60 * 2.75 },
|
|
9
|
+
"slow-4g": { title: "Slow 4G", download: (1.6 * 1000 * 1000) / 8 * 0.9, upload: (750 * 1000) / 8 * 0.9, latency: 150 * 3.75 },
|
|
10
|
+
"3g": { title: "3G", download: (500 * 1000) / 8 * 0.8, upload: (500 * 1000) / 8 * 0.8, latency: 400 * 5 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// The factor given to --cpu, or null when it is not a number of at least 1
|
|
14
|
+
export function parseCpuFactor(raw) {
|
|
15
|
+
const factor = Number(raw);
|
|
16
|
+
return raw.trim() !== "" && Number.isFinite(factor) && factor >= 1 ? factor : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const NETWORK_VALUES = ["downloadKbps", "uploadKbps", "latencyMs"];
|
|
20
|
+
|
|
21
|
+
// The throughputs and latency of a preset name or of a network given by its values
|
|
22
|
+
export function resolveNetwork(network) {
|
|
23
|
+
if (typeof network === "string") {
|
|
24
|
+
const preset = NETWORK_PRESETS[network];
|
|
25
|
+
if (!preset) throw new Error(`Unknown network preset: "${network}". Choose from: ${Object.keys(NETWORK_PRESETS).join(", ")}`);
|
|
26
|
+
return preset;
|
|
27
|
+
}
|
|
28
|
+
for (const key of NETWORK_VALUES) {
|
|
29
|
+
const value = network?.[key];
|
|
30
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
31
|
+
throw new Error(`Invalid network: ${key} must be a number of at least 0, such as { "downloadKbps": 50000, "uploadKbps": 10000, "latencyMs": 20 }`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
download: (network.downloadKbps * 1000) / 8,
|
|
36
|
+
upload: (network.uploadKbps * 1000) / 8,
|
|
37
|
+
latency: network.latencyMs,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 400 kbps, 1.44 Mbps, 50 Mbps
|
|
42
|
+
function formatKbps(kbps) {
|
|
43
|
+
return kbps < 1000 ? `${kbps} kbps` : `${Number((kbps / 1000).toFixed(2))} Mbps`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describeNetwork(network) {
|
|
47
|
+
if (typeof network === "string") return NETWORK_PRESETS[network]?.title ?? network;
|
|
48
|
+
return `${formatKbps(network.downloadKbps)}, ${network.latencyMs} ms`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The conditions of a run, as the payload reports them, or undefined without throttling
|
|
52
|
+
export function throttlingOf({ cpu, network }) {
|
|
53
|
+
if (cpu == null && network == null) return undefined;
|
|
54
|
+
return { ...(cpu != null ? { cpu } : {}), ...(network != null ? { network } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// "CPU 4x, Slow 4G" or "CPU 1x, 50 Mbps, 20 ms", for the header of the reports
|
|
58
|
+
export function describeThrottling({ cpu, network }) {
|
|
59
|
+
return [cpu != null ? `CPU ${cpu}x` : null, network != null ? describeNetwork(network) : null]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(", ");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function applyThrottling(context, page, { cpu, network }) {
|
|
65
|
+
if (cpu == null && network == null) return;
|
|
66
|
+
// An invalid network fails before anything is applied to the page
|
|
67
|
+
const conditions = network != null ? resolveNetwork(network) : null;
|
|
68
|
+
const cdp = await context.newCDPSession(page);
|
|
69
|
+
if (cpu != null) await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu });
|
|
70
|
+
if (conditions) {
|
|
71
|
+
const { download, upload, latency } = conditions;
|
|
72
|
+
await cdp.send("Network.enable");
|
|
73
|
+
await cdp.send("Network.emulateNetworkConditions", {
|
|
74
|
+
offline: false,
|
|
75
|
+
latency,
|
|
76
|
+
downloadThroughput: download,
|
|
77
|
+
uploadThroughput: upload,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|