webperf-snippets 1.12.0 → 1.14.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 +23 -8
- package/package.json +2 -1
- package/snippets/Interaction/Scroll-Performance.js +5 -1
- package/snippets/Loading/CSS-Complexity.js +562 -0
- package/snippets/Loading/WORKFLOWS.md +11 -0
- package/snippets/SCHEMA.md +59 -1
- package/src/bin.js +8 -7
- package/src/conditions.js +13 -3
- package/src/mcp/next-steps.js +28 -8
- package/src/mcp/server.js +73 -18
- package/src/runner.js +7 -4
- package/src/runs.js +1 -1
- package/src/throttling.js +4 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait, the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.14.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -171,7 +171,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
171
171
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
172
172
|
"navMs": 1240,
|
|
173
173
|
"results": [],
|
|
174
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
174
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.14.0" }
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
|
@@ -203,7 +203,7 @@ One page load is one sample, and lab values vary from one load to the next. `--r
|
|
|
203
203
|
npx webperf-snippets https://example.com --runs 5 --budget-lcp 2500
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
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 how much the runs agree, and the JSON output carries them in `runs`:
|
|
206
|
+
The median run is the one with the median LCP. A workflow without LCP uses the first of FCP, TTFB, INP and the navigation time that it measures, so a measurement of interactions answers with its median INP, 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 how much the runs agree, and the JSON output carries them in `runs`:
|
|
207
207
|
|
|
208
208
|
```json
|
|
209
209
|
"runs": {
|
|
@@ -334,7 +334,7 @@ Each budget compares the value measured, or the median with `--runs`, and fails
|
|
|
334
334
|
| `--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. |
|
|
335
335
|
| `--own-domains <list>` | Domains of the site to count as first party, such as its own CDN. Comma-separated or repeated. |
|
|
336
336
|
| `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
|
|
337
|
-
| `--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. |
|
|
337
|
+
| `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB, INP and `navMs` measured. |
|
|
338
338
|
| `--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. |
|
|
339
339
|
| `--baseline <file>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run: which metrics got worse or better beyond the noise, and which errors are new or fixed. |
|
|
340
340
|
| `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
|
|
@@ -404,15 +404,30 @@ It needs the Chromium of Playwright (`npx playwright install chromium`); without
|
|
|
404
404
|
| Tool | What it does |
|
|
405
405
|
| --------------- | ---------------------------------------------------------------------------- |
|
|
406
406
|
| `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
|
|
407
|
-
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport`, `waitMs`, `runs`, `baseline` and `thresholds`. The snippets that record interactions
|
|
408
|
-
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport`, `waitMs`, `runs`, `baseline` and `thresholds`. |
|
|
407
|
+
| `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns a summary of its result. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport`, `waitMs`, `cpu`, `network`, `interactions`, `runs`, `baseline` and `thresholds`. The snippets that record interactions need `interactions` with a click or a key press, or a page opened with `open_page`. |
|
|
408
|
+
| `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport`, `waitMs`, `cpu`, `network`, `interactions`, `runs`, `baseline` and `thresholds`. |
|
|
409
409
|
| `get_crux` | Returns the CrUX p75 of LCP, CLS and INP for a URL, or its origin. Takes `url` and an optional `formFactor` (`phone`, `desktop`, `tablet`). Needs `CRUX_API_KEY` in the environment of the server. |
|
|
410
|
-
| `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
|
|
410
|
+
| `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Takes optional `viewport`, `waitMs`, `cpu` and `network`. Returns a `sessionId`. |
|
|
411
411
|
| `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
|
|
412
412
|
| `read_results` | Returns what the tracking snippets of an open page have recorded. |
|
|
413
413
|
| `get_details` | Returns the whole object of one snippet of a run, by the `runId` that `run_snippet`, `run_workflow` or `read_results` returned, without measuring again. Takes `runId` and `snippet`. |
|
|
414
414
|
| `close_page` | Closes an open page. |
|
|
415
415
|
|
|
416
|
+
`run_snippet`, `run_workflow` and `open_page` take `cpu` and `network`, as `--cpu` and `--network`: a CPU factor of at least 1, such as `4`, and a DevTools preset (`fast-4g`, `slow-4g`, `3g`), applied before the page loads. The answer adds `throttling`, as the JSON does, and the conditions of the run name them, so a baseline measured under other throttling gives a warning.
|
|
417
|
+
|
|
418
|
+
`run_snippet` and `run_workflow` take `interactions`, the steps of `interact` (up to 50), as `--interact-script`: they run after the load of each run, so `runs`, `baseline` and `thresholds` work with them. With a click or a key press, `run_snippet` runs INP, Interactions and Input-Latency-Breakdown, and the `interaction` workflow runs its steps that need input instead of skipping them. The INP of a page over five runs with a click each, with the CPU 4 times slower, against an earlier run:
|
|
419
|
+
|
|
420
|
+
```json
|
|
421
|
+
{
|
|
422
|
+
"url": "http://localhost:3000/product",
|
|
423
|
+
"snippet": "INP",
|
|
424
|
+
"interactions": [{ "action": "click", "selector": "#buy" }, { "action": "wait", "ms": 300 }],
|
|
425
|
+
"runs": 5,
|
|
426
|
+
"cpu": 4,
|
|
427
|
+
"baseline": "<runId of the run before the change>"
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
416
431
|
`run_snippet` and `run_workflow` take `runs`, from 1 to 9, as `--runs`: the page loads that many times, one after the other, and the answer is the median run plus a `runs` field, the one of the JSON, with the median, min, max, the value of each run and the agreement of each metric. A run that fails counts in `failed` and is left out; when every run fails, the answer is a tool error. The server keeps the results of the median run only, as the JSON does, so `get_details` answers from that run and names it in `medianRun`; a metric other than the one that picked it (`medianBy`) can differ there from its median in `runs`.
|
|
417
432
|
|
|
418
433
|
Each run is a page load plus `waitMs`. With the default wait of 3 seconds, five runs of a workflow take about 20 seconds on a local page, and about a minute on a page that takes a few seconds to load. When the client asks for progress, the server reports the three stages of each run, numbered across the runs. A client sets its own limit on a tool call: the MCP SDK stops waiting after 60 seconds unless it is given a longer `timeout` or `resetTimeoutOnProgress`, and in Claude Code `MCP_TOOL_TIMEOUT` sets the limit. When the client gives up, the run in progress ends and no other starts.
|
|
@@ -541,7 +556,7 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
|
|
|
541
556
|
- **CLS in headless is conservative**: layout shifts that only happen on scroll are missed unless you script the scroll.
|
|
542
557
|
- **First navigation only**: each `webperf-snippets` invocation runs one URL. SPAs need the post-route URL passed directly.
|
|
543
558
|
- **Synthetic INP ≠ field INP**: `--interact-script` measures handler latency for a single scripted event. Real INP reflects the worst interaction across all user sessions; use RUM for field data.
|
|
544
|
-
- **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds.
|
|
559
|
+
- **A click that paints nothing**: INP is under 16 ms with the `no-paint-measured` issue, as web-vitals reports it, so it rates `good` and passes `--budget-inp`. Check that the selector reaches an element that responds. When the document recorded no interaction at all, as with a click inside an iframe, the snippet is an error that says a click or key press was sent, and the run exits with code `1`.
|
|
545
560
|
|
|
546
561
|
## Roadmap
|
|
547
562
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
|
+
"@projectwallace/css-analyzer": "9.9.5",
|
|
24
25
|
"vitest": "^5.0.5",
|
|
25
26
|
"web-vitals": "^6.2.3"
|
|
26
27
|
},
|
|
@@ -26,9 +26,13 @@
|
|
|
26
26
|
"scroll", "wheel", "touchstart", "touchmove", "touchend",
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
+
// When Playwright clicks, as the CLI does, its injected script can add touch listeners of its own to
|
|
30
|
+
// window, without passive, to check that the click lands on the element. They are not the page's.
|
|
31
|
+
const addedByPlaywright = () => /\bInjectedScript\b/.test(new Error().stack || "");
|
|
32
|
+
|
|
29
33
|
const _origAddEventListener = EventTarget.prototype.addEventListener;
|
|
30
34
|
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
|
31
|
-
if (SCROLL_EVENT_TYPES.has(type)) {
|
|
35
|
+
if (SCROLL_EVENT_TYPES.has(type) && !addedByPlaywright()) {
|
|
32
36
|
const isPassive =
|
|
33
37
|
options === true
|
|
34
38
|
? false // old-style capture boolean, not passive
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// CSS Complexity
|
|
2
|
+
// What in the CSS of the page costs bytes or style work and can go: embedded content, @import, empty
|
|
3
|
+
// rules, repeated selectors, unused @keyframes and complex selectors, read from the CSSOM of every
|
|
4
|
+
// stylesheet, and with
|
|
5
|
+
// fetch for a stylesheet of another origin whose rules the page cannot read
|
|
6
|
+
// https://webperf-snippets.net
|
|
7
|
+
|
|
8
|
+
(async () => {
|
|
9
|
+
const SCRIPT = "CSS-Complexity";
|
|
10
|
+
const MAX_ITEMS = 50;
|
|
11
|
+
// The 90th percentile of the embedded content of a homepage in The CSS Selection 2026 (Project
|
|
12
|
+
// Wallace): 90% of homepages embed less than this in all their CSS
|
|
13
|
+
const EMBEDDED_P90_BYTES = 36632;
|
|
14
|
+
// The 90th percentiles of !important (share of the declarations) and of @font-face rules
|
|
15
|
+
const IMPORTANT_P90_PERCENT = 13;
|
|
16
|
+
const FONT_FACE_P90 = 48;
|
|
17
|
+
// The 90th percentile of the most complex selector of a homepage
|
|
18
|
+
const SELECTOR_COMPLEXITY_P90 = 29;
|
|
19
|
+
const MAX_COMPLEX_SELECTORS = 5;
|
|
20
|
+
// Below this share of unique selectors, css-code-quality (Project Wallace) flags the duplication
|
|
21
|
+
const MIN_SELECTOR_UNIQUENESS = 0.66;
|
|
22
|
+
|
|
23
|
+
// @shared formatBytes
|
|
24
|
+
function formatBytes(bytes) {
|
|
25
|
+
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
|
|
26
|
+
if (bytes === 0) return "0 B";
|
|
27
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
28
|
+
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
|
|
29
|
+
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
30
|
+
}
|
|
31
|
+
// @end-shared formatBytes
|
|
32
|
+
|
|
33
|
+
// @shared ownRequestUrl
|
|
34
|
+
// The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
|
|
35
|
+
// new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
|
|
36
|
+
// comes from the cache, but it stays in the name of the Resource Timing entry of the request,
|
|
37
|
+
// which is how isOwnRequest leaves these requests out of what the page loaded.
|
|
38
|
+
function ownRequestUrl(url) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url, location.href);
|
|
41
|
+
if (!/^https?:$/.test(parsed.protocol)) return url;
|
|
42
|
+
parsed.hash = "webperf-snippets";
|
|
43
|
+
return parsed.href;
|
|
44
|
+
} catch {
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// @end-shared ownRequestUrl
|
|
49
|
+
|
|
50
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
51
|
+
|
|
52
|
+
// Splits on a separator outside parentheses, brackets and strings: the declarations of a rule
|
|
53
|
+
// (";"), or the selectors of a list (",")
|
|
54
|
+
function splitTopLevel(text, separator) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
let depth = 0;
|
|
57
|
+
let quote = null;
|
|
58
|
+
let start = 0;
|
|
59
|
+
for (let i = 0; i < text.length; i++) {
|
|
60
|
+
const c = text[i];
|
|
61
|
+
if (quote) {
|
|
62
|
+
if (c === "\\") i++;
|
|
63
|
+
else if (c === quote) quote = null;
|
|
64
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
65
|
+
else if (c === "(" || c === "[") depth++;
|
|
66
|
+
else if (c === ")" || c === "]") depth--;
|
|
67
|
+
else if (c === separator && depth === 0) {
|
|
68
|
+
parts.push(text.slice(start, i));
|
|
69
|
+
start = i + 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
parts.push(text.slice(start));
|
|
73
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The declarations as written, from cssText: the CSSOM lists a shorthand as all its longhands in
|
|
77
|
+
// style.length, and cssText joins them back
|
|
78
|
+
const declarationsOf = (style) => splitTopLevel(style.cssText, ";");
|
|
79
|
+
|
|
80
|
+
const FONT_TYPES = /^(font\/|application\/(x-)?font-|application\/vnd\.ms-fontobject)/;
|
|
81
|
+
|
|
82
|
+
// The data: URLs of a declaration block, as the CSSOM serializes them: url("data:…")
|
|
83
|
+
function embeddedOf(cssText, inFontFace) {
|
|
84
|
+
const found = [];
|
|
85
|
+
for (const [, raw] of cssText.matchAll(/url\("((?:[^"\\]|\\.)*)"\)/g)) {
|
|
86
|
+
const url = raw.replace(/\\(["\\])/g, "$1");
|
|
87
|
+
if (!/^data:/i.test(url)) continue;
|
|
88
|
+
const type = url.slice(5).split(/[;,]/)[0].toLowerCase() || "text/plain";
|
|
89
|
+
found.push({ type, bytes: url.length, font: inFontFace || FONT_TYPES.test(type) });
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- Selector complexity, as css-analyzer counts it ------------------------------------------
|
|
95
|
+
// Every part of a selector counts 1: type, class, id, attribute, pseudo-class, pseudo-element,
|
|
96
|
+
// combinator, * and &. A vendor prefix and an attribute value count 1 more. :is(), :where(),
|
|
97
|
+
// :not(), :has(), :matches() and :-webkit-any() add the complexity of the selectors they hold,
|
|
98
|
+
// and :nth-child() that of its "of S" part, or 1 for its An+B. Those add every selector found in
|
|
99
|
+
// their argument, at any depth, so :is(:is(.a)) is 4. Another pseudo with an argument adds 1 for
|
|
100
|
+
// the list and the selectors in it, but :lang() and :nth-of-type() only 1.
|
|
101
|
+
|
|
102
|
+
const SELECTOR_FUNCTIONS = new Set(["is", "where", "not", "has", "matches", "-webkit-any", "-moz-any"]);
|
|
103
|
+
const total = (values) => values.reduce((a, b) => a + b, 0);
|
|
104
|
+
// The complexities of the selectors of a list, and of every selector nested in them
|
|
105
|
+
const selectorsIn = (text) => splitTopLevel(text, ",").flatMap((selector) => {
|
|
106
|
+
const { complexity, nested } = analyzeSelector(selector);
|
|
107
|
+
return [complexity, ...nested];
|
|
108
|
+
});
|
|
109
|
+
const complexityOf = (selectorText) => analyzeSelector(selectorText).complexity;
|
|
110
|
+
|
|
111
|
+
// The complexity of a selector, and the complexities of the selectors nested in its pseudo-classes
|
|
112
|
+
function analyzeSelector(selectorText) {
|
|
113
|
+
const s = selectorText.trim();
|
|
114
|
+
let complexity = 0;
|
|
115
|
+
const nested = [];
|
|
116
|
+
let i = 0;
|
|
117
|
+
let afterCombinator = false;
|
|
118
|
+
const readIdent = () => {
|
|
119
|
+
const start = i;
|
|
120
|
+
while (i < s.length) {
|
|
121
|
+
if (s[i] === "\\") i += 2;
|
|
122
|
+
else if (/[\w-]/.test(s[i]) || s.charCodeAt(i) > 127) i++;
|
|
123
|
+
else break;
|
|
124
|
+
}
|
|
125
|
+
return s.slice(start, i);
|
|
126
|
+
};
|
|
127
|
+
// The text between the parenthesis at i and its match, after which i points
|
|
128
|
+
const readGroup = (open, close) => {
|
|
129
|
+
const start = i + 1;
|
|
130
|
+
let depth = 0;
|
|
131
|
+
let quote = null;
|
|
132
|
+
for (; i < s.length; i++) {
|
|
133
|
+
const c = s[i];
|
|
134
|
+
if (quote) {
|
|
135
|
+
if (c === "\\") i++;
|
|
136
|
+
else if (c === quote) quote = null;
|
|
137
|
+
} else if (c === '"' || c === "'") quote = c;
|
|
138
|
+
else if (c === open) depth++;
|
|
139
|
+
else if (c === close && --depth === 0) return s.slice(start, i++);
|
|
140
|
+
}
|
|
141
|
+
return s.slice(start);
|
|
142
|
+
};
|
|
143
|
+
while (i < s.length) {
|
|
144
|
+
const c = s[i];
|
|
145
|
+
if (/\s/.test(c)) {
|
|
146
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
147
|
+
// Whitespace between two compounds is the descendant combinator
|
|
148
|
+
if (i < s.length && !">+~".includes(s[i]) && complexity > 0 && !afterCombinator) {
|
|
149
|
+
complexity++;
|
|
150
|
+
afterCombinator = true;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (">+~".includes(c)) {
|
|
155
|
+
complexity++;
|
|
156
|
+
i++;
|
|
157
|
+
afterCombinator = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
afterCombinator = false;
|
|
161
|
+
if (c === "*" || c === "&") {
|
|
162
|
+
complexity++;
|
|
163
|
+
i++;
|
|
164
|
+
} else if (c === "#" || c === ".") {
|
|
165
|
+
i++;
|
|
166
|
+
readIdent();
|
|
167
|
+
complexity++;
|
|
168
|
+
} else if (c === "[") {
|
|
169
|
+
const inner = readGroup("[", "]");
|
|
170
|
+
complexity += /=/.test(inner) ? 2 : 1;
|
|
171
|
+
} else if (c === ":") {
|
|
172
|
+
i += s[i + 1] === ":" ? 2 : 1;
|
|
173
|
+
const name = readIdent().toLowerCase();
|
|
174
|
+
complexity += name.startsWith("-") ? 2 : 1;
|
|
175
|
+
if (s[i] === "(") {
|
|
176
|
+
const arg = readGroup("(", ")").trim();
|
|
177
|
+
const of = name === "nth-child" || name === "nth-last-child" ? arg.match(/\sof\s([\s\S]+)$/i) : null;
|
|
178
|
+
if (SELECTOR_FUNCTIONS.has(name) || of) {
|
|
179
|
+
const found = selectorsIn(of ? of[1] : arg);
|
|
180
|
+
complexity += total(found);
|
|
181
|
+
nested.push(...found);
|
|
182
|
+
} else if (name === "nth-child" || name === "nth-last-child" || name === "nth-of-type" || name === "nth-last-of-type" || name === "lang") {
|
|
183
|
+
complexity += arg ? 1 : 0;
|
|
184
|
+
} else {
|
|
185
|
+
const found = selectorsIn(arg);
|
|
186
|
+
const own = splitTopLevel(arg, ",").map(complexityOf);
|
|
187
|
+
complexity += 1 + total(own);
|
|
188
|
+
nested.push(...found);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else if (/[\w-\\]/.test(c) || s.charCodeAt(i) > 127) {
|
|
192
|
+
readIdent();
|
|
193
|
+
complexity++;
|
|
194
|
+
} else {
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { complexity, nested };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The most frequent value, or the mean of the most frequent ones when they tie, as css-analyzer
|
|
202
|
+
function modeOf(values) {
|
|
203
|
+
const frequencies = new Map();
|
|
204
|
+
for (const v of values) frequencies.set(v, (frequencies.get(v) ?? 0) + 1);
|
|
205
|
+
let top = 0;
|
|
206
|
+
for (const n of frequencies.values()) if (n > top) top = n;
|
|
207
|
+
const tied = [...frequencies.entries()].filter(([, n]) => n === top).map(([v]) => v);
|
|
208
|
+
return tied.length ? tied.reduce((a, b) => a + b, 0) / tied.length : 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const describeHost = (el) => el.localName + (el.id ? `#${el.id}` : el.classList[0] ? `.${el.classList[0]}` : "");
|
|
212
|
+
|
|
213
|
+
// --- The stylesheets: of the document, adopted, of open shadow roots, and imported -----------
|
|
214
|
+
|
|
215
|
+
// Each entry is a stylesheet of the page, or the URL of one to fetch, imported by a fetched one
|
|
216
|
+
const sheets = [];
|
|
217
|
+
const seenSheets = new Set();
|
|
218
|
+
// A stylesheet a browser extension links to the page is not part of it. The <style> elements an
|
|
219
|
+
// extension injects carry no such mark, so the page tells to measure without extensions.
|
|
220
|
+
const EXTENSION_URL = /^(chrome|moz|safari-web)-extension:/i;
|
|
221
|
+
let extensionCount = 0;
|
|
222
|
+
const addSheet = (sheet, source) => {
|
|
223
|
+
if (!sheet || seenSheets.has(sheet)) return;
|
|
224
|
+
seenSheets.add(sheet);
|
|
225
|
+
if (sheet.href && EXTENSION_URL.test(sheet.href)) {
|
|
226
|
+
extensionCount++;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sheets.push({ sheet, source });
|
|
230
|
+
};
|
|
231
|
+
const addUrl = (href) => {
|
|
232
|
+
if (seenSheets.has(href)) return;
|
|
233
|
+
if (EXTENSION_URL.test(href)) {
|
|
234
|
+
seenSheets.add(href);
|
|
235
|
+
extensionCount++;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
seenSheets.add(href);
|
|
239
|
+
sheets.push({ href, source: href });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// A stylesheet of another origin loaded without CORS: its rules throw, but fetch can read it when
|
|
243
|
+
// the server sends Access-Control-Allow-Origin, as a CDN often does. replaceSync parses it with
|
|
244
|
+
// the same CSSOM, and drops @import, so the imports are read from the text and left out of it.
|
|
245
|
+
async function fetchSheet(href) {
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetch(ownRequestUrl(href));
|
|
248
|
+
if (!response.ok) return null;
|
|
249
|
+
const text = (await response.text()).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
250
|
+
const statements = text.match(/@import\s[^;]*;/gi) ?? [];
|
|
251
|
+
const imports = statements
|
|
252
|
+
.map((statement) => statement.match(/@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/i)?.[2])
|
|
253
|
+
.filter(Boolean)
|
|
254
|
+
.map((url) => new URL(url, href).href);
|
|
255
|
+
const sheet = new window.CSSStyleSheet({ baseURL: href });
|
|
256
|
+
sheet.replaceSync(statements.reduce((css, statement) => css.replace(statement, ""), text));
|
|
257
|
+
return { rules: sheet.cssRules, imports };
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let styleIndex = 0;
|
|
263
|
+
let adoptedIndex = 0;
|
|
264
|
+
for (const sheet of document.styleSheets) addSheet(sheet, sheet.href || `<style> #${++styleIndex}`);
|
|
265
|
+
for (const sheet of document.adoptedStyleSheets ?? []) addSheet(sheet, `adopted stylesheet #${++adoptedIndex}`);
|
|
266
|
+
for (const el of document.querySelectorAll("*")) {
|
|
267
|
+
const root = el.shadowRoot;
|
|
268
|
+
if (!root) continue;
|
|
269
|
+
const host = describeHost(el);
|
|
270
|
+
for (const sheet of root.styleSheets) addSheet(sheet, sheet.href || `<style> in the shadow root of ${host}`);
|
|
271
|
+
for (const sheet of root.adoptedStyleSheets ?? []) addSheet(sheet, `adopted stylesheet in the shadow root of ${host}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- Counting ----------------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const imports = [];
|
|
277
|
+
const embeddedTypes = new Map();
|
|
278
|
+
// Every selector of every rule, without its context (@media, @supports), as css-analyzer counts them
|
|
279
|
+
const selectorCounts = new Map();
|
|
280
|
+
// The complexity of every selector, and of each distinct one
|
|
281
|
+
const complexities = [];
|
|
282
|
+
const complexityBySelector = new Map();
|
|
283
|
+
const keyframesDefined = new Set();
|
|
284
|
+
const animationsUsed = new Set();
|
|
285
|
+
let keyframesCount = 0;
|
|
286
|
+
let fontFaceCount = 0;
|
|
287
|
+
|
|
288
|
+
// An element of another namespace than HTML, SVG or MathML has a style attribute and no style
|
|
289
|
+
const noteAnimations = (style) => {
|
|
290
|
+
for (const name of (style?.animationName ?? "").split(",")) {
|
|
291
|
+
const n = name.trim();
|
|
292
|
+
if (n && n !== "none") animationsUsed.add(n);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Counts the declarations of a block, its !important and the animations it names
|
|
297
|
+
function noteDeclarations(style, stats) {
|
|
298
|
+
const declarations = declarationsOf(style);
|
|
299
|
+
stats.declarationCount += declarations.length;
|
|
300
|
+
stats.importantCount += declarations.filter((d) => /!\s*important$/i.test(d)).length;
|
|
301
|
+
noteAnimations(style);
|
|
302
|
+
return declarations;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function walk(rules, stats, sheetUrl) {
|
|
306
|
+
for (const rule of rules) {
|
|
307
|
+
if (rule instanceof window.CSSImportRule) {
|
|
308
|
+
stats.importCount++;
|
|
309
|
+
let href = rule.href;
|
|
310
|
+
try {
|
|
311
|
+
href = new URL(rule.href, sheetUrl).href;
|
|
312
|
+
} catch {
|
|
313
|
+
// keep the href as written
|
|
314
|
+
}
|
|
315
|
+
imports.push({ from: stats.source, href });
|
|
316
|
+
addSheet(rule.styleSheet, href);
|
|
317
|
+
} else if (rule instanceof window.CSSStyleRule) {
|
|
318
|
+
const declarations = noteDeclarations(rule.style, stats);
|
|
319
|
+
const nested = rule.cssRules?.length ?? 0;
|
|
320
|
+
const selectors = splitTopLevel(rule.selectorText, ",");
|
|
321
|
+
stats.ruleCount++;
|
|
322
|
+
stats.selectorCount += selectors.length;
|
|
323
|
+
for (const selector of selectors) {
|
|
324
|
+
selectorCounts.set(selector, (selectorCounts.get(selector) ?? 0) + 1);
|
|
325
|
+
const complexity = complexityBySelector.get(selector) ?? complexityOf(selector);
|
|
326
|
+
complexityBySelector.set(selector, complexity);
|
|
327
|
+
complexities.push(complexity);
|
|
328
|
+
}
|
|
329
|
+
if (declarations.length === 0 && nested === 0) stats.emptyRuleCount++;
|
|
330
|
+
count(embeddedOf(rule.style.cssText, false), stats);
|
|
331
|
+
if (nested) walk(rule.cssRules, stats, sheetUrl);
|
|
332
|
+
} else if (window.CSSNestedDeclarations && rule instanceof window.CSSNestedDeclarations) {
|
|
333
|
+
// The declarations of a style rule that follow a nested rule, or sit in a nested @media
|
|
334
|
+
noteDeclarations(rule.style, stats);
|
|
335
|
+
count(embeddedOf(rule.style.cssText, false), stats);
|
|
336
|
+
} else if (rule instanceof window.CSSFontFaceRule) {
|
|
337
|
+
fontFaceCount++;
|
|
338
|
+
stats.declarationCount += declarationsOf(rule.style).length;
|
|
339
|
+
count(embeddedOf(rule.style.cssText, true), stats);
|
|
340
|
+
} else if (rule instanceof window.CSSKeyframesRule) {
|
|
341
|
+
keyframesCount++;
|
|
342
|
+
keyframesDefined.add(rule.name);
|
|
343
|
+
for (const keyframe of rule.cssRules) {
|
|
344
|
+
stats.declarationCount += declarationsOf(keyframe.style).length;
|
|
345
|
+
count(embeddedOf(keyframe.style.cssText, false), stats);
|
|
346
|
+
}
|
|
347
|
+
} else if (rule.cssRules) {
|
|
348
|
+
// @media, @supports, @layer, @container and the other rules that group rules
|
|
349
|
+
walk(rule.cssRules, stats, sheetUrl);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function count(embedded, stats) {
|
|
355
|
+
for (const { type, bytes, font } of embedded) {
|
|
356
|
+
stats.embeddedBytes += bytes;
|
|
357
|
+
stats.embeddedCount++;
|
|
358
|
+
if (font) {
|
|
359
|
+
stats.fontCount++;
|
|
360
|
+
stats.fontBytes += bytes;
|
|
361
|
+
}
|
|
362
|
+
const t = embeddedTypes.get(type) ?? { type, count: 0, bytes: 0 };
|
|
363
|
+
t.count++;
|
|
364
|
+
t.bytes += bytes;
|
|
365
|
+
embeddedTypes.set(type, t);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const perSheet = [];
|
|
370
|
+
// The list grows while it is read: each @import adds the sheet it imports
|
|
371
|
+
for (let i = 0; i < sheets.length; i++) {
|
|
372
|
+
const { sheet, href, source } = sheets[i];
|
|
373
|
+
let rules = null;
|
|
374
|
+
let read = "cssom";
|
|
375
|
+
let fetchedImports = [];
|
|
376
|
+
try {
|
|
377
|
+
rules = sheet?.cssRules ?? null;
|
|
378
|
+
} catch {
|
|
379
|
+
// A stylesheet of another origin, loaded without CORS
|
|
380
|
+
}
|
|
381
|
+
const url = href ?? sheet?.href;
|
|
382
|
+
if (!rules && url) {
|
|
383
|
+
const fetched = await fetchSheet(url);
|
|
384
|
+
if (fetched) {
|
|
385
|
+
rules = fetched.rules;
|
|
386
|
+
fetchedImports = fetched.imports;
|
|
387
|
+
read = "fetched";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!rules) {
|
|
391
|
+
perSheet.push({ source, read: "unreadable" });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const stats = { source, read, ruleCount: 0, selectorCount: 0, declarationCount: 0, importantCount: 0, emptyRuleCount: 0, embeddedBytes: 0, embeddedCount: 0, fontCount: 0, fontBytes: 0, importCount: 0 };
|
|
395
|
+
walk(rules, stats, url || location.href);
|
|
396
|
+
for (const imported of fetchedImports) {
|
|
397
|
+
stats.importCount++;
|
|
398
|
+
imports.push({ from: source, href: imported });
|
|
399
|
+
addUrl(imported);
|
|
400
|
+
}
|
|
401
|
+
perSheet.push(stats);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The animations of style attributes, in the document and in open shadow roots
|
|
405
|
+
const roots = [document, ...[...document.querySelectorAll("*")].map((el) => el.shadowRoot).filter(Boolean)];
|
|
406
|
+
for (const root of roots) for (const el of root.querySelectorAll("[style]")) noteAnimations(el.style);
|
|
407
|
+
|
|
408
|
+
const read = perSheet.filter((s) => s.read !== "unreadable");
|
|
409
|
+
const unreadable = perSheet.filter((s) => s.read === "unreadable");
|
|
410
|
+
const sum = (key) => read.reduce((total, s) => total + s[key], 0);
|
|
411
|
+
// Unique selectors over all selectors; 1 without selectors, since none repeats
|
|
412
|
+
const selectorUniqueness = () => {
|
|
413
|
+
const total = [...selectorCounts.values()].reduce((a, b) => a + b, 0);
|
|
414
|
+
return total ? Math.round((selectorCounts.size / total) * 1000) / 1000 : 1;
|
|
415
|
+
};
|
|
416
|
+
const details = {
|
|
417
|
+
stylesheetCount: perSheet.length,
|
|
418
|
+
extensionCount,
|
|
419
|
+
unreadableCount: unreadable.length,
|
|
420
|
+
fetchedCount: perSheet.filter((s) => s.read === "fetched").length,
|
|
421
|
+
ruleCount: sum("ruleCount"),
|
|
422
|
+
selectorCount: sum("selectorCount"),
|
|
423
|
+
declarationCount: sum("declarationCount"),
|
|
424
|
+
embeddedBytes: sum("embeddedBytes"),
|
|
425
|
+
embeddedCount: sum("embeddedCount"),
|
|
426
|
+
embeddedTypes: [...embeddedTypes.values()].sort((a, b) => b.bytes - a.bytes || a.type.localeCompare(b.type)),
|
|
427
|
+
importCount: sum("importCount"),
|
|
428
|
+
emptyRuleCount: sum("emptyRuleCount"),
|
|
429
|
+
selectorUniqueness: selectorUniqueness(),
|
|
430
|
+
importantCount: sum("importantCount"),
|
|
431
|
+
importantPercent: 0,
|
|
432
|
+
fontFaceCount,
|
|
433
|
+
keyframesCount,
|
|
434
|
+
unusedKeyframes: [...keyframesDefined].filter((name) => !animationsUsed.has(name)).sort(),
|
|
435
|
+
// A loop, not Math.max(...complexities): a spread of hundreds of thousands of values overflows the stack
|
|
436
|
+
maxSelectorComplexity: complexities.reduce((max, c) => (c > max ? c : max), 0),
|
|
437
|
+
commonSelectorComplexity: modeOf(complexities),
|
|
438
|
+
complexSelectors: [...complexityBySelector.entries()]
|
|
439
|
+
.map(([selectorText, complexity]) => ({ selectorText, complexity }))
|
|
440
|
+
.sort((a, b) => b.complexity - a.complexity)
|
|
441
|
+
.slice(0, MAX_COMPLEX_SELECTORS),
|
|
442
|
+
};
|
|
443
|
+
details.importantPercent = details.declarationCount ? Math.round((details.importantCount / details.declarationCount) * 1000) / 10 : 0;
|
|
444
|
+
|
|
445
|
+
// --- Issues ------------------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
const issues = [];
|
|
448
|
+
for (const s of read.filter((s) => s.fontCount > 0)) {
|
|
449
|
+
issues.push({
|
|
450
|
+
code: "embedded-font",
|
|
451
|
+
severity: "warning",
|
|
452
|
+
message: `${s.source} embeds ${plural(s.fontCount, "font file")} as ${s.fontCount === 1 ? "a data: URL" : "data: URLs"} (${formatBytes(s.fontBytes)}). An embedded font downloads with the stylesheet, blocks rendering with it and cannot be cached apart from it: serve fonts as WOFF2 files, with font-display`,
|
|
453
|
+
target: s.source,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (details.embeddedBytes > EMBEDDED_P90_BYTES) {
|
|
457
|
+
issues.push({
|
|
458
|
+
code: "embedded-content",
|
|
459
|
+
severity: "warning",
|
|
460
|
+
message: `The CSS embeds ${formatBytes(details.embeddedBytes)} in ${plural(details.embeddedCount, "data: URL")}, more than 90% of homepages (${formatBytes(EMBEDDED_P90_BYTES)}, The CSS Selection 2026). It downloads and parses with the stylesheet even when it is not shown: serve large images and fonts as files`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
for (const { from, href } of imports) {
|
|
464
|
+
issues.push({
|
|
465
|
+
code: "import",
|
|
466
|
+
severity: "warning",
|
|
467
|
+
message: `${from} imports ${href} with @import: the browser only requests it after it downloads and parses ${from}. Link it from the HTML, or bundle it`,
|
|
468
|
+
target: href,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (details.emptyRuleCount > 0) {
|
|
472
|
+
issues.push({
|
|
473
|
+
code: "empty-rules",
|
|
474
|
+
severity: "info",
|
|
475
|
+
message: `${plural(details.emptyRuleCount, "empty rule")}, with no declarations: bytes to download and selectors to match that style nothing`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (details.selectorCount > 0 && details.selectorUniqueness < MIN_SELECTOR_UNIQUENESS) {
|
|
479
|
+
const repeated = [...selectorCounts.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]);
|
|
480
|
+
issues.push({
|
|
481
|
+
code: "duplicate-selectors",
|
|
482
|
+
severity: "info",
|
|
483
|
+
message: `Only ${Math.round(details.selectorUniqueness * 100)}% of the ${details.selectorCount} selectors are unique: the same selectors repeat across rules, which adds CSS to download and match. Some repeat inside @media on purpose; merge the others. Most repeated: ${repeated.slice(0, 3).map(([sel, n]) => `${sel} (${n})`).join(", ")}`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if (details.importantPercent > IMPORTANT_P90_PERCENT) {
|
|
487
|
+
issues.push({
|
|
488
|
+
code: "important-ratio",
|
|
489
|
+
severity: "info",
|
|
490
|
+
message: `${details.importantPercent}% of the declarations use !important, more than on 90% of homepages (${IMPORTANT_P90_PERCENT}%, The CSS Selection 2026): a sign of overrides on overrides, which make the CSS grow`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (fontFaceCount > FONT_FACE_P90) {
|
|
494
|
+
issues.push({
|
|
495
|
+
code: "font-face-count",
|
|
496
|
+
severity: "info",
|
|
497
|
+
message: `${fontFaceCount} @font-face rules, more than on 90% of homepages (${FONT_FACE_P90}, The CSS Selection 2026). Run Webfont-Usage-Analyzer to see which faces load and which are never used`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
for (const { selectorText, complexity } of details.complexSelectors.filter((c) => c.complexity > SELECTOR_COMPLEXITY_P90)) {
|
|
501
|
+
issues.push({
|
|
502
|
+
code: "selector-complexity",
|
|
503
|
+
severity: "info",
|
|
504
|
+
message: `${selectorText} has a complexity of ${complexity}, more than the most complex selector of 90% of homepages (${SELECTOR_COMPLEXITY_P90}, The CSS Selection 2026). Complexity counts the parts of a selector, not its cost: the CSS selector costs insight of the DevTools Performance panel measures how long matching takes`,
|
|
505
|
+
target: selectorText,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// A stylesheet that could not be read could use them
|
|
509
|
+
if (unreadable.length === 0) {
|
|
510
|
+
for (const name of details.unusedKeyframes) {
|
|
511
|
+
issues.push({
|
|
512
|
+
code: "unused-keyframes",
|
|
513
|
+
severity: "info",
|
|
514
|
+
message: `@keyframes ${name} is defined, and no animation of the stylesheets or of a style attribute uses it: CSS that never runs, unless JavaScript sets it later`,
|
|
515
|
+
target: name,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (unreadable.length > 0) {
|
|
520
|
+
issues.push({
|
|
521
|
+
code: "unreadable-stylesheets",
|
|
522
|
+
severity: "info",
|
|
523
|
+
message: `${plural(unreadable.length, "cross-origin stylesheet")} could not be read, by the page or with fetch (no Access-Control-Allow-Origin), so the counts are a lower bound: ${unreadable.map((s) => s.source).join(", ")}`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// --- Items, the heaviest sheets first ----------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
const items = perSheet
|
|
530
|
+
.map((s) => (s.read === "unreadable" ? { source: s.source, read: s.read } : { source: s.source, read: s.read, ruleCount: s.ruleCount, embeddedBytes: s.embeddedBytes, importCount: s.importCount, importantCount: s.importantCount }))
|
|
531
|
+
.sort((a, b) => (b.embeddedBytes ?? -1) - (a.embeddedBytes ?? -1) || (b.ruleCount ?? -1) - (a.ruleCount ?? -1));
|
|
532
|
+
|
|
533
|
+
// --- Console -----------------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
console.group("%c🎨 CSS Complexity", "font-weight: bold; font-size: 14px;");
|
|
536
|
+
const readNotes = [details.fetchedCount && `${details.fetchedCount} read with fetch`, unreadable.length && `${unreadable.length} unreadable`].filter(Boolean);
|
|
537
|
+
console.log(`Stylesheets: ${details.stylesheetCount}${readNotes.length ? ` (${readNotes.join(", ")})` : ""}`);
|
|
538
|
+
console.log(`Rules: ${details.ruleCount} · Selectors: ${details.selectorCount} · Declarations: ${details.declarationCount}`);
|
|
539
|
+
console.log(`Embedded content: ${formatBytes(details.embeddedBytes)} in ${plural(details.embeddedCount, "data: URL")}`);
|
|
540
|
+
console.log(`@import: ${details.importCount} · Empty rules: ${details.emptyRuleCount}`);
|
|
541
|
+
console.log(`Unique selectors: ${Math.round(details.selectorUniqueness * 100)}% · !important: ${details.importantCount} (${details.importantPercent}%)`);
|
|
542
|
+
console.log(`Selector complexity: ${details.maxSelectorComplexity} at most, ${details.commonSelectorComplexity} the most common`);
|
|
543
|
+
console.log(`@font-face: ${fontFaceCount} · @keyframes: ${keyframesCount}${details.unusedKeyframes.length ? ` (unused: ${details.unusedKeyframes.join(", ")})` : ""}`);
|
|
544
|
+
if (items.length > 0) {
|
|
545
|
+
console.table(
|
|
546
|
+
items.map((i) => ({ Source: i.source, Read: i.read, Rules: i.ruleCount ?? "-", Embedded: formatBytes(i.embeddedBytes), "@import": i.importCount ?? "-", "!important": i.importantCount ?? "-" }))
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (issues.length > 0) issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
|
|
550
|
+
else console.log("%c✅ No issues found", "color: #22c55e; font-weight: bold;");
|
|
551
|
+
console.groupEnd();
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
script: SCRIPT,
|
|
555
|
+
status: "ok",
|
|
556
|
+
count: perSheet.length,
|
|
557
|
+
corsLimitedAnalysis: unreadable.length > 0,
|
|
558
|
+
details,
|
|
559
|
+
items: items.slice(0, MAX_ITEMS),
|
|
560
|
+
issues,
|
|
561
|
+
};
|
|
562
|
+
})();
|
|
@@ -198,6 +198,17 @@ Use this decision tree to automatically run follow-up snippets based on results:
|
|
|
198
198
|
- **If no inline CSS but has blocking stylesheets** → Suggest critical CSS extraction
|
|
199
199
|
- **If CSS > 50KB total** → Run **CSS-Media-Queries-Analysis.js** to find savings
|
|
200
200
|
|
|
201
|
+
### After CSS-Complexity.js
|
|
202
|
+
|
|
203
|
+
- **If it ran in the user's browser** (Chrome DevTools MCP connected to their Chrome) → Extensions can inject CSS that the snippet counts: before reporting, check `items` for `<style>` or shadow root sources the page does not have, or measure again in a profile without extensions or with the CLI
|
|
204
|
+
- **If embedded-font** → Recommend serving the font as a WOFF2 file, and run **Webfont-Usage-Analyzer.js** to check its `font-display`
|
|
205
|
+
- **If embedded-content** → Run **Critical-CSS-Detection.js**: embedded bytes in render-blocking CSS delay the first paint
|
|
206
|
+
- **If import** → Recommend linking the imported stylesheet from the HTML or bundling it, and run **Find-render-blocking-resources.js** to see the chain
|
|
207
|
+
- **If font-face-count** → Run **Webfont-Usage-Analyzer.js** to find the faces that never load
|
|
208
|
+
- **If unused-keyframes** → Check that no script sets the animation before suggesting to remove it
|
|
209
|
+
- **If selector-complexity** → Treat it as maintainability: only a trace with CSS selector stats (Performance panel, CSS selector costs insight) shows whether matching it is slow
|
|
210
|
+
- **If corsLimitedAnalysis is true** → Report the counts as a lower bound, and suggest `crossorigin` on the `<link>` with `Access-Control-Allow-Origin` on stylesheets you control
|
|
211
|
+
|
|
201
212
|
### After Back-Forward-Cache.js
|
|
202
213
|
|
|
203
214
|
- **If bfcache blocked** → Provide specific remediation based on blocking reasons
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -287,7 +287,7 @@ An interaction that paints nothing has no frame to be measured to, so the browse
|
|
|
287
287
|
}
|
|
288
288
|
```
|
|
289
289
|
|
|
290
|
-
Later interactions that short have no entry of any kind: they only count in `performance.interactionCount`, and INP does not depend on them. `tracking` after a click means that the document recorded no interaction at all, as with a click inside an iframe, which records its own; the CLI and the MCP server
|
|
290
|
+
Later interactions that short have no entry of any kind: they only count in `performance.interactionCount`, and INP does not depend on them. `tracking` after a click means that the document recorded no interaction at all, as with a click inside an iframe, which records its own; the CLI and the MCP server turn that result into `status: "error"`, with an `error` that says a click or key press was sent, so the CLI exits with code `1`.
|
|
291
291
|
|
|
292
292
|
The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
|
|
293
293
|
|
|
@@ -826,6 +826,49 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
|
|
|
826
826
|
}
|
|
827
827
|
```
|
|
828
828
|
|
|
829
|
+
#### CSS-Complexity
|
|
830
|
+
|
|
831
|
+
Reads the CSSOM of every stylesheet of the page: of the document, adopted, in open shadow roots and imported with `@import`. One item per stylesheet, the most embedded bytes first, with `read: "cssom"`; `read: "fetched"` for a stylesheet of another origin whose rules the page cannot read and `fetch` can, as with `Access-Control-Allow-Origin` and a `<link>` without `crossorigin`, parsed with the same CSSOM, its `@import` rules included; or `read: "unreadable"` and only its `source` when neither works, which sets `corsLimitedAnalysis: true`. `source` is the URL of the sheet, or a label such as `<style> #1` or `adopted stylesheet #1`. A stylesheet a browser extension links to the page (`chrome-extension:`, `moz-extension:`, `safari-web-extension:`) is left out and counted in `details.extensionCount`; the `<style>` elements and shadow roots an extension injects cannot be told apart from those of the page, so in a browser with extensions, as with Chrome DevTools MCP connected to the user's Chrome, the counts can include them.
|
|
832
|
+
|
|
833
|
+
`details` counts what the CSSOM keeps of the CSS as written, as [css-analyzer](https://github.com/projectwallace/css-analyzer) counts its text: rules (style rules, nested and inside at-rules), selectors (each one of a list), declarations (also those of `@font-face` and `@keyframes`), embedded content (`data:` URLs, in characters, and by MIME type), `@import` rules, empty rules, `!important` declarations, `@font-face` and `@keyframes` rules. `selectorUniqueness` is unique selectors over all selectors, without their context, so a selector repeated in two `@media` counts as repeated (1 without selectors). `unusedKeyframes` lists the `@keyframes` that no `animation` or `animation-name` of the stylesheets or of a `style` attribute names. `maxSelectorComplexity` and `commonSelectorComplexity` (the mode, or the mean of the tied values) give the complexity of the selectors as css-analyzer computes it, and `complexSelectors` the 5 most complex, as `{ selectorText, complexity }`: `selectorText` is the selector of a rule, not of an element of the page. The CSSOM drops comments, hacks, invalid declarations and rules with unsupported selectors, and writes a few shorthands as longhands, so these counts can differ slightly from those of the text.
|
|
834
|
+
|
|
835
|
+
```json
|
|
836
|
+
{
|
|
837
|
+
"script": "CSS-Complexity",
|
|
838
|
+
"status": "ok",
|
|
839
|
+
"count": 2,
|
|
840
|
+
"corsLimitedAnalysis": false,
|
|
841
|
+
"details": {
|
|
842
|
+
"stylesheetCount": 2,
|
|
843
|
+
"extensionCount": 0,
|
|
844
|
+
"unreadableCount": 0,
|
|
845
|
+
"fetchedCount": 1,
|
|
846
|
+
"ruleCount": 812,
|
|
847
|
+
"selectorCount": 1104,
|
|
848
|
+
"declarationCount": 2210,
|
|
849
|
+
"embeddedBytes": 41210,
|
|
850
|
+
"embeddedCount": 12,
|
|
851
|
+
"embeddedTypes": [{ "type": "font/woff2", "count": 2, "bytes": 38090 }, { "type": "image/svg+xml", "count": 10, "bytes": 3120 }],
|
|
852
|
+
"importCount": 1,
|
|
853
|
+
"emptyRuleCount": 14,
|
|
854
|
+
"selectorUniqueness": 0.81,
|
|
855
|
+
"importantCount": 51,
|
|
856
|
+
"importantPercent": 2.3,
|
|
857
|
+
"fontFaceCount": 8,
|
|
858
|
+
"keyframesCount": 9,
|
|
859
|
+
"unusedKeyframes": ["spin-old"],
|
|
860
|
+
"maxSelectorComplexity": 18,
|
|
861
|
+
"commonSelectorComplexity": 1,
|
|
862
|
+
"complexSelectors": [{ "selectorText": ".nav > ul li:not(.active) a[href^=\"http\"]", "complexity": 11 }]
|
|
863
|
+
},
|
|
864
|
+
"items": [
|
|
865
|
+
{ "source": "https://example.com/app.css", "read": "cssom", "ruleCount": 790, "embeddedBytes": 41210, "importCount": 1, "importantCount": 48 },
|
|
866
|
+
{ "source": "https://cdn.example.com/vendor.css", "read": "fetched", "ruleCount": 22, "embeddedBytes": 0, "importCount": 0, "importantCount": 3 }
|
|
867
|
+
],
|
|
868
|
+
"issues": [{ "code": "import", "severity": "warning", "message": "https://example.com/app.css imports https://cdn.example.com/vendor.css with @import: ...", "target": "https://cdn.example.com/vendor.css" }]
|
|
869
|
+
}
|
|
870
|
+
```
|
|
871
|
+
|
|
829
872
|
#### CSS-Media-Queries-Analysis
|
|
830
873
|
|
|
831
874
|
One item per distinct media query that targets viewports wider than `details.minWidth`, largest first. `details` gives the totals, `totalUnique` and `uniquenessRatio`, and `bySource` breaks the rules down by stylesheet.
|
|
@@ -1265,6 +1308,21 @@ An issue about one thing among several, one issue per thing, also names it in `t
|
|
|
1265
1308
|
| `could-defer` | info | Blocking scripts other than the last three of `<body>`, which could try `defer` |
|
|
1266
1309
|
| `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the total is a lower bound |
|
|
1267
1310
|
|
|
1311
|
+
### CSS-Complexity
|
|
1312
|
+
|
|
1313
|
+
| Code | Severity | When |
|
|
1314
|
+
|------|----------|------|
|
|
1315
|
+
| `embedded-font` | warning | A stylesheet embeds a font as a `data:` URL; `target` is the stylesheet |
|
|
1316
|
+
| `embedded-content` | warning | The `data:` URLs of all the CSS add up to more than 36.6 kB, the 90th percentile of homepages in The CSS Selection 2026 |
|
|
1317
|
+
| `import` | warning | An `@import` rule, one issue per rule; `target` is the URL it imports |
|
|
1318
|
+
| `empty-rules` | info | One or more style rules without declarations |
|
|
1319
|
+
| `duplicate-selectors` | info | Fewer than 66% of the selectors are unique, the threshold of css-code-quality |
|
|
1320
|
+
| `important-ratio` | info | More than 13% of the declarations use `!important`, the 90th percentile of homepages |
|
|
1321
|
+
| `font-face-count` | info | More than 48 `@font-face` rules, the 90th percentile of homepages |
|
|
1322
|
+
| `unused-keyframes` | info | A `@keyframes` that no animation of the stylesheets or of a `style` attribute uses, only when every stylesheet could be read; `target` is its name |
|
|
1323
|
+
| `selector-complexity` | info | A selector among the 5 most complex with a complexity above 29, the 90th percentile of the most complex selector of homepages; `target` is the selector of the rule |
|
|
1324
|
+
| `unreadable-stylesheets` | info | Cross-origin stylesheets whose rules the page cannot read and `fetch` cannot either |
|
|
1325
|
+
|
|
1268
1326
|
### CSS-Media-Queries-Analysis
|
|
1269
1327
|
|
|
1270
1328
|
| Code | Severity | When |
|
package/src/bin.js
CHANGED
|
@@ -40,7 +40,7 @@ Options:
|
|
|
40
40
|
--workflow <name> Workflow to run (default: core-web-vitals)
|
|
41
41
|
Workflows: core-web-vitals, audit, loading, interaction
|
|
42
42
|
--snippet <name> Run a single snippet by name, alias or Category/Name path
|
|
43
|
-
Any of the
|
|
43
|
+
Any of the 57 snippets, such as Compression-Audit
|
|
44
44
|
Aliases: LCP, CLS, INP, LCP-Subparts, fonts,
|
|
45
45
|
render-blocking, resource-hints, preload-scripts,
|
|
46
46
|
priority-hints, critical-css, ttfb,
|
|
@@ -71,7 +71,8 @@ Options:
|
|
|
71
71
|
--matrix <file> Measure every URL of a JSON file on every device and in every
|
|
72
72
|
variant it lists, and report a table of the medians
|
|
73
73
|
--median-by <metric> Metric that picks the median run: LCP, FCP, TTFB, CLS, INP,
|
|
74
|
-
navMs (default: the first of LCP, FCP, TTFB, navMs
|
|
74
|
+
navMs (default: the first of LCP, FCP, TTFB, INP, navMs
|
|
75
|
+
measured)
|
|
75
76
|
--baseline <file> Compare the page, or each combination of a matrix, with the --json
|
|
76
77
|
output of an earlier run: which metrics got worse or better beyond
|
|
77
78
|
the noise, and which errors are new or fixed. It only informs,
|
|
@@ -313,8 +314,8 @@ async function main() {
|
|
|
313
314
|
// What the JSON records of how each page was measured, for a later run to compare with. The machine is
|
|
314
315
|
// measured once, before Chromium starts, so its processes do not slow the benchmark down.
|
|
315
316
|
const machine = machineOf();
|
|
316
|
-
const conditionsFor = (viewport) =>
|
|
317
|
-
measurementConditions({ viewport, waitMs, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
317
|
+
const conditionsFor = ({ viewport, cpu, network }) =>
|
|
318
|
+
measurementConditions({ viewport, waitMs, cpu, network, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
318
319
|
|
|
319
320
|
if (matrixPath) {
|
|
320
321
|
let matrix;
|
|
@@ -336,7 +337,7 @@ async function main() {
|
|
|
336
337
|
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
337
338
|
payload = { ...median.payload, runs: median.runs };
|
|
338
339
|
}
|
|
339
|
-
payload.conditions = conditionsFor(viewportName);
|
|
340
|
+
payload.conditions = conditionsFor({ viewport: viewportName, cpu, network });
|
|
340
341
|
|
|
341
342
|
if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
|
|
342
343
|
|
|
@@ -422,8 +423,8 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
422
423
|
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
423
424
|
combinations.push(
|
|
424
425
|
median.payload
|
|
425
|
-
? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c
|
|
426
|
-
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c
|
|
426
|
+
? { ...identity, ...median.payload, runs: median.runs, conditions: conditionsFor(c) }
|
|
427
|
+
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
|
|
427
428
|
);
|
|
428
429
|
}
|
|
429
430
|
|
package/src/conditions.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { cliVersion } from "./report-to.js";
|
|
2
2
|
|
|
3
3
|
// How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
|
|
4
|
-
// both were measured the same way. A snippet is named by its path, so an alias and its path match.
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// both were measured the same way. A snippet is named by its path, so an alias and its path match. The
|
|
5
|
+
// CPU factor and the network go in only when the run was throttled, so a run without throttling keeps
|
|
6
|
+
// the conditions of earlier versions.
|
|
7
|
+
export function measurementConditions({ viewport, workflow, snippet, waitMs, cpu, network, machine }) {
|
|
8
|
+
return {
|
|
9
|
+
viewport,
|
|
10
|
+
...(snippet ? { snippet } : { workflow }),
|
|
11
|
+
waitMs,
|
|
12
|
+
...(cpu != null ? { cpu } : {}),
|
|
13
|
+
...(network != null ? { network } : {}),
|
|
14
|
+
cliVersion,
|
|
15
|
+
...(machine ? { machine } : {}),
|
|
16
|
+
};
|
|
7
17
|
}
|
package/src/mcp/next-steps.js
CHANGED
|
@@ -1,29 +1,49 @@
|
|
|
1
1
|
import { RULES, nextSteps } from "../decision-tree.js";
|
|
2
2
|
import { loadSnippet } from "../load-snippet.js";
|
|
3
3
|
import { isTrackingSnippet } from "../tracking.js";
|
|
4
|
+
import { throttlingOf } from "../throttling.js";
|
|
4
5
|
|
|
5
6
|
const isTrackingPath = (path) => isTrackingSnippet(path, loadSnippet(path));
|
|
6
7
|
|
|
7
8
|
// The call of the tool that runs a snippet: a tracking snippet needs a page the agent interacts with. A
|
|
8
|
-
// snippet waits as long as the run that called for it, so it reads the same paint
|
|
9
|
-
|
|
9
|
+
// snippet waits as long as the run that called for it, so it reads the same paint, and loads under its
|
|
10
|
+
// throttling, so it measures the same page.
|
|
11
|
+
const callFor = ({ path, reason, url, viewport, waitMs, cpu, network, isTracking }) =>
|
|
10
12
|
isTracking(path)
|
|
11
|
-
? { tool: "open_page", args: { url, track: [path], viewport }, reason }
|
|
12
|
-
: { tool: "run_snippet", args: { url, snippet: path, viewport, ...(waitMs !== undefined ? { waitMs } : {}) }, reason };
|
|
13
|
+
? { tool: "open_page", args: { url, track: [path], viewport, ...throttlingOf({ cpu, network }) }, reason }
|
|
14
|
+
: { tool: "run_snippet", args: { url, snippet: path, viewport, ...(waitMs !== undefined ? { waitMs } : {}), ...throttlingOf({ cpu, network }) }, reason };
|
|
13
15
|
|
|
14
16
|
// The follow-ups the decision tree of the CLI asks for, as the calls an agent makes next. The rules name
|
|
15
17
|
// the steps of a workflow (LCP, CLS, TTFB, FCP), which are the scripts of the snippets, so a result of
|
|
16
18
|
// run_snippet, whose id is Category/Name, is matched by its script. `ran` leaves out what already ran.
|
|
17
|
-
export function followUpCalls({ results, url, viewport, waitMs, ran = [], rules = RULES, isTracking = isTrackingPath }) {
|
|
19
|
+
export function followUpCalls({ results, url, viewport, waitMs, cpu, network, ran = [], rules = RULES, isTracking = isTrackingPath }) {
|
|
18
20
|
const named = results.map((r) => ({ ...r, id: r.script ?? r.id }));
|
|
19
|
-
return nextSteps(named, rules, ran).map(({ path, reason }) => callFor({ path, reason, url, viewport, waitMs, isTracking }));
|
|
21
|
+
return nextSteps(named, rules, ran).map(({ path, reason }) => callFor({ path, reason, url, viewport, waitMs, cpu, network, isTracking }));
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// The steps a workflow skipped because they measure clicks and key presses, in one page to open
|
|
23
|
-
export function trackingCall({ paths, url, viewport }) {
|
|
25
|
+
export function trackingCall({ paths, url, viewport, cpu, network }) {
|
|
24
26
|
return {
|
|
25
27
|
tool: "open_page",
|
|
26
|
-
args: { url, track: paths, viewport },
|
|
28
|
+
args: { url, track: paths, viewport, ...throttlingOf({ cpu, network }) },
|
|
27
29
|
reason: "These steps measure clicks and key presses: open the page with them, interact with it and read them with read_results",
|
|
28
30
|
};
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
// A click the agent completes: only the agent can choose the element of the page to click
|
|
34
|
+
const CLICK_TO_COMPLETE = { action: "click", selector: "<selector of the element to click>" };
|
|
35
|
+
|
|
36
|
+
// The steps a workflow skipped because they measure clicks and key presses. First the workflow again
|
|
37
|
+
// with interactions, which measures them in one call, with the runs and the baseline of a page load; then
|
|
38
|
+
// a page to open with them, for an agent that explores the page step by step.
|
|
39
|
+
export function inputStepsCalls({ workflow, paths, url, viewport, waitMs, cpu, network }) {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
tool: "run_workflow",
|
|
43
|
+
args: { url, workflow, viewport, ...(waitMs !== undefined ? { waitMs } : {}), ...throttlingOf({ cpu, network }), interactions: [CLICK_TO_COMPLETE] },
|
|
44
|
+
reason:
|
|
45
|
+
"These steps measure clicks and key presses: run the workflow again with interactions, a click or type step on an element of the page in place of this selector; runs and baseline work with them",
|
|
46
|
+
},
|
|
47
|
+
trackingCall({ paths, url, viewport, cpu, network }),
|
|
48
|
+
];
|
|
49
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -15,12 +15,13 @@ import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js
|
|
|
15
15
|
import { registerResources } from "./documents.js";
|
|
16
16
|
import { registerPrompts } from "./prompts.js";
|
|
17
17
|
import { shapeResults, createRuns, RunError } from "./summary.js";
|
|
18
|
-
import { followUpCalls,
|
|
18
|
+
import { followUpCalls, inputStepsCalls } from "./next-steps.js";
|
|
19
19
|
import { isHttpUrl } from "../url.js";
|
|
20
20
|
import { machineOf } from "../machine.js";
|
|
21
21
|
import { measurementConditions } from "../conditions.js";
|
|
22
22
|
import { prepareBaseline, compareWithBaseline } from "./baseline.js";
|
|
23
23
|
import { MAX_RUNS, measureRuns } from "../runs.js";
|
|
24
|
+
import { MIN_CPU_FACTOR, NETWORK_PRESETS, applyThrottling, throttlingOf } from "../throttling.js";
|
|
24
25
|
|
|
25
26
|
const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
|
|
26
27
|
const STAGES = 3;
|
|
@@ -72,7 +73,27 @@ const Runs = z
|
|
|
72
73
|
"Measure the page this many times, one after the other, as --runs of the CLI: the answer is the median run, plus runs with the median, min, max and agreement of each metric. With baseline, 5 or more on both sides give the statistical verdict: use 5 for a comparison, and more only when agreement says noisy. Each run is a full page load and wait, so it takes that much longer"
|
|
73
74
|
);
|
|
74
75
|
|
|
76
|
+
// The throttling of --cpu and --network, applied to the page before it loads. Without them the page runs
|
|
77
|
+
// at the speed of the machine, as the CLI does. A network of the tools is a preset: its own values are
|
|
78
|
+
// for --matrix.
|
|
79
|
+
const Cpu = z
|
|
80
|
+
.number()
|
|
81
|
+
.min(MIN_CPU_FACTOR)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe(
|
|
84
|
+
"Slow the CPU of the page down by this factor, as --cpu of the CLI. Leave it out unless the user asks for throttling or the baseline was throttled: compared with a baseline, use the cpu it was measured with (no throttling by default)"
|
|
85
|
+
);
|
|
86
|
+
const Network = z
|
|
87
|
+
.enum(Object.keys(NETWORK_PRESETS))
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(
|
|
90
|
+
"Throttle the network as DevTools does, as --network of the CLI. Leave it out unless the user asks for throttling or the baseline was throttled: compared with a baseline, use the network it was measured with (no throttling by default)"
|
|
91
|
+
);
|
|
92
|
+
|
|
75
93
|
const LooseObject = (shape) => z.looseObject(shape).meta({ additionalProperties: true });
|
|
94
|
+
const Throttling = LooseObject({})
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("With cpu or network, the throttling the page ran under, as the throttling field of the JSON of the CLI");
|
|
76
97
|
const RunsSummary = LooseObject({
|
|
77
98
|
count: z.number(),
|
|
78
99
|
failed: z.number().describe("Runs left out because they failed"),
|
|
@@ -117,6 +138,7 @@ const DETAIL_NOTE =
|
|
|
117
138
|
"Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
|
|
118
139
|
const BASELINE_NOTE = "With baseline, it also compares the run with an earlier one, as --baseline of the CLI, and says what regressed.";
|
|
119
140
|
const QUEUE_NOTE = "Measurements run one at a time, so several in one turn save no time.";
|
|
141
|
+
const THROTTLING_NOTE = "cpu and network throttle the page as --cpu and --network of the CLI, and the answer says so in throttling.";
|
|
120
142
|
|
|
121
143
|
// A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
|
|
122
144
|
// selector, the URL of a script. The tools that return what a page holds say how to read it, and so
|
|
@@ -133,6 +155,16 @@ const Step = z.discriminatedUnion("action", [
|
|
|
133
155
|
z.object({ action: z.literal("wait"), ms: z.number().int().min(0).max(10000) }),
|
|
134
156
|
]);
|
|
135
157
|
|
|
158
|
+
// The steps of interact, run after the load of each run, as --interact-script of the CLI
|
|
159
|
+
const Interactions = z
|
|
160
|
+
.array(Step)
|
|
161
|
+
.min(1)
|
|
162
|
+
.max(50)
|
|
163
|
+
.optional()
|
|
164
|
+
.describe(
|
|
165
|
+
"Steps to run on the page after the load, as --interact-script of the CLI: click, type, hover, scroll or wait, as in interact. A click or a key press is what INP, Interactions and Input-Latency-Breakdown measure. Each run repeats them, so runs and baseline work with them"
|
|
166
|
+
);
|
|
167
|
+
|
|
136
168
|
const SnippetInfo = z.object({
|
|
137
169
|
name: z.string().describe("Category/Name, the name other tools take"),
|
|
138
170
|
category: z.enum(CATEGORIES),
|
|
@@ -341,12 +373,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
341
373
|
{
|
|
342
374
|
title: "Run a snippet",
|
|
343
375
|
description:
|
|
344
|
-
`Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) run
|
|
376
|
+
`Opens the URL in a headless Chromium, runs one web performance snippet on the loaded page and returns the object the snippet returns, as defined in snippets/SCHEMA.md. Use list_snippets for the names. The snippets that record interactions (INP, Interactions, Input-Latency-Breakdown) need interactions with a click or a key press, which run after the load of each run; open_page explores a page step by step instead. ${THROTTLING_NOTE} ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
345
377
|
inputSchema: z.object({
|
|
346
378
|
url: HttpUrl,
|
|
347
379
|
snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
|
|
348
380
|
viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
|
|
349
381
|
waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the snippet, in ms"),
|
|
382
|
+
cpu: Cpu,
|
|
383
|
+
network: Network,
|
|
384
|
+
interactions: Interactions,
|
|
350
385
|
runs: Runs,
|
|
351
386
|
detail: Detail,
|
|
352
387
|
baseline: Baseline,
|
|
@@ -360,6 +395,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
360
395
|
viewport: z.enum(VIEWPORTS),
|
|
361
396
|
navMs: z.number().describe("From the start of the navigation to the end of the wait"),
|
|
362
397
|
pageErrors: z.array(z.string()).describe("Uncaught errors of the page during the run"),
|
|
398
|
+
throttling: Throttling,
|
|
363
399
|
result: SnippetResult,
|
|
364
400
|
runs: RunsSummary,
|
|
365
401
|
baseline: Comparison,
|
|
@@ -367,7 +403,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
367
403
|
}),
|
|
368
404
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
369
405
|
},
|
|
370
|
-
async ({ url, snippet, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
|
|
406
|
+
async ({ url, snippet, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
|
|
371
407
|
let path;
|
|
372
408
|
try {
|
|
373
409
|
path = resolveSnippetName(snippet);
|
|
@@ -375,8 +411,11 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
375
411
|
return toolError(err.message);
|
|
376
412
|
}
|
|
377
413
|
const source = loadSnippet(path);
|
|
378
|
-
|
|
379
|
-
|
|
414
|
+
// With interactions, the runner installs it before the steps and reads it after them
|
|
415
|
+
if (isTrackingSnippet(path, source) && !interactions) {
|
|
416
|
+
return toolError(
|
|
417
|
+
`${path} records interactions: run it with interactions that click or type on an element of the page, which run after the load, or open the page with open_page and this snippet in track, use it with interact, and read it with read_results.`
|
|
418
|
+
);
|
|
380
419
|
}
|
|
381
420
|
let base;
|
|
382
421
|
try {
|
|
@@ -387,15 +426,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
387
426
|
|
|
388
427
|
try {
|
|
389
428
|
const { conditions, run } = await measureInTurn(
|
|
390
|
-
{ viewport, snippet: path, waitMs },
|
|
391
|
-
(onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
|
|
429
|
+
{ viewport, snippet: path, waitMs, cpu, network },
|
|
430
|
+
(onProgress, signal) => runSnippets({ url, items: [{ id: path, path, source }], waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
|
|
392
431
|
count,
|
|
393
432
|
ctx
|
|
394
433
|
);
|
|
395
434
|
const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
|
|
396
435
|
const { runId, current } = keepPage(run, run.results, conditions);
|
|
397
436
|
const id = run.results[0].id;
|
|
398
|
-
const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, ran: [path] });
|
|
437
|
+
const nextSteps = followUpCalls({ results: run.results, url, viewport, waitMs, cpu, network, ran: [path] });
|
|
399
438
|
return toolResult({
|
|
400
439
|
url,
|
|
401
440
|
runId,
|
|
@@ -404,6 +443,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
404
443
|
viewport,
|
|
405
444
|
navMs: run.navMs,
|
|
406
445
|
pageErrors: run.pageErrors,
|
|
446
|
+
...(run.throttling ? { throttling: run.throttling } : {}),
|
|
407
447
|
result: shaped.results[0],
|
|
408
448
|
...(run.runs ? { runs: run.runs } : {}),
|
|
409
449
|
...(base ? { baseline: compareWithBaseline(base, current) } : {}),
|
|
@@ -422,12 +462,15 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
422
462
|
{
|
|
423
463
|
title: "Run a workflow",
|
|
424
464
|
description:
|
|
425
|
-
`Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
465
|
+
`Opens the URL in a headless Chromium and runs a workflow: a sequence of snippets plus the follow-ups its decision tree adds from the results (for example LCP-Subparts when LCP is over 2.5 s, with the reason). core-web-vitals: LCP and CLS. audit: render-blocking resources, resource hints, critical CSS, TTFB, scripts, images and more. loading: TTFB, FCP, render-blocking, scripts and fonts. interaction: long tasks, animation frames, scroll and layout shifts while it scrolls the page, or while it runs interactions, which with a click or a key press also measure INP, Interactions and Input-Latency-Breakdown. ${THROTTLING_NOTE} ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
426
466
|
inputSchema: z.object({
|
|
427
467
|
url: HttpUrl,
|
|
428
468
|
workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
|
|
429
469
|
viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
|
|
430
470
|
waitMs: z.number().int().min(0).max(10000).default(3000).describe("Wait after the load event before running the workflow, in ms"),
|
|
471
|
+
cpu: Cpu,
|
|
472
|
+
network: Network,
|
|
473
|
+
interactions: Interactions,
|
|
431
474
|
runs: Runs,
|
|
432
475
|
detail: Detail,
|
|
433
476
|
baseline: Baseline,
|
|
@@ -441,6 +484,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
441
484
|
viewport: z.enum(VIEWPORTS),
|
|
442
485
|
navMs: z.number(),
|
|
443
486
|
pageErrors: z.array(z.string()),
|
|
487
|
+
throttling: Throttling,
|
|
444
488
|
results: z
|
|
445
489
|
.array(z.looseObject({ id: z.string(), status: z.string() }).meta({ additionalProperties: true }))
|
|
446
490
|
.describe("One object per step, in order, then the follow-ups, each with the reason it was added"),
|
|
@@ -450,7 +494,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
450
494
|
}),
|
|
451
495
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
452
496
|
},
|
|
453
|
-
async ({ url, workflow, viewport, waitMs, runs: count, detail, baseline, thresholds }, ctx) => {
|
|
497
|
+
async ({ url, workflow, viewport, waitMs, cpu, network, interactions, runs: count, detail, baseline, thresholds }, ctx) => {
|
|
454
498
|
let base;
|
|
455
499
|
try {
|
|
456
500
|
base = baselineFor({ baseline, thresholds });
|
|
@@ -459,15 +503,19 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
459
503
|
}
|
|
460
504
|
try {
|
|
461
505
|
const { conditions, run } = await measureInTurn(
|
|
462
|
-
{ viewport, workflow, waitMs },
|
|
463
|
-
(onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress, signal }),
|
|
506
|
+
{ viewport, workflow, waitMs, cpu, network },
|
|
507
|
+
(onProgress, signal) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], cpu, network, interactions, onProgress, signal }),
|
|
464
508
|
count,
|
|
465
509
|
ctx
|
|
466
510
|
);
|
|
467
511
|
// The runner explains a skipped step with the flag of the CLI; here the tools are what the model has
|
|
468
512
|
const results = run.results.map((r) =>
|
|
469
513
|
r.status === "skipped"
|
|
470
|
-
? {
|
|
514
|
+
? {
|
|
515
|
+
...r,
|
|
516
|
+
reason:
|
|
517
|
+
"This step measures clicks and key presses: run the workflow with interactions that click or type on an element of the page, or open the page with open_page and this snippet in track, use it with interact, and read it with read_results.",
|
|
518
|
+
}
|
|
471
519
|
: r
|
|
472
520
|
);
|
|
473
521
|
const shaped = shapeResults(results, detail);
|
|
@@ -477,7 +525,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
477
525
|
.filter((r) => r.status === "skipped")
|
|
478
526
|
.map((r) => WORKFLOWS[workflow].steps.find((s) => s.id === r.id)?.path)
|
|
479
527
|
.filter(Boolean);
|
|
480
|
-
const nextSteps = skipped.length ?
|
|
528
|
+
const nextSteps = skipped.length ? inputStepsCalls({ workflow, paths: skipped, url, viewport, waitMs, cpu, network }) : [];
|
|
481
529
|
return toolResult({
|
|
482
530
|
url,
|
|
483
531
|
runId,
|
|
@@ -486,6 +534,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
486
534
|
viewport,
|
|
487
535
|
navMs: run.navMs,
|
|
488
536
|
pageErrors: run.pageErrors,
|
|
537
|
+
...(run.throttling ? { throttling: run.throttling } : {}),
|
|
489
538
|
...(run.runs ? { runs: run.runs } : {}),
|
|
490
539
|
...(base ? { baseline: compareWithBaseline(base, current) } : {}),
|
|
491
540
|
...(nextSteps.length ? { nextSteps } : {}),
|
|
@@ -558,23 +607,26 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
558
607
|
{
|
|
559
608
|
title: "Open a page",
|
|
560
609
|
description:
|
|
561
|
-
`Opens the URL in a headless Chromium that stays open between calls, and installs the snippets that record interactions (INP, Interactions, Input-Latency-Breakdown, and the other tracking snippets in list_snippets). Returns a sessionId for interact, read_results and close_page. At most 3 pages are open at a time, and a page closes after 10 minutes without use. ${PAGE_DATA}`,
|
|
610
|
+
`Opens the URL in a headless Chromium that stays open between calls, and installs the snippets that record interactions (INP, Interactions, Input-Latency-Breakdown, and the other tracking snippets in list_snippets). Returns a sessionId for interact, read_results and close_page. ${THROTTLING_NOTE} The throttling stays for the interactions on the page. At most 3 pages are open at a time, and a page closes after 10 minutes without use. ${PAGE_DATA}`,
|
|
562
611
|
inputSchema: z.object({
|
|
563
612
|
url: HttpUrl,
|
|
564
613
|
track: z.array(z.string()).min(1).describe("The tracking snippets to install, by name, alias or Category/Name"),
|
|
565
614
|
viewport: z.enum(VIEWPORTS).default("mobile").describe("Device size of the page"),
|
|
566
615
|
waitMs: z.number().int().min(0).max(10000).default(1000).describe("Wait after the load event before installing the snippets, in ms"),
|
|
616
|
+
cpu: Cpu,
|
|
617
|
+
network: Network,
|
|
567
618
|
}),
|
|
568
619
|
outputSchema: z.object({
|
|
569
620
|
sessionId: z.string(),
|
|
570
621
|
url: z.string(),
|
|
571
622
|
viewport: z.enum(VIEWPORTS),
|
|
572
623
|
navMs: z.number(),
|
|
624
|
+
throttling: Throttling,
|
|
573
625
|
tracking: z.array(z.object({ snippet: z.string(), status: z.string() })).describe("The installed snippets"),
|
|
574
626
|
}),
|
|
575
627
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
576
628
|
},
|
|
577
|
-
withSession(async ({ url, track, viewport, waitMs }, ctx) => {
|
|
629
|
+
withSession(async ({ url, track, viewport, waitMs, cpu, network }, ctx) => {
|
|
578
630
|
const items = [];
|
|
579
631
|
for (const name of track) {
|
|
580
632
|
let path;
|
|
@@ -600,6 +652,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
600
652
|
throw err;
|
|
601
653
|
}
|
|
602
654
|
try {
|
|
655
|
+
// Before the load, as the runner applies it: the load and every interaction run throttled
|
|
656
|
+
await applyThrottling(session.context, session.page, { cpu, network });
|
|
603
657
|
const navStart = Date.now();
|
|
604
658
|
await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
|
|
605
659
|
if (waitMs > 0) await session.page.waitForTimeout(waitMs);
|
|
@@ -609,7 +663,8 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
609
663
|
if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
|
|
610
664
|
session.stopListening();
|
|
611
665
|
const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
|
|
612
|
-
|
|
666
|
+
const throttling = throttlingOf({ cpu, network });
|
|
667
|
+
return toolResult({ sessionId: session.id, url, viewport, navMs, ...(throttling ? { throttling } : {}), tracking });
|
|
613
668
|
} catch (err) {
|
|
614
669
|
await sessions.close(session.id);
|
|
615
670
|
if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
|
|
@@ -664,7 +719,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
664
719
|
{
|
|
665
720
|
title: "Read the tracking snippets",
|
|
666
721
|
description:
|
|
667
|
-
`Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
722
|
+
`Returns what the tracking snippets installed by open_page have recorded on the page so far, as the objects they return. A snippet with nothing recorded yet, such as INP before any interaction, answers with status tracking and a message: interact with the page and read again. After a click or key press that the document did not record, as one inside an iframe, it is an error that says so: interact with an element of the page and read again. ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
668
723
|
inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
|
|
669
724
|
outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
|
|
670
725
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
package/src/runner.js
CHANGED
|
@@ -22,8 +22,9 @@ const LATE_ENTRIES_MS = 2000;
|
|
|
22
22
|
const POLL_MS = 100;
|
|
23
23
|
|
|
24
24
|
// A snippet still tracking after that wait has seen no interaction at all, and the browser agrees: a
|
|
25
|
-
// click that paints nothing is recorded too. The
|
|
26
|
-
// the
|
|
25
|
+
// click that paints nothing is recorded too. The measurement failed, so the result is an error, which
|
|
26
|
+
// exits with 1, instead of the message of the snippet (click on the page, then call the function
|
|
27
|
+
// again), which would ask for what the run has just done.
|
|
27
28
|
const NOTHING_RECORDED =
|
|
28
29
|
"A click or key press was sent, and this document recorded no interaction. Check that it reaches an element of this document: an interaction inside an iframe is recorded by the iframe.";
|
|
29
30
|
|
|
@@ -90,8 +91,10 @@ export async function collectTrackingData(page, result, hasInput) {
|
|
|
90
91
|
data = await read();
|
|
91
92
|
}
|
|
92
93
|
if (data && typeof data === "object") {
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
if (hasInput && data.status === "tracking") {
|
|
95
|
+
return { id: result.id, script: data.script, status: "error", error: NOTHING_RECORDED };
|
|
96
|
+
}
|
|
97
|
+
return { id: result.id, ...data };
|
|
95
98
|
}
|
|
96
99
|
return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
|
|
97
100
|
} catch (err) {
|
package/src/runs.js
CHANGED
|
@@ -13,7 +13,7 @@ export const MAX_RUNS = 9;
|
|
|
13
13
|
export const MEDIAN_METRICS = ["LCP", "FCP", "TTFB", "CLS", "INP", "navMs"];
|
|
14
14
|
|
|
15
15
|
// Without --median-by, the median follows the first of these that the runs measured
|
|
16
|
-
const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "navMs"];
|
|
16
|
+
const DEFAULT_ORDER = ["LCP", "FCP", "TTFB", "INP", "navMs"];
|
|
17
17
|
|
|
18
18
|
const SUMMARY_METRICS = MEDIAN_METRICS.filter((metric) => metric !== "navMs");
|
|
19
19
|
|
package/src/throttling.js
CHANGED
|
@@ -10,10 +10,13 @@ export const NETWORK_PRESETS = {
|
|
|
10
10
|
"3g": { title: "3G", download: (500 * 1000) / 8 * 0.8, upload: (500 * 1000) / 8 * 0.8, latency: 400 * 5 },
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
// The smallest CPU factor, no slowdown, for --cpu and the cpu of the tools of the MCP server
|
|
14
|
+
export const MIN_CPU_FACTOR = 1;
|
|
15
|
+
|
|
13
16
|
// The factor given to --cpu, or null when it is not a number of at least 1
|
|
14
17
|
export function parseCpuFactor(raw) {
|
|
15
18
|
const factor = Number(raw);
|
|
16
|
-
return raw.trim() !== "" && Number.isFinite(factor) && factor >=
|
|
19
|
+
return raw.trim() !== "" && Number.isFinite(factor) && factor >= MIN_CPU_FACTOR ? factor : null;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const NETWORK_VALUES = ["downloadKbps", "uploadKbps", "latencyMs"];
|