webperf-snippets 1.11.0 → 1.13.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 +13 -4
- package/package.json +2 -1
- package/snippets/CoreWebVitals/INP.js +20 -4
- package/snippets/CoreWebVitals/LCP-Trail.js +52 -40
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -0
- package/snippets/Interaction/Interactions.js +4 -0
- package/snippets/Loading/CSS-Complexity.js +562 -0
- package/snippets/Loading/WORKFLOWS.md +11 -0
- package/snippets/SCHEMA.md +86 -0
- package/src/baseline.js +33 -5
- package/src/bin.js +8 -1
- package/src/mcp/queue.js +72 -0
- package/src/mcp/server.js +116 -45
- package/src/runner.js +29 -1
- package/src/snippet-names.js +90 -19
package/snippets/SCHEMA.md
CHANGED
|
@@ -217,6 +217,8 @@ A script wrapped in `void (async () => {...})()` discards its return value. Neve
|
|
|
217
217
|
|
|
218
218
|
`details.element` is a CSS selector that matches only the LCP element, so `document.querySelector(details.element)` returns it: the id when it is unique, otherwise a path of tag, classes and `:nth-of-type()` from the nearest ancestor with a unique id, such as `section.intro:nth-of-type(2) > p.copy:nth-of-type(2)`. LCP-Subparts (`details.element`) and LCP-Trail (`finalElement` and each `selector`) build it the same way.
|
|
219
219
|
|
|
220
|
+
In LCP-Trail, a candidate whose element has left the DOM has `selector: null` and `removed: true`, with its `time`, `sizePixels`, `url` for an image and `id` when the element had one, and `finalElement` is `null` when the LCP element itself was removed. `value` is the time of the last entry either way.
|
|
221
|
+
|
|
220
222
|
#### CLS
|
|
221
223
|
|
|
222
224
|
Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` after interactions to get an updated value.
|
|
@@ -269,6 +271,24 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
|
|
|
269
271
|
|
|
270
272
|
If no interactions yet, `getINP()` returns `status: "tracking"` with a `message` and `getDataFn: "getINP"`: interact with the page and call it again.
|
|
271
273
|
|
|
274
|
+
An interaction that paints nothing has no frame to be measured to, so the browser measures it to the end of its handlers, often 0 ms, and gives no `event` entry under 16 ms at any `durationThreshold`. The first interaction of a page always has a `first-input` entry, which is how web-vitals reads it too, so `getINP()` returns it as `status: "ok"` with its value, and an `info` issue when the worst value is under 16 ms:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"script": "INP",
|
|
279
|
+
"status": "ok",
|
|
280
|
+
"metric": "INP",
|
|
281
|
+
"value": 0,
|
|
282
|
+
"unit": "ms",
|
|
283
|
+
"rating": "good",
|
|
284
|
+
"thresholds": { "good": 200, "needsImprovement": 500 },
|
|
285
|
+
"details": { "totalInteractions": 1, "worstEvent": "pointerdown → h1", "phases": { "inputDelay": 0, "processingTime": 0, "presentationDelay": 0 } },
|
|
286
|
+
"issues": [{ "code": "no-paint-measured", "severity": "info", "message": "Worst interaction: 0 ms, under the 16 ms of a frame, so the page most likely painted nothing after it. If it should respond, check that the click or key press reaches an element that does." }]
|
|
287
|
+
}
|
|
288
|
+
```
|
|
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 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
|
+
|
|
272
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.
|
|
273
293
|
|
|
274
294
|
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "tracking"`, a `message`, `count: 0` and no items.
|
|
@@ -806,6 +826,49 @@ Issues keep `{ severity, message }`. The fix is the last sentence of the message
|
|
|
806
826
|
}
|
|
807
827
|
```
|
|
808
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
|
+
|
|
809
872
|
#### CSS-Media-Queries-Analysis
|
|
810
873
|
|
|
811
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.
|
|
@@ -853,6 +916,8 @@ With no usage the script returns `count: 0`, no items and an `info` issue.
|
|
|
853
916
|
|
|
854
917
|
Tracking scripts return `status: "tracking"` first. The function named by `getDataFn` returns the full result. Items are capped at 50 (20 for frames) and sorted by relevance; `count` holds the total.
|
|
855
918
|
|
|
919
|
+
Interactions and Input-Latency-Breakdown read `first-input` as INP does, so an interaction that paints nothing counts when it is the first one, and the later ones under 16 ms have no entry.
|
|
920
|
+
|
|
856
921
|
#### Interactions (tracking)
|
|
857
922
|
|
|
858
923
|
```json
|
|
@@ -1202,6 +1267,12 @@ Every issue has a `code` that says what it is, so a comparison pairs the issues
|
|
|
1202
1267
|
|
|
1203
1268
|
An issue about one thing among several, one issue per thing, also names it in `target`: the URL of a resource, a CSS selector, or a name such as an event type or a CSS property. Two issues with the same code and different targets are two issues.
|
|
1204
1269
|
|
|
1270
|
+
### INP
|
|
1271
|
+
|
|
1272
|
+
| Code | Severity | When |
|
|
1273
|
+
|------|----------|------|
|
|
1274
|
+
| `no-paint-measured` | info | The worst interaction took under 16 ms, too short for a frame, so the page most likely painted nothing after it |
|
|
1275
|
+
|
|
1205
1276
|
### LCP-Image-Entropy
|
|
1206
1277
|
|
|
1207
1278
|
| Code | Severity | When |
|
|
@@ -1237,6 +1308,21 @@ An issue about one thing among several, one issue per thing, also names it in `t
|
|
|
1237
1308
|
| `could-defer` | info | Blocking scripts other than the last three of `<body>`, which could try `defer` |
|
|
1238
1309
|
| `unknown-size` | info | Scripts whose size is hidden by a missing `Timing-Allow-Origin`, so the total is a lower bound |
|
|
1239
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
|
+
|
|
1240
1326
|
### CSS-Media-Queries-Analysis
|
|
1241
1327
|
|
|
1242
1328
|
| Code | Severity | When |
|
package/src/baseline.js
CHANGED
|
@@ -204,8 +204,32 @@ function machineWarning(base, current) {
|
|
|
204
204
|
return `the baseline was measured on another machine (${base.cpu}, ${base.cores} cores) than this run (${current.cpu}, ${current.cores} cores); timings can differ for that alone`;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
const valueCount = (payload, metric) => measured(payload, metric).values?.length ?? 1;
|
|
208
|
+
|
|
209
|
+
// Why five runs can leave four values, when a run failed; a run that missed a metric alone does not count
|
|
210
|
+
function afterFailedRuns(payload) {
|
|
211
|
+
const failed = payload.runs?.failed ?? 0;
|
|
212
|
+
return failed ? ` after ${failed} failed run${failed === 1 ? "" : "s"}` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The metrics compared on both sides that have no statistics, one line for each set of numbers. A
|
|
216
|
+
// metric that one side lacks has no comparison to back, and its verdict already says "not measured".
|
|
217
|
+
function statisticsWarnings(base, current, metrics) {
|
|
218
|
+
const metricsByNumbers = new Map();
|
|
219
|
+
for (const [metric, { stats, verdict }] of Object.entries(metrics)) {
|
|
220
|
+
if (stats || verdict === "not measured") continue;
|
|
221
|
+
const inBase = valueCount(base, metric);
|
|
222
|
+
const numbers = `the baseline has ${inBase} value${inBase === 1 ? "" : "s"}${afterFailedRuns(base)}, and this run ${valueCount(current, metric)}${afterFailedRuns(current)}`;
|
|
223
|
+
metricsByNumbers.set(numbers, [...(metricsByNumbers.get(numbers) ?? []), metric]);
|
|
224
|
+
}
|
|
225
|
+
return [...metricsByNumbers].map(
|
|
226
|
+
([numbers, names]) => `no statistics for ${names.join(", ")}: ${numbers}; they need ${RUNS_FOR_STATISTICS} a side, so measure both with ${RUNS_FOR_STATISTICS} runs or more`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// What differs in how both were measured, and what it cost. The comparison goes on: the warnings say
|
|
231
|
+
// how far to trust it.
|
|
232
|
+
function warningsOf(base, current, metrics) {
|
|
209
233
|
const warnings = [];
|
|
210
234
|
const b = base.conditions;
|
|
211
235
|
const c = current.conditions;
|
|
@@ -221,7 +245,10 @@ function warningsOf(base, current) {
|
|
|
221
245
|
}
|
|
222
246
|
// In words for both: the CLI asks for runs with --runs, and the tools of the MCP server with runs
|
|
223
247
|
if (runCount(base) < 2) warnings.push(`the baseline has 1 run, so only the thresholds apply; measure both with ${RUNS_FOR_STATISTICS} runs or more`);
|
|
224
|
-
else
|
|
248
|
+
else {
|
|
249
|
+
if (runCount(base) !== runCount(current)) warnings.push(`the baseline has ${runCount(base)} runs, and this run ${runCount(current)}`);
|
|
250
|
+
warnings.push(...statisticsWarnings(base, current, metrics));
|
|
251
|
+
}
|
|
225
252
|
const machine = machineWarning(b?.machine, c?.machine);
|
|
226
253
|
if (machine) warnings.push(machine);
|
|
227
254
|
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
@@ -235,13 +262,14 @@ function warningsOf(base, current) {
|
|
|
235
262
|
// A page against its baseline, as the `baseline` field of its JSON. The runs of the baseline say how
|
|
236
263
|
// much to trust its values, in the header of the reports.
|
|
237
264
|
export function compareRun(base, current, { file, thresholds = DEFAULT_THRESHOLDS }) {
|
|
265
|
+
const metrics = compareMetrics(base, current, thresholds);
|
|
238
266
|
return {
|
|
239
267
|
file,
|
|
240
268
|
url: base.url,
|
|
241
269
|
runs: base.runs ? { count: base.runs.count, medianBy: base.runs.medianBy } : { count: 1 },
|
|
242
|
-
metrics
|
|
270
|
+
metrics,
|
|
243
271
|
errors: compareErrors(base, current),
|
|
244
|
-
warnings: warningsOf(base, current),
|
|
272
|
+
warnings: warningsOf(base, current, metrics),
|
|
245
273
|
};
|
|
246
274
|
}
|
|
247
275
|
|
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,
|
|
@@ -89,6 +89,7 @@ Options:
|
|
|
89
89
|
--verbose Show all items, even for passing checks
|
|
90
90
|
--headed Show the browser window (debug)
|
|
91
91
|
-h, --help Show this help
|
|
92
|
+
-v, --version Show the version
|
|
92
93
|
|
|
93
94
|
Examples:
|
|
94
95
|
npx webperf-snippets https://example.com
|
|
@@ -182,6 +183,7 @@ async function main() {
|
|
|
182
183
|
verbose: { type: "boolean" },
|
|
183
184
|
headed: { type: "boolean" },
|
|
184
185
|
help: { type: "boolean", short: "h" },
|
|
186
|
+
version: { type: "boolean", short: "v" },
|
|
185
187
|
},
|
|
186
188
|
allowPositionals: true,
|
|
187
189
|
});
|
|
@@ -196,6 +198,11 @@ async function main() {
|
|
|
196
198
|
return;
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
if (values.version) {
|
|
202
|
+
process.stdout.write(`${cliVersion}\n`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
199
206
|
const url = positionals[0];
|
|
200
207
|
const matrixPath = values.matrix;
|
|
201
208
|
if (url && matrixPath) fail("Use a URL or --matrix, not both");
|
package/src/mcp/queue.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The reason of an abort is whatever the client sent, often a string, or nothing: a caller gets an error
|
|
2
|
+
const gaveUp = () => new Error("The caller gave up while it waited for its turn");
|
|
3
|
+
|
|
4
|
+
// One task at a time, in the order they arrive. A caller that gives up while it waits leaves the line
|
|
5
|
+
// at once, so it never runs and holds up no one. A task that fails passes the turn like any other, and
|
|
6
|
+
// its failure stays with its caller.
|
|
7
|
+
//
|
|
8
|
+
// A waiting caller hears how many tasks are ahead when it starts to wait, whenever the line moves, and
|
|
9
|
+
// every `reportEveryMs` while it does not: a client can restart its timeout on each report, and a task
|
|
10
|
+
// ahead can take minutes.
|
|
11
|
+
export function createQueue({ reportEveryMs = 5000 } = {}) {
|
|
12
|
+
const waiting = [];
|
|
13
|
+
let taken = false;
|
|
14
|
+
|
|
15
|
+
// A report is a courtesy: one that fails, as one to a client that left, changes nothing
|
|
16
|
+
const report = async ({ onWait }, ahead) => {
|
|
17
|
+
try {
|
|
18
|
+
await onWait?.(ahead);
|
|
19
|
+
} catch {
|
|
20
|
+
// nothing to tell, and no one to tell it to
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const reportAll = () => waiting.forEach((waiter, i) => report(waiter, i + 1));
|
|
24
|
+
|
|
25
|
+
function take({ signal, onWait }) {
|
|
26
|
+
if (signal?.aborted) return Promise.reject(gaveUp());
|
|
27
|
+
if (!taken) {
|
|
28
|
+
taken = true;
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const waiter = { onWait };
|
|
33
|
+
const leave = () => {
|
|
34
|
+
waiting.splice(waiting.indexOf(waiter), 1);
|
|
35
|
+
clearInterval(timer);
|
|
36
|
+
signal?.removeEventListener("abort", cancel);
|
|
37
|
+
};
|
|
38
|
+
const cancel = () => {
|
|
39
|
+
leave();
|
|
40
|
+
reject(gaveUp());
|
|
41
|
+
reportAll();
|
|
42
|
+
};
|
|
43
|
+
waiter.enter = () => {
|
|
44
|
+
leave();
|
|
45
|
+
resolve();
|
|
46
|
+
};
|
|
47
|
+
const timer = setInterval(() => report(waiter, waiting.indexOf(waiter) + 1), reportEveryMs);
|
|
48
|
+
// The timer alone must not keep the process alive
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
51
|
+
waiting.push(waiter);
|
|
52
|
+
report(waiter, waiting.length);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pass() {
|
|
57
|
+
taken = waiting.length > 0;
|
|
58
|
+
waiting[0]?.enter();
|
|
59
|
+
reportAll();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function run(task, options = {}) {
|
|
63
|
+
await take(options);
|
|
64
|
+
try {
|
|
65
|
+
return await task();
|
|
66
|
+
} finally {
|
|
67
|
+
pass();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { run };
|
|
72
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { RULES } from "../decision-tree.js";
|
|
|
10
10
|
import { fetchCrux, fetchCruxHistory } from "../crux.js";
|
|
11
11
|
import { runInteractions, isInputStep, settle } from "../interactions.js";
|
|
12
12
|
import { createSessions, SessionError } from "./sessions.js";
|
|
13
|
+
import { createQueue } from "./queue.js";
|
|
13
14
|
import { launchChromium, isMissingBrowser, MISSING_BROWSER } from "../browser.js";
|
|
14
15
|
import { registerResources } from "./documents.js";
|
|
15
16
|
import { registerPrompts } from "./prompts.js";
|
|
@@ -24,6 +25,10 @@ import { MAX_RUNS, measureRuns } from "../runs.js";
|
|
|
24
25
|
const VIEWPORTS = Object.keys(VIEWPORT_PRESETS);
|
|
25
26
|
const STAGES = 3;
|
|
26
27
|
const NAV_TIMEOUT_MS = 30000;
|
|
28
|
+
// How long a page load can keep its turn: far beyond the load and the wait, which have their own limits
|
|
29
|
+
// (30 s and 10 s), because a run that reaches this is not slow. A page whose main thread never yields
|
|
30
|
+
// answers no evaluation, and Playwright has no timeout for one, so it would hold up the queue for ever.
|
|
31
|
+
const CALL_LIMIT_MS = 120000;
|
|
27
32
|
|
|
28
33
|
const HttpUrl = z
|
|
29
34
|
.string()
|
|
@@ -111,6 +116,7 @@ const NextSteps = z
|
|
|
111
116
|
const DETAIL_NOTE =
|
|
112
117
|
"Large results come as summaries with a runId for get_details; nextSteps, when present, is the call to make next.";
|
|
113
118
|
const BASELINE_NOTE = "With baseline, it also compares the run with an earlier one, as --baseline of the CLI, and says what regressed.";
|
|
119
|
+
const QUEUE_NOTE = "Measurements run one at a time, so several in one turn save no time.";
|
|
114
120
|
|
|
115
121
|
// A page can put instructions for the agent in what a snippet returns: an alt text, a class in a
|
|
116
122
|
// selector, the URL of a script. The tools that return what a page holds say how to read it, and so
|
|
@@ -162,12 +168,44 @@ function progressFor(ctx, runs) {
|
|
|
162
168
|
});
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
// The report of a call that waits for its turn, for a client that wants progress: how many calls are
|
|
172
|
+
// ahead. Progress has to grow with each report, and the stages of a run start at 1, so it stays below 1.
|
|
173
|
+
function waitProgressFor(ctx, total) {
|
|
174
|
+
const progressToken = ctx.mcpReq._meta?.progressToken;
|
|
175
|
+
if (progressToken === undefined) return undefined;
|
|
176
|
+
let reports = 0;
|
|
177
|
+
return (ahead) => {
|
|
178
|
+
reports++;
|
|
179
|
+
const message = `Waiting for its turn: ${ahead} ${ahead === 1 ? "call" : "calls"} ahead`;
|
|
180
|
+
return ctx.mcpReq.notify({ method: "notifications/progress", params: { progressToken, progress: reports / (reports + 1), ...(total ? { total } : {}), message } });
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const stoppedAnswering = (limitMs) => `The page did not answer within ${limitMs / 1000} seconds`;
|
|
185
|
+
|
|
186
|
+
// A page that stopped answering is closed, and the runs left of the call would only hold up the queue
|
|
187
|
+
class PageStoppedError extends Error {}
|
|
188
|
+
const endsTheCall = (err) => isMissingBrowser(err) || err instanceof PageStoppedError;
|
|
189
|
+
|
|
165
190
|
// A page load measured once, or `count` times with the median run as the answer, as --runs of the CLI.
|
|
166
|
-
//
|
|
167
|
-
|
|
191
|
+
// `measure` gets the signal of its run, which closes its Chromium. When the client gives up (a cancel,
|
|
192
|
+
// its timeout, or leaving), the run in progress ends and the runs left never start.
|
|
193
|
+
async function measurePage(measure, count, ctx, limitMs) {
|
|
168
194
|
const progress = progressFor(ctx, count);
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
const measureRun = async (run) => {
|
|
196
|
+
const limit = AbortSignal.timeout(limitMs);
|
|
197
|
+
let measured;
|
|
198
|
+
try {
|
|
199
|
+
measured = await measure(progress(run), AbortSignal.any([ctx.mcpReq.signal, limit]));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (!limit.aborted) throw err;
|
|
202
|
+
}
|
|
203
|
+
// The runner keeps a failed evaluation as a result, and closing Chromium fails every one
|
|
204
|
+
if (limit.aborted) throw new PageStoppedError(stoppedAnswering(limitMs));
|
|
205
|
+
return measured;
|
|
206
|
+
};
|
|
207
|
+
if (count === 1) return measureRun(1);
|
|
208
|
+
const { payload, runs, errors } = await measureRuns(measureRun, count, { isFatal: endsTheCall, signal: ctx.mcpReq.signal });
|
|
171
209
|
if (!payload) throw new Error(`Every run failed: ${[...new Set(errors.map(firstLine))].join("; ")}`);
|
|
172
210
|
return { ...payload, runs };
|
|
173
211
|
}
|
|
@@ -224,10 +262,18 @@ function registerRuns(server, runs) {
|
|
|
224
262
|
|
|
225
263
|
// The MCP server of the CLI: the same snippets and runner, offered as tools an agent calls by name.
|
|
226
264
|
// serveStdio creates one instance per connection, so the pages it keeps open belong to that client.
|
|
227
|
-
export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env, machine = machineOf } = {}) {
|
|
265
|
+
export function createServer({ launchBrowser = () => launchChromium(), maxPages, idleMs, env = process.env, machine = machineOf, callLimitMs = CALL_LIMIT_MS, reportEveryMs } = {}) {
|
|
228
266
|
const server = new McpServer({ name: "webperf-snippets", version: cliVersion }, { instructions: INSTRUCTIONS });
|
|
229
267
|
const sessions = createSessions({ launchBrowser, maxPages, idleMs });
|
|
230
268
|
const runs = createRuns();
|
|
269
|
+
// An agent sends several calls in one turn and the client runs them at once. The calls that make a page
|
|
270
|
+
// work take turns (run_snippet, run_workflow, open_page, interact): a load or a click competes for the
|
|
271
|
+
// CPU with any other, which inflates what each page reports. A call keeps its turn only while it runs,
|
|
272
|
+
// so it can wait only for something that ends on its own: never for a later call of the agent, and
|
|
273
|
+
// never for a page left open, which keeps no turn. read_results and close_page take no turn: they ask
|
|
274
|
+
// little of Chromium, and they are how an agent gets out of a page.
|
|
275
|
+
const queue = createQueue({ reportEveryMs });
|
|
276
|
+
const inTurn = (ctx, total, task) => queue.run(task, { signal: ctx.mcpReq.signal, onWait: waitProgressFor(ctx, total) });
|
|
231
277
|
// The client left (end of stdin, or a closed transport): close the pages and Chromium
|
|
232
278
|
server.server.onclose = () => sessions.closeAll();
|
|
233
279
|
registerResources(server);
|
|
@@ -235,10 +281,13 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
235
281
|
registerRuns(server, runs);
|
|
236
282
|
|
|
237
283
|
// The machine goes in the conditions of every run, as in the JSON of the CLI, so a baseline measured
|
|
238
|
-
// on another one warns. It is measured once,
|
|
284
|
+
// on another one warns. It is measured once, in the turn of the first run, before its Chromium starts.
|
|
239
285
|
let machineOfServer;
|
|
240
286
|
const conditionsOf = (conditions) => measurementConditions({ ...conditions, machine: (machineOfServer ??= machine()) });
|
|
241
287
|
|
|
288
|
+
const measureInTurn = (conditions, measure, count, ctx) =>
|
|
289
|
+
inTurn(ctx, STAGES * count, async () => ({ conditions: conditionsOf(conditions), run: await measurePage(measure, count, ctx, callLimitMs) }));
|
|
290
|
+
|
|
242
291
|
// A run of a page load, kept with its page in the shape of the JSON of the CLI: its runId is the
|
|
243
292
|
// baseline of a later run, and the resources list it
|
|
244
293
|
const keepPage = (run, results, conditions) => {
|
|
@@ -292,7 +341,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
292
341
|
{
|
|
293
342
|
title: "Run a snippet",
|
|
294
343
|
description:
|
|
295
|
-
`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 on a page opened with open_page instead. ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
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 on a page opened with open_page instead. ${QUEUE_NOTE} ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
296
345
|
inputSchema: z.object({
|
|
297
346
|
url: HttpUrl,
|
|
298
347
|
snippet: z.string().describe("The snippet: its name (LCP-Subparts), an alias (render-blocking) or Category/Name"),
|
|
@@ -337,8 +386,12 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
337
386
|
}
|
|
338
387
|
|
|
339
388
|
try {
|
|
340
|
-
const
|
|
341
|
-
|
|
389
|
+
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 }),
|
|
392
|
+
count,
|
|
393
|
+
ctx
|
|
394
|
+
);
|
|
342
395
|
const shaped = shapeResults(run.results.map(({ id, ...result }) => result), detail);
|
|
343
396
|
const { runId, current } = keepPage(run, run.results, conditions);
|
|
344
397
|
const id = run.results[0].id;
|
|
@@ -369,7 +422,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
369
422
|
{
|
|
370
423
|
title: "Run a workflow",
|
|
371
424
|
description:
|
|
372
|
-
`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. ${BASELINE_NOTE} ${DETAIL_NOTE} ${PAGE_DATA}`,
|
|
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}`,
|
|
373
426
|
inputSchema: z.object({
|
|
374
427
|
url: HttpUrl,
|
|
375
428
|
workflow: z.enum(Object.keys(WORKFLOWS)).default("core-web-vitals"),
|
|
@@ -405,9 +458,9 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
405
458
|
return toolError(err.message);
|
|
406
459
|
}
|
|
407
460
|
try {
|
|
408
|
-
const conditions
|
|
409
|
-
|
|
410
|
-
(onProgress) => runMeasurement({ url, workflow: WORKFLOWS[workflow], rules: RULES, waitMs, viewport: VIEWPORT_PRESETS[viewport], onProgress }),
|
|
461
|
+
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 }),
|
|
411
464
|
count,
|
|
412
465
|
ctx
|
|
413
466
|
);
|
|
@@ -537,27 +590,33 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
537
590
|
items.push({ id: path, path, source });
|
|
538
591
|
}
|
|
539
592
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
session
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
593
|
+
return inTurn(ctx, undefined, async () => {
|
|
594
|
+
const limit = AbortSignal.timeout(callLimitMs);
|
|
595
|
+
let session;
|
|
596
|
+
try {
|
|
597
|
+
session = await sessions.create({ viewport: VIEWPORT_PRESETS[viewport], signal: AbortSignal.any([ctx.mcpReq.signal, limit]) });
|
|
598
|
+
} catch (err) {
|
|
599
|
+
if (isMissingBrowser(err)) return toolError(MISSING_BROWSER);
|
|
600
|
+
throw err;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
const navStart = Date.now();
|
|
604
|
+
await session.page.goto(url, { waitUntil: "load", timeout: NAV_TIMEOUT_MS });
|
|
605
|
+
if (waitMs > 0) await session.page.waitForTimeout(waitMs);
|
|
606
|
+
const navMs = Date.now() - navStart;
|
|
607
|
+
session.tracking = await evaluateItems(session.page, items);
|
|
608
|
+
// evaluateItems keeps a failed evaluation as a result, and closing the page fails every one
|
|
609
|
+
if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
|
|
610
|
+
session.stopListening();
|
|
611
|
+
const tracking = session.tracking.map(({ id, status }) => ({ snippet: id, status }));
|
|
612
|
+
return toolResult({ sessionId: session.id, url, viewport, navMs, tracking });
|
|
613
|
+
} catch (err) {
|
|
614
|
+
await sessions.close(session.id);
|
|
615
|
+
if (limit.aborted) return toolError(`Opening the page failed: ${stoppedAnswering(callLimitMs)}`);
|
|
616
|
+
if (/page\.goto/.test(err.message)) return toolError(`Could not load ${url}: ${firstLine(err.message)}`);
|
|
617
|
+
return toolError(`Opening the page failed: ${firstLine(err.message)}`);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
561
620
|
})
|
|
562
621
|
);
|
|
563
622
|
|
|
@@ -575,16 +634,28 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
575
634
|
// A click can submit a form or change data on the site, so this tool is not read-only
|
|
576
635
|
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
577
636
|
},
|
|
578
|
-
withSession(async ({ sessionId, steps }) => {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
637
|
+
withSession(async ({ sessionId, steps }, ctx) => {
|
|
638
|
+
// A page that is not open answers now, not after a turn it does not need
|
|
639
|
+
sessions.get(sessionId);
|
|
640
|
+
return inTurn(ctx, undefined, async () => {
|
|
641
|
+
const session = sessions.get(sessionId);
|
|
642
|
+
// A wait step is time the agent asked for. Closing the page fails whatever it stopped answering.
|
|
643
|
+
const limitMs = callLimitMs + steps.reduce((sum, step) => sum + (step.ms ?? 0), 0);
|
|
644
|
+
const limit = AbortSignal.timeout(limitMs);
|
|
645
|
+
const closePage = () => sessions.close(sessionId);
|
|
646
|
+
limit.addEventListener("abort", closePage, { once: true });
|
|
647
|
+
try {
|
|
648
|
+
await runInteractions(session.page, steps);
|
|
649
|
+
session.hadInput ||= steps.some(isInputStep);
|
|
650
|
+
await settle(session.page);
|
|
651
|
+
if (!limit.aborted) return toolResult({ sessionId, steps: steps.length, url: session.page.url() });
|
|
652
|
+
} catch (err) {
|
|
653
|
+
if (!limit.aborted) return toolError(`A step failed: ${firstLine(err.message)}`);
|
|
654
|
+
} finally {
|
|
655
|
+
limit.removeEventListener("abort", closePage);
|
|
656
|
+
}
|
|
657
|
+
return toolError(`${stoppedAnswering(limitMs)}, and it was closed. Open it again with open_page.`);
|
|
658
|
+
});
|
|
588
659
|
})
|
|
589
660
|
);
|
|
590
661
|
|
|
@@ -593,7 +664,7 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
593
664
|
{
|
|
594
665
|
title: "Read the tracking snippets",
|
|
595
666
|
description:
|
|
596
|
-
`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}`,
|
|
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. 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}`,
|
|
597
668
|
inputSchema: z.object({ sessionId: SessionId, detail: Detail }),
|
|
598
669
|
outputSchema: z.object({ sessionId: z.string(), runId: SummaryRunId, detail: DetailOut, pageErrors: z.array(z.string()), results: z.array(SnippetResult) }),
|
|
599
670
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
package/src/runner.js
CHANGED
|
@@ -21,6 +21,13 @@ const DEFAULT_NAV_TIMEOUT = 30000;
|
|
|
21
21
|
const LATE_ENTRIES_MS = 2000;
|
|
22
22
|
const POLL_MS = 100;
|
|
23
23
|
|
|
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 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.
|
|
28
|
+
const NOTHING_RECORDED =
|
|
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.";
|
|
30
|
+
|
|
24
31
|
// Snippets are IIFEs. Playwright evaluates a string as an expression, so we
|
|
25
32
|
// trim trailing semicolons to keep the IIFE call as a single expression and
|
|
26
33
|
// recover its return value in Node.
|
|
@@ -83,13 +90,28 @@ export async function collectTrackingData(page, result, hasInput) {
|
|
|
83
90
|
await page.waitForTimeout(POLL_MS);
|
|
84
91
|
data = await read();
|
|
85
92
|
}
|
|
86
|
-
if (data && typeof data === "object")
|
|
93
|
+
if (data && typeof data === "object") {
|
|
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 };
|
|
98
|
+
}
|
|
87
99
|
return { id: result.id, status: "error", error: `${result.getDataFn}() did not return an object` };
|
|
88
100
|
} catch (err) {
|
|
89
101
|
return { id: result.id, status: "error", error: `${result.getDataFn}() failed: ${err.message}` };
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
// An aborted signal closes Chromium, which fails whatever the run is waiting for, a page that stopped
|
|
106
|
+
// answering included: Playwright has no timeout for an evaluation. Returns what stops listening.
|
|
107
|
+
function closeOnAbort(browser, signal) {
|
|
108
|
+
if (!signal) return () => {};
|
|
109
|
+
const close = () => browser.close().catch(() => {});
|
|
110
|
+
if (signal.aborted) close();
|
|
111
|
+
signal.addEventListener("abort", close, { once: true });
|
|
112
|
+
return () => signal.removeEventListener("abort", close);
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
export async function runSnippets({
|
|
94
116
|
url,
|
|
95
117
|
items,
|
|
@@ -106,9 +128,11 @@ export async function runSnippets({
|
|
|
106
128
|
ownDomains,
|
|
107
129
|
// Called at each of the three stages of a run, for callers that report progress (the MCP server)
|
|
108
130
|
onProgress = () => {},
|
|
131
|
+
signal,
|
|
109
132
|
}) {
|
|
110
133
|
await onProgress(1, "Opening Chromium");
|
|
111
134
|
const browser = await launchChromium({ headless });
|
|
135
|
+
const stopListening = closeOnAbort(browser, signal);
|
|
112
136
|
try {
|
|
113
137
|
const context = await browser.newContext({ viewport, storageState });
|
|
114
138
|
const page = await context.newPage();
|
|
@@ -126,6 +150,7 @@ export async function runSnippets({
|
|
|
126
150
|
await onProgress(3, "Snippets evaluated");
|
|
127
151
|
return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
128
152
|
} finally {
|
|
153
|
+
stopListening();
|
|
129
154
|
await browser.close();
|
|
130
155
|
}
|
|
131
156
|
}
|
|
@@ -146,9 +171,11 @@ export async function runMeasurement({
|
|
|
146
171
|
network,
|
|
147
172
|
ownDomains,
|
|
148
173
|
onProgress = () => {},
|
|
174
|
+
signal,
|
|
149
175
|
}) {
|
|
150
176
|
await onProgress(1, "Opening Chromium");
|
|
151
177
|
const browser = await launchChromium({ headless });
|
|
178
|
+
const stopListening = closeOnAbort(browser, signal);
|
|
152
179
|
try {
|
|
153
180
|
const context = await browser.newContext({ viewport, storageState });
|
|
154
181
|
const page = await context.newPage();
|
|
@@ -212,6 +239,7 @@ export async function runMeasurement({
|
|
|
212
239
|
await onProgress(3, "Workflow and follow-ups evaluated");
|
|
213
240
|
return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
214
241
|
} finally {
|
|
242
|
+
stopListening();
|
|
215
243
|
await browser.close();
|
|
216
244
|
}
|
|
217
245
|
}
|