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 +9 -0
- package/bin/browsertime.js +30 -4
- package/lib/chrome/coverage.js +198 -0
- package/lib/chrome/webdriver/chromium.js +14 -0
- package/lib/core/engine/collector.js +8 -0
- package/lib/support/cli.js +15 -1
- package/lib/support/har/index.js +5 -1
- package/package.json +2 -2
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
|
package/bin/browsertime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
116
|
+
storageManager = new StorageManager(firstUrl, options);
|
|
113
117
|
const harName = options.har ?? 'browsertime';
|
|
114
|
-
|
|
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(
|
|
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) {
|
package/lib/support/cli.js
CHANGED
|
@@ -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',
|
package/lib/support/har/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|