webperf-snippets 1.14.0 → 1.15.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 -18
- package/package.json +2 -2
- package/snippets/Media/Image-Element-Audit.js +26 -3
- package/snippets/SCHEMA.md +3 -2
- package/src/baseline.js +19 -0
- package/src/bin.js +7 -4
- package/src/browser.js +39 -2
- package/src/conditions.js +3 -2
- package/src/mcp/server.js +6 -3
- package/src/runner.js +9 -5
package/README.md
CHANGED
|
@@ -24,6 +24,12 @@ npm install --save-dev webperf-snippets playwright
|
|
|
24
24
|
npx playwright install chromium
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
`npx playwright install chromium` downloads two builds of Chromium: the full Chromium and the headless shell. The CLI and its MCP server run the full Chromium in its headless mode, the binary of Chrome DevTools MCP and of a `--headed` run, and the headless shell only when it is the one installed (`--only-shell`). The full Chromium alone is enough, and skips the download of the headless shell (266 MB on Linux ARM64):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx playwright install --no-shell chromium
|
|
31
|
+
```
|
|
32
|
+
|
|
27
33
|
## Usage
|
|
28
34
|
|
|
29
35
|
```bash
|
|
@@ -70,10 +76,10 @@ JSON output (for piping into `jq` or CI):
|
|
|
70
76
|
npx webperf-snippets https://example.com --json
|
|
71
77
|
```
|
|
72
78
|
|
|
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.
|
|
79
|
+
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 browser (`build` `chromium` for the full Chromium or `chromium-headless-shell`, its `mode` and its `version`), 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
80
|
|
|
75
81
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
82
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "browser": { "build": "chromium", "mode": "headless", "version": "153.0.8010.12" }, "cliVersion": "1.15.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
|
|
77
83
|
```
|
|
78
84
|
|
|
79
85
|
Single snippet:
|
|
@@ -171,7 +177,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
171
177
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
172
178
|
"navMs": 1240,
|
|
173
179
|
"results": [],
|
|
174
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
180
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.15.0" }
|
|
175
181
|
}
|
|
176
182
|
```
|
|
177
183
|
|
|
@@ -297,7 +303,7 @@ The report adds the comparison after the usual one, and `--markdown` a table for
|
|
|
297
303
|
}
|
|
298
304
|
```
|
|
299
305
|
|
|
300
|
-
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
|
|
306
|
+
When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs, browser (build, mode or version) or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
|
|
301
307
|
|
|
302
308
|
The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
|
|
303
309
|
|
|
@@ -526,18 +532,7 @@ LCP, CLS and INP vary by 15 to 30% on shared runners; prefer the deterministic `
|
|
|
526
532
|
|
|
527
533
|
## Publishing
|
|
528
534
|
|
|
529
|
-
The CLI package is published to npm via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed.
|
|
530
|
-
|
|
531
|
-
### Release steps
|
|
532
|
-
|
|
533
|
-
1. Bump the version in `cli/package.json`.
|
|
534
|
-
2. Commit the version change.
|
|
535
|
-
3. Tag and push:
|
|
536
|
-
```bash
|
|
537
|
-
git tag cli-v0.2.0
|
|
538
|
-
git push origin cli-v0.2.0
|
|
539
|
-
```
|
|
540
|
-
4. The `publish-cli` CI job runs, executes the full test suite, and publishes to npm.
|
|
535
|
+
The CLI package is published to npm and to the MCP Registry via a tag-based workflow. Publishing is explicit and intentional; it only happens when a `cli-v*` tag is pushed, and it waits for a maintainer to approve it in the `npm-publish` environment. [`RELEASING.md`](https://github.com/nucliweb/webperf-snippets/blob/main/RELEASING.md) has the steps: the versions to bump, the checks, the tags, and what to do when a publication fails halfway.
|
|
541
536
|
|
|
542
537
|
### Why tag-based and not path-based
|
|
543
538
|
|
|
@@ -547,9 +542,9 @@ An alternative is to publish automatically on every push to `main` that touches
|
|
|
547
542
|
|
|
548
543
|
Tag protection rules restrict who can push `cli-v*` tags. Configure them under **Settings → Rules → New ruleset** in the repository, targeting the `cli-v*` tag pattern and limiting push access to admins or maintainers. This ensures only authorized collaborators can trigger a publish.
|
|
549
544
|
|
|
550
|
-
###
|
|
545
|
+
### No stored token
|
|
551
546
|
|
|
552
|
-
The
|
|
547
|
+
The workflow publishes with npm trusted publishing: npm exchanges the OIDC token of the run for a short-lived one, so no npm token is stored in the repository. The MCP Registry takes the same OIDC token.
|
|
553
548
|
|
|
554
549
|
## Known limitations
|
|
555
550
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@modelcontextprotocol/client": "^2.3.1",
|
|
24
24
|
"@projectwallace/css-analyzer": "9.9.5",
|
|
25
|
-
"vitest": "^5.0
|
|
25
|
+
"vitest": "^5.1.0",
|
|
26
26
|
"web-vitals": "^6.2.3"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
@@ -172,6 +172,18 @@
|
|
|
172
172
|
return { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
// An <img> with no src or srcset, and no <source> of its <picture> with one, loads nothing yet: a
|
|
176
|
+
// carousel or a lazy loader fills it in later with JavaScript. The checks of one image (its format,
|
|
177
|
+
// its loading, its dimensions) say nothing about it, so it is counted, and the practice named once.
|
|
178
|
+
const hasSource = (img) =>
|
|
179
|
+
Boolean(img.currentSrc) ||
|
|
180
|
+
Boolean(img.getAttribute("src")?.trim()) ||
|
|
181
|
+
Boolean(img.getAttribute("srcset")?.trim()) ||
|
|
182
|
+
(img.parentElement?.tagName === "PICTURE" &&
|
|
183
|
+
Array.from(img.parentElement.querySelectorAll("source")).some((source) => source.getAttribute("srcset")?.trim()));
|
|
184
|
+
const withoutSrc = images.filter((img) => !hasSource(img));
|
|
185
|
+
const withSrc = images.filter(hasSource);
|
|
186
|
+
|
|
175
187
|
const { candidate: lcpCandidate, nonImageTag } = await findLcpCandidate(images);
|
|
176
188
|
const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
|
|
177
189
|
|
|
@@ -179,7 +191,7 @@
|
|
|
179
191
|
// Uses the browser cache (force-cache) so already-loaded images
|
|
180
192
|
// don't trigger new network requests. Falls back to URL detection
|
|
181
193
|
// if the server does not expose CORS headers.
|
|
182
|
-
const formats = await Promise.all(
|
|
194
|
+
const formats = await Promise.all(withSrc.map((img) => fetchFormat(img.currentSrc || img.src)));
|
|
183
195
|
|
|
184
196
|
// @shared uniqueSelector
|
|
185
197
|
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
@@ -215,7 +227,7 @@
|
|
|
215
227
|
}
|
|
216
228
|
// @end-shared uniqueSelector
|
|
217
229
|
|
|
218
|
-
const audited =
|
|
230
|
+
const audited = withSrc.map((img, i) => {
|
|
219
231
|
const inViewport = isInViewport(img);
|
|
220
232
|
const isLcp = img === lcpCandidate;
|
|
221
233
|
const rect = img.getBoundingClientRect();
|
|
@@ -282,6 +294,8 @@
|
|
|
282
294
|
};
|
|
283
295
|
});
|
|
284
296
|
|
|
297
|
+
const WITHOUT_SRC_MESSAGE = `${withoutSrc.length} <img> without src or srcset load nothing until JavaScript fills them in, as in a carousel: put the real URL in src with loading="lazy" and width/height, so the browser loads them without waiting for the script and reserves their space`;
|
|
298
|
+
|
|
285
299
|
const withIssues = audited.filter((r) => r.issues.length > 0);
|
|
286
300
|
const totalErrors = audited.flatMap((r) => r.issues.filter((i) => i.s === "error")).length;
|
|
287
301
|
const totalWarnings = audited.flatMap((r) => r.issues.filter((i) => i.s === "warning")).length;
|
|
@@ -293,6 +307,7 @@
|
|
|
293
307
|
console.log("");
|
|
294
308
|
console.log("%cSummary", "font-weight: bold;");
|
|
295
309
|
console.log(` Total images : ${images.length}`);
|
|
310
|
+
if (withoutSrc.length > 0) console.log(` Without src : ${withoutSrc.length} (filled in later by JavaScript, not audited one by one)`);
|
|
296
311
|
console.log(` In viewport : ${audited.filter((r) => r.inViewport).length}`);
|
|
297
312
|
console.log(` Off viewport : ${audited.filter((r) => !r.inViewport).length}`);
|
|
298
313
|
console.log(
|
|
@@ -326,7 +341,7 @@
|
|
|
326
341
|
|
|
327
342
|
// Full table
|
|
328
343
|
console.log("");
|
|
329
|
-
console.group(`%c📋
|
|
344
|
+
console.group(`%c📋 Images with a source (${audited.length})`, "font-weight: bold;");
|
|
330
345
|
console.table(
|
|
331
346
|
audited.map((r) => ({
|
|
332
347
|
src: shortSrc(r.src),
|
|
@@ -376,6 +391,12 @@
|
|
|
376
391
|
console.log("%c✅ No issues found.", "color: #22c55e; font-weight: bold;");
|
|
377
392
|
}
|
|
378
393
|
|
|
394
|
+
if (withoutSrc.length > 0) {
|
|
395
|
+
console.log("");
|
|
396
|
+
console.log(`%cℹ️ ${WITHOUT_SRC_MESSAGE}`, "color: #3b82f6;");
|
|
397
|
+
console.log(" First of them:", withoutSrc[0]);
|
|
398
|
+
}
|
|
399
|
+
|
|
379
400
|
// Quick reference
|
|
380
401
|
console.log("");
|
|
381
402
|
console.group("%c📝 Quick Reference", "color: #3b82f6; font-weight: bold;");
|
|
@@ -420,6 +441,7 @@
|
|
|
420
441
|
corsLimitedAnalysis: formatGuessedCount > 0,
|
|
421
442
|
details: {
|
|
422
443
|
totalImages: images.length,
|
|
444
|
+
withoutSrc: withoutSrc.length,
|
|
423
445
|
formatGuessedCount,
|
|
424
446
|
inViewport: audited.filter((r) => r.inViewport).length,
|
|
425
447
|
offViewport: audited.filter((r) => !r.inViewport).length,
|
|
@@ -464,6 +486,7 @@
|
|
|
464
486
|
...(r.src ? { target: r.src.startsWith("data:") ? r.src.slice(0, 60) : r.src } : {}),
|
|
465
487
|
}))
|
|
466
488
|
),
|
|
489
|
+
...(withoutSrc.length > 0 ? [{ code: "images-without-src", severity: "info", message: WITHOUT_SRC_MESSAGE }] : []),
|
|
467
490
|
],
|
|
468
491
|
};
|
|
469
492
|
})();
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -1152,7 +1152,7 @@ Returns buffered long tasks immediately. Ongoing tracking continues. `items` lis
|
|
|
1152
1152
|
|
|
1153
1153
|
#### Image-Element-Audit (async)
|
|
1154
1154
|
|
|
1155
|
-
`items` holds the 50 images to look at first: the LCP image, then those with the most errors and warnings. `count` and `details` cover every image. The format comes from the `Content-Type` of the response; when a cross-origin image blocks `fetch()` it is guessed from the URL, `details.formatGuessedCount` counts those images and `corsLimitedAnalysis` is `true`.
|
|
1155
|
+
`items` holds the 50 images to look at first: the LCP image, then those with the most errors and warnings. `count` and `details` cover every image. The format comes from the `Content-Type` of the response; when a cross-origin image blocks `fetch()` it is guessed from the URL, `details.formatGuessedCount` counts those images and `corsLimitedAnalysis` is `true`. An `<img>` without `src` or `srcset`, and without a `<source>` of its `<picture>` that has one, loads nothing yet, as the slides a carousel fills in with JavaScript: `details.withoutSrc` counts those images, they stay out of `items` and of the checks of one image, and one `images-without-src` issue names them all.
|
|
1156
1156
|
|
|
1157
1157
|
```json
|
|
1158
1158
|
{
|
|
@@ -1649,7 +1649,7 @@ Each issue about one hint names its URL in `target`.
|
|
|
1649
1649
|
|
|
1650
1650
|
### Image-Element-Audit
|
|
1651
1651
|
|
|
1652
|
-
Each issue names its image in `target`, except `lcp-not-img`.
|
|
1652
|
+
Each issue names its image in `target`, except `lcp-not-img` and `images-without-src`.
|
|
1653
1653
|
|
|
1654
1654
|
| Code | Severity | When |
|
|
1655
1655
|
|------|----------|------|
|
|
@@ -1665,6 +1665,7 @@ Each issue names its image in `target`, except `lcp-not-img`.
|
|
|
1665
1665
|
| `lazy-and-high-priority` | error | An image with both `loading="lazy"` and `fetchpriority="high"` |
|
|
1666
1666
|
| `missing-dimensions` | warning | An image without `width` and `height` |
|
|
1667
1667
|
| `no-modern-format` | info | An image that is not WebP, AVIF, JPEG XL or SVG, outside a `<picture>` |
|
|
1668
|
+
| `images-without-src` | info | One or more `<img>` without `src` or `srcset`, which JavaScript fills in later; one issue for all of them |
|
|
1668
1669
|
|
|
1669
1670
|
### Oversized-Images
|
|
1670
1671
|
|
package/src/baseline.js
CHANGED
|
@@ -204,6 +204,23 @@ 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
|
+
// The full Chromium and the headless shell are two builds, and a version of either can move the timings
|
|
208
|
+
// of the same page. A baseline without a browser comes from a CLI that launched the headless shell, with
|
|
209
|
+
// Playwright 1.49 or later, so it warns only when this run used another browser.
|
|
210
|
+
const BUILDS = { chromium: "the full Chromium", "chromium-headless-shell": "the headless shell" };
|
|
211
|
+
const describeBrowser = ({ build, mode, version }) => `${BUILDS[build] ?? build} ${version}${mode === "headed" ? ", headed" : ""}`;
|
|
212
|
+
|
|
213
|
+
function browserWarning(base, current) {
|
|
214
|
+
if (!base || !current?.browser) return null;
|
|
215
|
+
if (!base.browser) {
|
|
216
|
+
const { build, mode } = current.browser;
|
|
217
|
+
if (build === "chromium-headless-shell" && mode === "headless") return null;
|
|
218
|
+
return `the baseline does not name its browser (CLI ${base.cliVersion} launched the headless shell with Playwright 1.49 or later), and this run used ${describeBrowser(current.browser)}; timings can differ for that alone`;
|
|
219
|
+
}
|
|
220
|
+
const [was, is] = [describeBrowser(base.browser), describeBrowser(current.browser)];
|
|
221
|
+
return was === is ? null : `the baseline was measured with ${was}, and this run with ${is}; timings can differ for that alone`;
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
const valueCount = (payload, metric) => measured(payload, metric).values?.length ?? 1;
|
|
208
225
|
|
|
209
226
|
// Why five runs can leave four values, when a run failed; a run that missed a metric alone does not count
|
|
@@ -251,6 +268,8 @@ function warningsOf(base, current, metrics) {
|
|
|
251
268
|
}
|
|
252
269
|
const machine = machineWarning(b?.machine, c?.machine);
|
|
253
270
|
if (machine) warnings.push(machine);
|
|
271
|
+
const browser = browserWarning(b, c);
|
|
272
|
+
if (browser) warnings.push(browser);
|
|
254
273
|
if (b && c && b.cliVersion !== c.cliVersion) {
|
|
255
274
|
warnings.push(`the baseline was measured with CLI ${b.cliVersion}, and this run with ${c.cliVersion}; the snippets may have changed`);
|
|
256
275
|
}
|
package/src/bin.js
CHANGED
|
@@ -314,8 +314,9 @@ async function main() {
|
|
|
314
314
|
// What the JSON records of how each page was measured, for a later run to compare with. The machine is
|
|
315
315
|
// measured once, before Chromium starts, so its processes do not slow the benchmark down.
|
|
316
316
|
const machine = machineOf();
|
|
317
|
-
|
|
318
|
-
|
|
317
|
+
// The browser is the one of the run the report keeps: the run itself, or the median one
|
|
318
|
+
const conditionsFor = ({ viewport, cpu, network, browser }) =>
|
|
319
|
+
measurementConditions({ viewport, waitMs, cpu, network, browser, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
|
|
319
320
|
|
|
320
321
|
if (matrixPath) {
|
|
321
322
|
let matrix;
|
|
@@ -337,7 +338,8 @@ async function main() {
|
|
|
337
338
|
if (!median.payload) throw new Error(`Every run failed: ${median.errors.join("; ")}`);
|
|
338
339
|
payload = { ...median.payload, runs: median.runs };
|
|
339
340
|
}
|
|
340
|
-
|
|
341
|
+
const { browser, ...measured } = payload;
|
|
342
|
+
payload = { ...measured, conditions: conditionsFor({ viewport: viewportName, cpu, network, browser }) };
|
|
341
343
|
|
|
342
344
|
if (cruxKey) payload.crux = await fieldDataOf(url, formFactorFor(viewportName));
|
|
343
345
|
|
|
@@ -421,9 +423,10 @@ async function runMatrix(matrixPath, matrix, measureWith, { values, waitMs, repo
|
|
|
421
423
|
const conditions = { url: c.url, viewport: VIEWPORT_PRESETS[c.viewport], storageState: c.storageState, headers: c.headers, cpu: c.cpu, network: c.network, ownDomains: c.ownDomains };
|
|
422
424
|
const median = await measureRunsWithProgress(() => measureWith(conditions), matrix.runs, matrix.medianBy, (i) => `[${String(++started).padStart(width)}/${total}] ${c.id} · run ${i}/${matrix.runs}`);
|
|
423
425
|
const identity = { id: c.id, name: c.name, device: c.device, ...(c.variant ? { variant: c.variant } : {}), url: c.url };
|
|
426
|
+
const { browser, ...measured } = median.payload ?? {};
|
|
424
427
|
combinations.push(
|
|
425
428
|
median.payload
|
|
426
|
-
? { ...identity, ...
|
|
429
|
+
? { ...identity, ...measured, runs: median.runs, conditions: conditionsFor({ ...c, browser }) }
|
|
427
430
|
: { ...identity, status: "error", error: `Every run failed: ${median.errors.join("; ")}`, runs: { count: matrix.runs, failed: matrix.runs }, conditions: conditionsFor(c) }
|
|
428
431
|
);
|
|
429
432
|
}
|
package/src/browser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { chromium } from "playwright";
|
|
2
3
|
|
|
3
4
|
// Chromium for Playwright is a download of its own: npx installs this package and its peer playwright,
|
|
@@ -13,9 +14,45 @@ export class MissingBrowserError extends Error {
|
|
|
13
14
|
|
|
14
15
|
export const isMissingBrowser = (err) => err instanceof MissingBrowserError || /Executable doesn't exist/.test(err?.message ?? "");
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Whether the full Chromium of this Playwright is on the machine. executablePath() names it in every
|
|
18
|
+
// version since 1.40, whichever of the two binaries a launch would pick.
|
|
19
|
+
function fullChromiumInstalled() {
|
|
17
20
|
try {
|
|
18
|
-
return
|
|
21
|
+
return existsSync(chromium.executablePath());
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which Chromium a launch uses. Headless, Playwright 1.49 and later picks the headless shell
|
|
28
|
+
// (chromium_headless_shell-*), a build of its own, and channel "chromium" picks the full Chromium in its
|
|
29
|
+
// headless mode, the binary Chrome DevTools MCP runs. So the full Chromium when it is installed
|
|
30
|
+
// (npx playwright install chromium installs both; --no-shell only this one), and the headless shell
|
|
31
|
+
// otherwise (--only-shell). It is checked on disk, not by a launch that fails: Playwright makes the same
|
|
32
|
+
// check before it starts the process, so the outcome is the same, without a failure in its logs.
|
|
33
|
+
// Before 1.49 there is no headless shell, and channel "chromium" is the executable a launch without a
|
|
34
|
+
// channel runs, with the same arguments. Headed, Playwright already runs the full Chromium; and a
|
|
35
|
+
// channel or an executable of the caller wins.
|
|
36
|
+
export function chromiumLaunchOptions(options = {}, { hasFullChromium = fullChromiumInstalled } = {}) {
|
|
37
|
+
if (options.headless === false || options.channel || options.executablePath) return options;
|
|
38
|
+
return hasFullChromium() ? { channel: "chromium", ...options } : options;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The browser a launch with these options ran, for the conditions of a measurement: its build (the full
|
|
42
|
+
// Chromium, "chromium", or the headless shell, "chromium-headless-shell"), its mode and its version. A
|
|
43
|
+
// later run compared with it can then say when the browser changed, which can move the timings.
|
|
44
|
+
export function launchedBrowser(options = {}, browser) {
|
|
45
|
+
const headed = options.headless === false;
|
|
46
|
+
return {
|
|
47
|
+
build: options.channel ?? (headed ? "chromium" : "chromium-headless-shell"),
|
|
48
|
+
mode: headed ? "headed" : "headless",
|
|
49
|
+
version: browser.version(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function launchChromium(options, { launch = (o) => chromium.launch(o), hasFullChromium } = {}) {
|
|
54
|
+
try {
|
|
55
|
+
return await launch(chromiumLaunchOptions(options, { hasFullChromium }));
|
|
19
56
|
} catch (err) {
|
|
20
57
|
throw isMissingBrowser(err) ? new MissingBrowserError() : err;
|
|
21
58
|
}
|
package/src/conditions.js
CHANGED
|
@@ -3,14 +3,15 @@ import { cliVersion } from "./report-to.js";
|
|
|
3
3
|
// How a measurement was taken, kept in its JSON so that a later run compared with it can tell whether
|
|
4
4
|
// both were measured the same way. A snippet is named by its path, so an alias and its path match. The
|
|
5
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 }) {
|
|
6
|
+
// the conditions of earlier versions. The browser is the one the run launched (browser.js, launchedBrowser).
|
|
7
|
+
export function measurementConditions({ viewport, workflow, snippet, waitMs, cpu, network, browser, machine }) {
|
|
8
8
|
return {
|
|
9
9
|
viewport,
|
|
10
10
|
...(snippet ? { snippet } : { workflow }),
|
|
11
11
|
waitMs,
|
|
12
12
|
...(cpu != null ? { cpu } : {}),
|
|
13
13
|
...(network != null ? { network } : {}),
|
|
14
|
+
...(browser ? { browser } : {}),
|
|
14
15
|
cliVersion,
|
|
15
16
|
...(machine ? { machine } : {}),
|
|
16
17
|
};
|
package/src/mcp/server.js
CHANGED
|
@@ -314,11 +314,14 @@ export function createServer({ launchBrowser = () => launchChromium(), maxPages,
|
|
|
314
314
|
|
|
315
315
|
// The machine goes in the conditions of every run, as in the JSON of the CLI, so a baseline measured
|
|
316
316
|
// on another one warns. It is measured once, in the turn of the first run, before its Chromium starts.
|
|
317
|
+
// The browser is the one the run launched, the median one with several runs.
|
|
317
318
|
let machineOfServer;
|
|
318
|
-
const conditionsOf = (conditions) => measurementConditions({ ...conditions, machine: (machineOfServer ??= machine()) });
|
|
319
|
-
|
|
320
319
|
const measureInTurn = (conditions, measure, count, ctx) =>
|
|
321
|
-
inTurn(ctx, STAGES * count, async () =>
|
|
320
|
+
inTurn(ctx, STAGES * count, async () => {
|
|
321
|
+
machineOfServer ??= machine();
|
|
322
|
+
const { browser, ...run } = await measurePage(measure, count, ctx, callLimitMs);
|
|
323
|
+
return { conditions: measurementConditions({ ...conditions, browser, machine: machineOfServer }), run };
|
|
324
|
+
});
|
|
322
325
|
|
|
323
326
|
// A run of a page load, kept with its page in the shape of the JSON of the CLI: its runId is the
|
|
324
327
|
// baseline of a later run, and the resources list it
|
package/src/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { launchChromium } from "./browser.js";
|
|
1
|
+
import { chromiumLaunchOptions, launchChromium, launchedBrowser } from "./browser.js";
|
|
2
2
|
import { loadSnippet } from "./load-snippet.js";
|
|
3
3
|
import { runInteractions, loadInteractionSteps, isInputStep, settle } from "./interactions.js";
|
|
4
4
|
import { isTrackingSnippet } from "./tracking.js";
|
|
@@ -131,7 +131,9 @@ export async function runSnippets({
|
|
|
131
131
|
signal,
|
|
132
132
|
}) {
|
|
133
133
|
await onProgress(1, "Opening Chromium");
|
|
134
|
-
const
|
|
134
|
+
const launchOptions = chromiumLaunchOptions({ headless });
|
|
135
|
+
const browser = await launchChromium(launchOptions);
|
|
136
|
+
const launched = launchedBrowser(launchOptions, browser);
|
|
135
137
|
const stopListening = closeOnAbort(browser, signal);
|
|
136
138
|
try {
|
|
137
139
|
const context = await browser.newContext({ viewport, storageState });
|
|
@@ -148,7 +150,7 @@ export async function runSnippets({
|
|
|
148
150
|
const prepared = items.map((item) => ({ ...item, source: withOwnDomains(item.source, ownDomains) }));
|
|
149
151
|
const results = await evaluateAroundInteractions(page, prepared, interactions);
|
|
150
152
|
await onProgress(3, "Snippets evaluated");
|
|
151
|
-
return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
153
|
+
return { url, navMs, results, pageErrors, throttling: throttlingOf({ cpu, network }), browser: launched };
|
|
152
154
|
} finally {
|
|
153
155
|
stopListening();
|
|
154
156
|
await browser.close();
|
|
@@ -174,7 +176,9 @@ export async function runMeasurement({
|
|
|
174
176
|
signal,
|
|
175
177
|
}) {
|
|
176
178
|
await onProgress(1, "Opening Chromium");
|
|
177
|
-
const
|
|
179
|
+
const launchOptions = chromiumLaunchOptions({ headless });
|
|
180
|
+
const browser = await launchChromium(launchOptions);
|
|
181
|
+
const launched = launchedBrowser(launchOptions, browser);
|
|
178
182
|
const stopListening = closeOnAbort(browser, signal);
|
|
179
183
|
try {
|
|
180
184
|
const context = await browser.newContext({ viewport, storageState });
|
|
@@ -237,7 +241,7 @@ export async function runMeasurement({
|
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
await onProgress(3, "Workflow and follow-ups evaluated");
|
|
240
|
-
return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }) };
|
|
244
|
+
return { url, navMs, results: [...initialResults, ...followUpResults], pageErrors, throttling: throttlingOf({ cpu, network }), browser: launched };
|
|
241
245
|
} finally {
|
|
242
246
|
stopListening();
|
|
243
247
|
await browser.close();
|