browsertime 27.3.0 → 27.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 27.4.0 - 2026-05-24
4
+
5
+ ### Added
6
+ * JS/CSS coverage collection for Chrome. `--chrome.coverage` turns it on for every iteration for users who want the data and accept that detailed V8 coverage deoptimizes scripts and will skew their timing metrics. `--enableProfileRun` also turns coverage on for Chrome alongside the existing trace, so users who want coverage without affecting their timings can lean on the same extra-iteration pattern they already use for tracing — the result is merged back into the main `browsertime.json` so consumers like sitespeed.io see the data in the same place they read every other metric. Each entry has a per-file breakdown (`url`, `totalBytes`, `usedBytes`, `unusedBytes`, `unusedPercent`) [#2480](https://github.com/sitespeedio/browsertime/pull/2480).
7
+
8
+ ### Fixed
9
+ * HAR post-processing no longer throws when a page has no matching entries. Occasionally a HAR contains a page whose `pageref` has no entries — typically a navigation that didn't produce any requests, or an extension-injected page. `getDocumentRequests` and `getFinalURL` dereferenced `shift()` / `pop()` without a guard, and `getMainDocumentTimings` wrapped its entire per-page loop in a single try/catch so one bad page wiped out the main-document timings for every other page in the same HAR. Downstream this surfaced as a noisy triplet of warnings followed by an empty coach-data payload in sitespeed.io. The three crash points are now guarded and unresolvable pages are skipped [#2483](https://github.com/sitespeedio/browsertime/pull/2483).
10
+ * Bumped `geckodriver` to a version with native ARM-64 support [#2482](https://github.com/sitespeedio/browsertime/pull/2482).
11
+
3
12
  ## 27.3.0 - 2026-05-20
4
13
 
5
14
  ### Added
@@ -95,12 +95,16 @@ async function run(urls, options) {
95
95
  scriptsByCategory = merge(scriptsByCategory, userScripts);
96
96
  }
97
97
 
98
+ let result;
99
+ let storageManager;
100
+ let jsonName;
101
+
98
102
  try {
99
103
  if (options.preWarmServer) {
100
104
  await preWarmServer(urls, options);
101
105
  }
102
106
  await engine.start();
103
- const result = await engine.runMultiple(urls, scriptsByCategory);
107
+ result = await engine.runMultiple(urls, scriptsByCategory);
104
108
  let saveOperations = [];
105
109
 
106
110
  // TODO setup by name
@@ -109,9 +113,9 @@ async function run(urls, options) {
109
113
  if (Array.isArray(firstUrl)) {
110
114
  firstUrl = firstUrl[0];
111
115
  }
112
- const storageManager = new StorageManager(firstUrl, options);
116
+ storageManager = new StorageManager(firstUrl, options);
113
117
  const harName = options.har ?? 'browsertime';
114
- const jsonName = options.output ?? 'browsertime';
118
+ jsonName = options.output ?? 'browsertime';
115
119
 
116
120
  saveOperations.push(storageManager.writeJson(jsonName + '.json', result));
117
121
 
@@ -169,6 +173,7 @@ async function run(urls, options) {
169
173
  options.chrome.traceCategory = [
170
174
  'disabled-by-default-v8.cpu_profiler'
171
175
  ];
176
+ options.chrome.coverage = true;
172
177
  }
173
178
  }
174
179
  if (options.enableVideoRun) {
@@ -186,9 +191,30 @@ async function run(urls, options) {
186
191
  }
187
192
  const traceEngine = new Engine(options);
188
193
  await traceEngine.start();
189
- await traceEngine.runMultiple(urls, scriptsByCategory);
194
+ const profileResult = await traceEngine.runMultiple(
195
+ urls,
196
+ scriptsByCategory
197
+ );
190
198
  await traceEngine.stop();
191
199
  log.info('Extra run finished');
200
+
201
+ // Fill the main JSON's coverage from the profile run only when
202
+ // the main iterations didn't collect any (--enableProfileRun
203
+ // without --chrome.coverage). Otherwise the main run's
204
+ // per-iteration samples are what the user asked for.
205
+ let mergedCoverage = false;
206
+ for (const [i, pr] of profileResult.entries()) {
207
+ const main = result[i];
208
+ if (!main || !pr.coverage) continue;
209
+ if (main.coverage.js.length > 0 || main.coverage.css.length > 0) {
210
+ continue;
211
+ }
212
+ main.coverage = pr.coverage;
213
+ mergedCoverage = true;
214
+ }
215
+ if (mergedCoverage) {
216
+ await storageManager.writeJson(jsonName + '.json', result);
217
+ }
192
218
  }
193
219
  } catch (error) {
194
220
  log.error('Error running browsertime', error);
@@ -0,0 +1,198 @@
1
+ import { getLogger } from '@sitespeed.io/log';
2
+
3
+ const log = getLogger('browsertime.chrome.coverage');
4
+
5
+ // Sum the union length of half-open [start, end) ranges.
6
+ function unionLength(ranges) {
7
+ if (ranges.length === 0) return 0;
8
+ const sorted = ranges
9
+ .map(r => [r.startOffset, r.endOffset])
10
+ .toSorted((a, b) => a[0] - b[0]);
11
+ let total = 0;
12
+ let [curStart, curEnd] = sorted[0];
13
+ for (let i = 1; i < sorted.length; i++) {
14
+ const [s, e] = sorted[i];
15
+ if (s <= curEnd) {
16
+ if (e > curEnd) curEnd = e;
17
+ } else {
18
+ total += curEnd - curStart;
19
+ curStart = s;
20
+ curEnd = e;
21
+ }
22
+ }
23
+ total += curEnd - curStart;
24
+ return total;
25
+ }
26
+
27
+ function summarize(files) {
28
+ let totalBytes = 0;
29
+ let usedBytes = 0;
30
+ for (const file of files) {
31
+ totalBytes += file.totalBytes;
32
+ usedBytes += file.usedBytes;
33
+ }
34
+ const unusedBytes = totalBytes - usedBytes;
35
+ return {
36
+ totalBytes,
37
+ usedBytes,
38
+ unusedBytes,
39
+ unusedPercent: totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0
40
+ };
41
+ }
42
+
43
+ export class Coverage {
44
+ constructor(cdp) {
45
+ this.rawClient = cdp.getRawClient();
46
+ this.started = false;
47
+ this.styleSheetUrls = new Map();
48
+ this.disposeStyleSheetListener = undefined;
49
+ }
50
+
51
+ async start() {
52
+ const client = this.rawClient;
53
+ this.styleSheetUrls.clear();
54
+ this.disposeStyleSheetListener = client.CSS.styleSheetAdded(
55
+ ({ header }) => {
56
+ if (header && header.styleSheetId) {
57
+ this.styleSheetUrls.set(
58
+ header.styleSheetId,
59
+ header.sourceURL || header.sourceMapURL || ''
60
+ );
61
+ }
62
+ }
63
+ );
64
+
65
+ await client.Debugger.enable();
66
+ await client.Profiler.enable();
67
+ await client.DOM.enable();
68
+ await client.CSS.enable();
69
+ await client.Profiler.startPreciseCoverage({
70
+ callCount: false,
71
+ detailed: true,
72
+ allowTriggeredUpdates: false
73
+ });
74
+ await client.CSS.startRuleUsageTracking();
75
+ this.started = true;
76
+ }
77
+
78
+ async collect() {
79
+ if (!this.started) return;
80
+ const client = this.rawClient;
81
+
82
+ let js;
83
+ let css;
84
+ try {
85
+ const jsResult = await client.Profiler.takePreciseCoverage();
86
+ js = await this.#processJs(jsResult.result || []);
87
+ } catch (error) {
88
+ log.warn('Could not collect JS coverage: %s', error.message);
89
+ js = {
90
+ totalBytes: 0,
91
+ usedBytes: 0,
92
+ unusedBytes: 0,
93
+ unusedPercent: 0,
94
+ files: []
95
+ };
96
+ }
97
+
98
+ try {
99
+ const cssResult = await client.CSS.stopRuleUsageTracking();
100
+ css = await this.#processCss(cssResult.ruleUsage || []);
101
+ } catch (error) {
102
+ log.warn('Could not collect CSS coverage: %s', error.message);
103
+ css = {
104
+ totalBytes: 0,
105
+ usedBytes: 0,
106
+ unusedBytes: 0,
107
+ unusedPercent: 0,
108
+ files: []
109
+ };
110
+ }
111
+
112
+ try {
113
+ await client.Profiler.stopPreciseCoverage();
114
+ } catch {
115
+ // ignore — collection already returned data
116
+ }
117
+
118
+ if (this.disposeStyleSheetListener) {
119
+ this.disposeStyleSheetListener();
120
+ this.disposeStyleSheetListener = undefined;
121
+ }
122
+
123
+ this.started = false;
124
+ return { js, css };
125
+ }
126
+
127
+ async #processJs(scripts) {
128
+ const client = this.rawClient;
129
+ const files = [];
130
+ for (const script of scripts) {
131
+ if (!script.url || !/^https?:/i.test(script.url)) {
132
+ continue;
133
+ }
134
+ let source;
135
+ try {
136
+ const r = await client.Debugger.getScriptSource({
137
+ scriptId: script.scriptId
138
+ });
139
+ source = r.scriptSource || '';
140
+ } catch {
141
+ continue;
142
+ }
143
+ const totalBytes = source.length;
144
+ if (totalBytes === 0) continue;
145
+
146
+ const usedRanges = [];
147
+ for (const fn of script.functions) {
148
+ for (const r of fn.ranges) {
149
+ if (r.count > 0) usedRanges.push(r);
150
+ }
151
+ }
152
+ const usedBytes = unionLength(usedRanges);
153
+ const unusedBytes = totalBytes - usedBytes;
154
+ files.push({
155
+ url: script.url,
156
+ totalBytes,
157
+ usedBytes,
158
+ unusedBytes,
159
+ unusedPercent: (unusedBytes / totalBytes) * 100
160
+ });
161
+ }
162
+ return { ...summarize(files), files };
163
+ }
164
+
165
+ async #processCss(ruleUsage) {
166
+ const client = this.rawClient;
167
+ const byStylesheet = new Map();
168
+ for (const u of ruleUsage) {
169
+ const list = byStylesheet.get(u.styleSheetId) || [];
170
+ list.push(u);
171
+ byStylesheet.set(u.styleSheetId, list);
172
+ }
173
+ const files = [];
174
+ for (const [styleSheetId, usages] of byStylesheet) {
175
+ let text;
176
+ try {
177
+ const r = await client.CSS.getStyleSheetText({ styleSheetId });
178
+ text = r.text || '';
179
+ } catch {
180
+ continue;
181
+ }
182
+ const totalBytes = text.length;
183
+ if (totalBytes === 0) continue;
184
+
185
+ const usedBytes = unionLength(usages.filter(u => u.used));
186
+ const unusedBytes = totalBytes - usedBytes;
187
+ files.push({
188
+ url: this.styleSheetUrls.get(styleSheetId) || '',
189
+ styleSheetId,
190
+ totalBytes,
191
+ usedBytes,
192
+ unusedBytes,
193
+ unusedPercent: (unusedBytes / totalBytes) * 100
194
+ });
195
+ }
196
+ return { ...summarize(files), files };
197
+ }
198
+ }
@@ -15,6 +15,7 @@ import { parse } from '../traceCategoriesParser.js';
15
15
  import { pathToFolder } from '../../support/pathToFolder.js';
16
16
  import { loadUsbPowerProfiler } from '../../support/usbPower.js';
17
17
  import { ChromeDevtoolsProtocol } from '../chromeDevtoolsProtocol.js';
18
+ import { Coverage } from '../coverage.js';
18
19
  import { NetworkManager } from '../networkManager.js';
19
20
  import { Android, isAndroidConfigured } from '../../android/index.js';
20
21
  import { getRenderBlocking } from './traceUtilities.js';
@@ -181,6 +182,11 @@ export class Chromium {
181
182
  }
182
183
  }
