browsertime 27.3.0 → 27.4.1

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,19 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 27.4.1 - 2026-05-25
4
+
5
+ ### Fixed
6
+ * `--chrome.coverage` no longer reports "0 B unused" for functions that executed at least once. The JS used-bytes calculation took the union of every V8 range with count > 0, but V8 returns nested ranges — an outer range over the whole function body and inner ranges over sub-blocks. If a function ran at all, its outer range already covered the whole body and the inner count == 0 ranges (the actual dead branches) got absorbed into the union and contributed nothing. On modern bundles where module-evaluation top-level code runs for almost every function, the per-script unused-% collapsed to roughly zero across the board, leaving sitespeed.io rendering a "0 B unused" column on pages that obviously ship plenty of dead code. The calculation now walks the ranges as nested intervals, painting the innermost range's count at every byte (the same definition Chrome DevTools' Coverage panel uses), so dead branches inside executed functions show up correctly. CSS rule-usage tracking returns flat, non-overlapping ranges and is unchanged [#2484](https://github.com/sitespeedio/browsertime/pull/2484).
7
+
8
+ ## 27.4.0 - 2026-05-24
9
+
10
+ ### Added
11
+ * 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).
12
+
13
+ ### Fixed
14
+ * 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).
15
+ * Bumped `geckodriver` to a version with native ARM-64 support [#2482](https://github.com/sitespeedio/browsertime/pull/2482).
16
+
3
17
  ## 27.3.0 - 2026-05-20
4
18
 
5
19
  ### 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,232 @@
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. Used for the
6
+ // flat, non-nested ranges that CSS rule-usage tracking returns.
7
+ function unionLength(ranges) {
8
+ if (ranges.length === 0) return 0;
9
+ const sorted = ranges
10
+ .map(r => [r.startOffset, r.endOffset])
11
+ .toSorted((a, b) => a[0] - b[0]);
12
+ let total = 0;
13
+ let [curStart, curEnd] = sorted[0];
14
+ for (let i = 1; i < sorted.length; i++) {
15
+ const [s, e] = sorted[i];
16
+ if (s <= curEnd) {
17
+ if (e > curEnd) curEnd = e;
18
+ } else {
19
+ total += curEnd - curStart;
20
+ curStart = s;
21
+ curEnd = e;
22
+ }
23
+ }
24
+ total += curEnd - curStart;
25
+ return total;
26
+ }
27
+
28
+ // Count used bytes for a script under detailed (block-level) V8
29
+ // coverage. The CDP returns nested ranges: the outermost range is the
30
+ // function body and inner ranges are sub-blocks. An inner range with
31
+ // count == 0 inside a containing range with count > 0 represents dead
32
+ // code in an otherwise-executed function — those bytes must read as
33
+ // unused. A naive union of every range whose count > 0 is wrong: the
34
+ // outer range alone covers the entire function, so inner count == 0
35
+ // ranges get masked and zero-count branches disappear, making typical
36
+ // modern bundles look as if every byte was executed. Walk every range
37
+ // across every function from outermost to innermost (start ascending,
38
+ // length descending), painting a per-byte coverage flag; inner ranges
39
+ // overwrite outer ones, so the final flag at each byte reflects the
40
+ // innermost range's count.
41
+ export function usedJsBytes(scriptCoverage, totalBytes) {
42
+ const ranges = [];
43
+ for (const fn of scriptCoverage.functions) {
44
+ for (const r of fn.ranges) {
45
+ if (r.endOffset > r.startOffset) ranges.push(r);
46
+ }
47
+ }
48
+ if (ranges.length === 0) return 0;
49
+ ranges.sort((a, b) =>
50
+ a.startOffset === b.startOffset
51
+ ? b.endOffset - a.endOffset
52
+ : a.startOffset - b.startOffset
53
+ );
54
+ const used = new Uint8Array(totalBytes);
55
+ for (const r of ranges) {
56
+ const start = Math.max(0, r.startOffset);
57
+ const end = Math.min(totalBytes, r.endOffset);
58
+ if (end > start) used.fill(r.count > 0 ? 1 : 0, start, end);
59
+ }
60
+ let count = 0;
61
+ for (let i = 0; i < totalBytes; i++) {
62
+ if (used[i] === 1) count++;
63
+ }
64
+ return count;
65
+ }
66
+
67
+ function summarize(files) {
68
+ let totalBytes = 0;
69
+ let usedBytes = 0;
70
+ for (const file of files) {
71
+ totalBytes += file.totalBytes;
72
+ usedBytes += file.usedBytes;
73
+ }
74
+ const unusedBytes = totalBytes - usedBytes;
75
+ return {
76
+ totalBytes,
77
+ usedBytes,
78
+ unusedBytes,
79
+ unusedPercent: totalBytes > 0 ? (unusedBytes / totalBytes) * 100 : 0
80
+ };
81
+ }
82
+
83
+ export class Coverage {
84
+ constructor(cdp) {
85
+ this.rawClient = cdp.getRawClient();
86
+ this.started = false;
87
+ this.styleSheetUrls = new Map();
88
+ this.disposeStyleSheetListener = undefined;
89
+ }
90
+
91
+ async start() {
92
+ const client = this.rawClient;
93
+ this.styleSheetUrls.clear();
94
+ this.disposeStyleSheetListener = client.CSS.styleSheetAdded(
95
+ ({ header }) => {
96
+ if (header && header.styleSheetId) {
97
+ this.styleSheetUrls.set(
98
+ header.styleSheetId,
99
+ header.sourceURL || header.sourceMapURL || ''
100
+ );
101
+ }
102
+ }
103
+ );
104
+
105
+ await client.Debugger.enable();
106
+ await client.Profiler.enable();
107
+ await client.DOM.enable();
108
+ await client.CSS.enable();
109
+ await client.Profiler.startPreciseCoverage({
110
+ callCount: false,
111
+ detailed: true,
112
+ allowTriggeredUpdates: false
113
+ });
114
+ await client.CSS.startRuleUsageTracking();
115
+ this.started = true;
116
+ }
117
+
118
+ async collect() {
119
+ if (!this.started) return;
120
+ const client = this.rawClient;
121
+
122
+ let js;
123
+ let css;
124
+ try {
125
+ const jsResult = await client.Profiler.takePreciseCoverage();
126
+ js = await this.#processJs(jsResult.result || []);
127
+ } catch (error) {
128
+ log.warn('Could not collect JS coverage: %s', error.message);
129
+ js = {
130
+ totalBytes: 0,
131
+ usedBytes: 0,
132
+ unusedBytes: 0,
133
+ unusedPercent: 0,
134
+ files: []
135
+ };
136
+ }
137
+
138
+ try {
139
+ const cssResult = await client.CSS.stopRuleUsageTracking();
140
+ css = await this.#processCss(cssResult.ruleUsage || []);
141
+ } catch (error) {
142
+ log.warn('Could not collect CSS coverage: %s', error.message);
143
+ css = {
144
+ totalBytes: 0,
145
+ usedBytes: 0,
146
+ unusedBytes: 0,
147
+ unusedPercent: 0,
148
+ files: []
149
+ };
150
+ }
151
+
152
+ try {
153
+ await client.Profiler.stopPreciseCoverage();
154
+ } catch {
155
+ // ignore — collection already returned data
156
+ }
157
+
158
+ if (this.disposeStyleSheetListener) {
159
+ this.disposeStyleSheetListener();
160
+ this.disposeStyleSheetListener = undefined;
161
+ }
162
+
163
+ this.started = false;
164
+ return { js, css };
165
+ }
166
+
167
+ async #processJs(scripts) {
168
+ const client = this.rawClient;
169
+ const files = [];
170
+ for (const script of scripts) {
171
+ if (!script.url || !/^https?:/i.test(script.url)) {
172
+ continue;
173
+ }
174
+ let source;
175
+ try {
176
+ const r = await client.Debugger.getScriptSource({
177
+ scriptId: script.scriptId
178
+ });
179
+ source = r.scriptSource || '';
180
+ } catch {
181
+ continue;
182
+ }
183
+ const totalBytes = source.length;
184
+ if (totalBytes === 0) continue;
185
+
186
+ const usedBytes = usedJsBytes(script, totalBytes);
187
+ const unusedBytes = totalBytes - usedBytes;
188
+ files.push({
189
+ url: script.url,
190
+ totalBytes,
191
+ usedBytes,
192
+ unusedBytes,
193
+ unusedPercent: (unusedBytes / totalBytes) * 100
194
+ });
195
+ }
196
+ return { ...summarize(files), files };
197
+ }
198
+
199
+ async #processCss(ruleUsage) {
200
+ const client = this.rawClient;
201
+ const byStylesheet = new Map();
202
+ for (const u of ruleUsage) {
203
+ const list = byStylesheet.get(u.styleSheetId) || [];
204
+ list.push(u);
205
+ byStylesheet.set(u.styleSheetId, list);
206
+ }
207
+ const files = [];
208
+ for (const [styleSheetId, usages] of byStylesheet) {
209
+ let text;
210
+ try {
211
+ const r = await client.CSS.getStyleSheetText({ styleSheetId });
212
+ text = r.text || '';
213
+ } catch {
214
+ continue;
215
+ }
216
+ const totalBytes = text.length;
217
+ if (totalBytes === 0) continue;
218
+
219
+ const usedBytes = unionLength(usages.filter(u => u.used));
220
+ const unusedBytes = totalBytes - usedBytes;
221
+ files.push({
222
+ url: this.styleSheetUrls.get(styleSheetId) || '',
223
+ styleSheetId,
224
+ totalBytes,
225
+ usedBytes,
226
+ unusedBytes,
227
+ unusedPercent: (unusedBytes / totalBytes) * 100
228
+ });
229
+ }
230
+ return { ...summarize(files), files };
231
+ }
232
+ }
@@ -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.1",
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",