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 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.14.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
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.14.0" }
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
- ### Required secret
545
+ ### No stored token
551
546
 
552
- The `NPM_TOKEN` secret must be set in the repository settings with publish access to the `webperf-snippets` npm package.
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.14.0",
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.5",
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(images.map((img) => fetchFormat(img.currentSrc || img.src)));
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 = images.map((img, i) => {
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📋 All Images (${images.length})`, "font-weight: bold;");
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
  })();
@@ -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
- const conditionsFor = ({ viewport, cpu, network }) =>
318
- measurementConditions({ viewport, waitMs, cpu, network, machine, ...(items ? { snippet: items[0].path } : { workflow: values.workflow ?? "core-web-vitals" }) });
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
- payload.conditions = conditionsFor({ viewport: viewportName, cpu, network });
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, ...median.payload, runs: median.runs, conditions: conditionsFor(c) }
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
- export async function launchChromium(options) {
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 await chromium.launch(options);
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 () => ({ conditions: conditionsOf(conditions), run: await measurePage(measure, count, ctx, callLimitMs) }));
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 browser = await launchChromium({ headless });
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 browser = await launchChromium({ headless });
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();