183
184
 
185
+ if (this.chrome.coverage) {
186
+ this.coverage = new Coverage(this.cdpClient);
187
+ await this.coverage.start();
188
+ }
189
+
184
190
  if (
185
191
  this.collectTracingEvents &&
186
192
  !this.isTracing &&
@@ -325,6 +331,14 @@ export class Chromium {
325
331
  );
326
332
  }
327
333
 
334
+ if (this.coverage) {
335
+ const coverage = await this.coverage.collect();
336
+ this.coverage = undefined;
337
+ if (coverage) {
338
+ result.coverage = coverage;
339
+ }
340
+ }
341
+
328
342
  if (this.chrome.cdp && this.chrome.cdp.performance) {
329
343
  const rawCDPMetrics = await this.cdpClient.getPerformanceMetrics();
330
344
  const cleanedMetrics = {};
@@ -39,6 +39,7 @@ function getNewResult(url, options) {
39
39
  markedAsFailure: 0,
40
40
  failureMessages: [],
41
41
  cdp: { performance: [] },
42
+ coverage: { js: [], css: [] },
42
43
  android: { batteryTemperature: [], power: [] },
43
44
  timestamps: [],
44
45
  browserScripts: [],
@@ -185,6 +186,13 @@ export class Collector {
185
186
  });
186
187
  }
187
188
 
189
+ // Only available for Chrome with --chrome.coverage or
190
+ // --enableProfileRun.
191
+ if (data.coverage) {
192
+ results.coverage.js.push(data.coverage.js);
193
+ results.coverage.css.push(data.coverage.css);
194
+ }
195
+
188
196
  this._logIterationMetrics(data, url);
189
197
 
190
198
  if (data.visualMetrics) {
@@ -373,6 +373,20 @@ export function parseCommandLine() {
373
373
  'If you use --user-data-dir as an argument to Chrome and want to clean that directory between each iteration you should use --chrome.cleanUserDataDir true.',
374
374
  group: 'chrome'
375
375
  })
376
+ .option('chrome.coverage', {
377
+ type: 'boolean',
378
+ default: false,
379
+ describe:
380
+ 'Collect JavaScript and CSS code coverage on every iteration. ' +
381
+ 'Per-iteration summaries with a per-file breakdown land in the ' +
382
+ 'per-URL result as coverage.js[] and coverage.css[]. This has a ' +
383
+ 'real performance cost: detailed JS coverage causes V8 to ' +
384
+ 'deoptimize and will skew timing metrics like LCP, TBT and INP. ' +
385
+ 'Use --enableProfileRun instead if you want coverage without ' +
386
+ 'affecting your timing data - it collects coverage during the ' +
387
+ 'extra profile iteration only.',
388
+ group: 'chrome'
389
+ })
376
390
  .option('cpu', {
377
391
  type: 'boolean',
378
392
  describe:
@@ -651,7 +665,7 @@ export function parseCommandLine() {
651
665
  .option('enableProfileRun', {
652
666
  type: 'boolean',
653
667
  describe:
654
- 'Make one extra run that collects the profiling trace log (no other metrics is collected). For Chrome it will collect the timeline trace, for Firefox it will get the Geckoprofiler trace. This means you do not need to get the trace for all runs and can skip the overhead it produces.'
668
+ 'Make one extra run that collects the profiling trace log (no other metrics is collected). For Chrome it will collect the timeline trace and JavaScript/CSS coverage, for Firefox it will get the Geckoprofiler trace. This means you do not need to get the trace for all runs and can skip the overhead it produces.'
655
669
  })
656
670
  .option('enableVideoRun', {
657
671
  type: 'boolean',
@@ -42,6 +42,7 @@ function getDocumentRequests(entries, pageId) {
42
42
 
43
43
  do {
44
44
  entry = pageEntries.shift();
45
+ if (!entry) break;
45
46
  requests.push(entry);
46
47
  } while (entry.response.redirectURL);
47
48
 
@@ -52,7 +53,7 @@ function getFinalURL(entries, pageref) {
52
53
  const requests = getDocumentRequests(entries, pageref);
53
54
 
54
55
  const finalEntry = requests.pop();
55
- return finalEntry.request.url;
56
+ return finalEntry ? finalEntry.request.url : undefined;
56
57
  }
57
58
 
58
59
  function addExtrasToHAR(
@@ -249,13 +250,16 @@ export function getMainDocumentTimings(har) {
249
250
  for (let page of har.log.pages) {
250
251
  const pageId = page.id;
251
252
  const url = page._url;
253
+ if (url === undefined) continue;
252
254
 
253
255
  let pageEntries = [...entries];
254
256
  const finalURL = getFinalURL(pageEntries, pageId);
257
+ if (finalURL === undefined) continue;
255
258
 
256
259
  pageEntries = pageEntries.filter(
257
260
  entry => entry.pageref === pageId && entry.request.url === finalURL
258
261
  );
262
+ if (pageEntries.length === 0) continue;
259
263
 
260
264
  timings.push({ url, timings: pageEntries[0].timings });
261
265
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "browsertime",
3
3
  "description": "Get performance metrics from your web page using Browsertime.",
4
- "version": "27.3.0",
4
+ "version": "27.4.0",
5
5
  "bin": "./bin/browsertime.js",
6
6
  "type": "module",
7
7
  "types": "./scripting.d.ts",
@@ -9,7 +9,7 @@
9
9
  "@devicefarmer/adbkit": "3.3.8",
10
10
  "@sitespeed.io/chromedriver": "148.0.7778",
11
11
  "@sitespeed.io/edgedriver": "148.0.3967",
12
- "@sitespeed.io/geckodriver": "0.36.1",
12
+ "@sitespeed.io/geckodriver": "0.36.2",
13
13
  "@sitespeed.io/log": "2.0.0",
14
14
  "@sitespeed.io/throttle": "6.0.0",
15
15
  "chrome-har": "1.3.1",