browsertime 17.16.0 → 17.18.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 +18 -0
- package/browserscripts/pageinfo/resources.js +17 -4
- package/lib/chrome/networkManager.js +61 -0
- package/lib/chrome/webdriver/chromium.js +26 -44
- package/lib/chrome/webdriver/traceUtilities.js +41 -6
- package/lib/core/engine/command/chromeTrace.js +63 -0
- package/lib/core/engine/command/geckoProfiler.js +3 -6
- package/lib/core/engine/command/measure.js +10 -2
- package/lib/core/engine/iteration.js +5 -1
- package/lib/core/seleniumRunner.js +111 -90
- package/lib/firefox/networkManager.js +109 -0
- package/lib/firefox/webdriver/builder.js +2 -0
- package/lib/firefox/webdriver/firefox.js +8 -0
- package/lib/safari/webdriver/safari.js +4 -0
- package/lib/support/cli.js +21 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Browsertime changelog (we do [semantic versioning](https://semver.org))
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
## 17.18.0 - 2022-10-23
|
|
5
|
+
### Added
|
|
6
|
+
* Updated to Chromedriver 119 [#2003](https://github.com/sitespeedio/browsertime/pull/2003). 119 works with both Chrome 118 and 119 so it fixes [#1197](https://github.com/sitespeedio/browsertime/issues/1997).
|
|
7
|
+
|
|
8
|
+
* Add support for network idle method to know when to end a test that uses network logs. Uses Bidi for Firefox and CDP for Chrome to listen on network events to know when to end a test. By default 5 seconds idle network time ends a tests (you could have network responses that hasn't arrived yet) [#1960](https://github.com/sitespeedio/browsertime/pull/1960). Potentially this can help SPA users or users where the page uses iframes. You can try it out by adding `--pageCompleteCheckNetworkIdle` yo your command line. This is still some work in progress but feel free to try ut out.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
* Make sure timer always is cleared. There was case of where we do a rase beteween a promise and a timeout where the timeout timer wasn't cleared/removed [#2005](https://github.com/sitespeedio/browsertime/pull/2005).
|
|
12
|
+
|
|
13
|
+
## 17.17.0 - 2022-10-11
|
|
14
|
+
### Added
|
|
15
|
+
* Firefox 118, Edge 117 and Chrome/Chromedriver 118 in the Docker container [#1996](https://github.com/sitespeedio/browsertime/pull/1996).
|
|
16
|
+
* Expose trace start/stop for Chrome in scripting (the same way as for Firefox). Thank you [KS](https://github.com/92kns) for [#1988](https://github.com/sitespeedio/browsertime/pull/1988). Documentation is coming when the functionality is rolled out in sitespeed.io.
|
|
17
|
+
|
|
18
|
+
### Updated
|
|
19
|
+
* Updated to Selenium 4.12
|
|
20
|
+
|
|
3
21
|
## 17.16.0 - 2022-09-04
|
|
4
22
|
### Added
|
|
5
23
|
* Firefox 117 and Edge 116 in the Docker container.
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
(function() {
|
|
1
|
+
(function () {
|
|
2
2
|
const resources = window.performance.getEntriesByType('resource');
|
|
3
3
|
|
|
4
4
|
let resourceDuration = 0;
|
|
5
|
+
let servedFromCache = 0;
|
|
6
|
+
let servedFromCacheSupported = false;
|
|
5
7
|
for (let i = 0; i < resources.length; i++) {
|
|
6
8
|
resourceDuration += resources[i].duration;
|
|
9
|
+
if (resources[i].deliveryType !== undefined) {
|
|
10
|
+
servedFromCacheSupported = true;
|
|
11
|
+
if (resources[i].deliveryType === 'cache') {
|
|
12
|
+
servedFromCache++;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
7
15
|
}
|
|
8
16
|
|
|
9
|
-
|
|
17
|
+
const info = {
|
|
10
18
|
count: Number(resources.length),
|
|
11
|
-
duration: Number(resourceDuration)
|
|
19
|
+
duration: Number(resourceDuration),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
if (servedFromCacheSupported === true) {
|
|
23
|
+
info.servedFromCache = Number(servedFromCache);
|
|
12
24
|
}
|
|
13
|
-
})();
|
|
14
25
|
|
|
26
|
+
return info;
|
|
27
|
+
})();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import intel from 'intel';
|
|
2
|
+
import get from 'lodash.get';
|
|
3
|
+
|
|
4
|
+
const log = intel.getLogger('browsertime.chrome.network');
|
|
5
|
+
|
|
6
|
+
export class NetworkManager {
|
|
7
|
+
constructor(cdpClient, options) {
|
|
8
|
+
this.maxTimeout = get(options, 'timeouts.pageCompleteCheck', 30_000);
|
|
9
|
+
this.idleTime = get(options, 'timeouts.networkIdle', 5000);
|
|
10
|
+
|
|
11
|
+
this.cdp = cdpClient.getRawClient();
|
|
12
|
+
this.inflight = 0;
|
|
13
|
+
this.lastRequestTimestamp;
|
|
14
|
+
this.lastResponseTimestamp;
|
|
15
|
+
|
|
16
|
+
this.cdp.Network.requestWillBeSent(() => {
|
|
17
|
+
this.inflight++;
|
|
18
|
+
this.lastRequestTimestamp = Date.now();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
this.cdp.Network.loadingFinished(() => {
|
|
22
|
+
this.inflight--;
|
|
23
|
+
this.lastResponseTimestamp = Date.now();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.cdp.Network.loadingFailed(() => {
|
|
27
|
+
this.inflight--;
|
|
28
|
+
this.lastResponseTimestamp = Date.now();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async waitForNetworkIdle() {
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
// eslint-disable-next-line no-constant-condition
|
|
35
|
+
while (true) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const sinceLastResponseRequest =
|
|
38
|
+
now - Math.max(this.lastResponseTimestamp, this.lastRequestTimestamp);
|
|
39
|
+
const sinceStart = now - startTime;
|
|
40
|
+
|
|
41
|
+
if (sinceLastResponseRequest >= this.idleTime) {
|
|
42
|
+
if (this.inflight > 0) {
|
|
43
|
+
log.info(
|
|
44
|
+
'Idle time without any request/responses. Inflight requests:' +
|
|
45
|
+
this.inflight
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sinceStart >= this.maxTimeout) {
|
|
52
|
+
log.info(
|
|
53
|
+
'Timeout waiting for network. Inflight requests:' + this.inflight
|
|
54
|
+
);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await new Promise(r => setTimeout(r, 200));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -14,12 +14,9 @@ import { logging as _logging } from 'selenium-webdriver';
|
|
|
14
14
|
import { parse } from '../traceCategoriesParser.js';
|
|
15
15
|
import { pathToFolder } from '../../support/pathToFolder.js';
|
|
16
16
|
import { ChromeDevtoolsProtocol } from '../chromeDevtoolsProtocol.js';
|
|
17
|
+
import { NetworkManager } from '../networkManager.js';
|
|
17
18
|
import { Android, isAndroidConfigured } from '../../android/index.js';
|
|
18
|
-
import {
|
|
19
|
-
getFirstContentFulPaintEvent,
|
|
20
|
-
getLargestContentfulPaintEvent,
|
|
21
|
-
getRecalculateStyleElementsAndTimeBefore
|
|
22
|
-
} from './traceUtilities.js';
|
|
19
|
+
import { getRenderBlocking } from './traceUtilities.js';
|
|
23
20
|
const unlink = promisify(_unlink);
|
|
24
21
|
const rm = promisify(_rm);
|
|
25
22
|
|
|
@@ -160,7 +157,11 @@ export class Chromium {
|
|
|
160
157
|
await this.android.resetPowerUsage();
|
|
161
158
|
}
|
|
162
159
|
|
|
163
|
-
if (
|
|
160
|
+
if (
|
|
161
|
+
this.collectTracingEvents &&
|
|
162
|
+
!this.isTracing &&
|
|
163
|
+
this.options.chrome.timelineRecordingType !== 'custom'
|
|
164
|
+
) {
|
|
164
165
|
this.isTracing = true;
|
|
165
166
|
return this.cdpClient.startTrace();
|
|
166
167
|
}
|
|
@@ -174,7 +175,11 @@ export class Chromium {
|
|
|
174
175
|
*/
|
|
175
176
|
async afterPageCompleteCheck(runner, index, url, alias) {
|
|
176
177
|
const result = { url, alias };
|
|
177
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
this.collectTracingEvents &&
|
|
180
|
+
this.isTracing &&
|
|
181
|
+
this.options.chrome.timelineRecordingType !== 'custom'
|
|
182
|
+
) {
|
|
178
183
|
// We are ready and can stop collecting events
|
|
179
184
|
this.isTracing = false;
|
|
180
185
|
this.events = await this.cdpClient.stopTrace();
|
|
@@ -334,7 +339,10 @@ export class Chromium {
|
|
|
334
339
|
await this.storageManager.gzip(filename, gzFilename, true);
|
|
335
340
|
}
|
|
336
341
|
|
|
337
|
-
if (
|
|
342
|
+
if (
|
|
343
|
+
this.collectTracingEvents &&
|
|
344
|
+
this.options.chrome.timelineRecordingType !== 'custom'
|
|
345
|
+
) {
|
|
338
346
|
const trace = parse(this.events, result.url);
|
|
339
347
|
const name = this.options.enableProfileRun
|
|
340
348
|
? `trace-${index}-extra-run.json`
|
|
@@ -345,50 +353,19 @@ export class Chromium {
|
|
|
345
353
|
result.cpu = cpu;
|
|
346
354
|
|
|
347
355
|
// Collect render blocking info
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
task =>
|
|
351
|
-
task.cat === 'devtools.timeline' &&
|
|
352
|
-
task.name === 'ResourceSendRequest' &&
|
|
353
|
-
task.args.data.url &&
|
|
354
|
-
task.args.data.renderBlocking
|
|
355
|
-
);
|
|
356
|
-
for (let asset of urlsWithBlockingInfo) {
|
|
357
|
-
renderBlockingInfo[asset.args.data.url] =
|
|
358
|
-
asset.args.data.renderBlocking;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const fcpEvent = getFirstContentFulPaintEvent(trace.traceEvents);
|
|
362
|
-
const lcpEvent = getLargestContentfulPaintEvent(trace.traceEvents);
|
|
363
|
-
|
|
364
|
-
result.renderBlocking = { recalculateStyle: {}, requests: {} };
|
|
365
|
-
|
|
366
|
-
if (fcpEvent) {
|
|
367
|
-
const beforeFCP = getRecalculateStyleElementsAndTimeBefore(
|
|
368
|
-
trace.traceEvents,
|
|
369
|
-
fcpEvent.ts
|
|
370
|
-
);
|
|
371
|
-
result.renderBlocking.recalculateStyle.beforeFCP = beforeFCP;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (lcpEvent) {
|
|
375
|
-
const beforeLCP = getRecalculateStyleElementsAndTimeBefore(
|
|
376
|
-
trace.traceEvents,
|
|
377
|
-
lcpEvent.ts
|
|
378
|
-
);
|
|
379
|
-
result.renderBlocking.recalculateStyle.beforeLCP = beforeLCP;
|
|
380
|
-
}
|
|
356
|
+
const render = await getRenderBlocking(trace);
|
|
357
|
+
result.renderBlocking = render.renderBlocking;
|
|
381
358
|
|
|
382
359
|
if (!this.options.skipHar) {
|
|
383
360
|
for (let harRequest of this.hars[index - 1].log.entries) {
|
|
384
|
-
if (renderBlockingInfo[harRequest.request.url]) {
|
|
361
|
+
if (render.renderBlockingInfo[harRequest.request.url]) {
|
|
385
362
|
harRequest._renderBlocking =
|
|
386
|
-
renderBlockingInfo[harRequest.request.url];
|
|
363
|
+
render.renderBlockingInfo[harRequest.request.url];
|
|
387
364
|
}
|
|
388
365
|
}
|
|
389
366
|
}
|
|
390
367
|
|
|
391
|
-
result.renderBlocking.requests = renderBlockingInfo;
|
|
368
|
+
result.renderBlocking.requests = render.renderBlockingInfo;
|
|
392
369
|
}
|
|
393
370
|
|
|
394
371
|
// Google Web Vitals hacksery
|
|
@@ -526,4 +503,9 @@ export class Chromium {
|
|
|
526
503
|
async setCookies(url, cookies) {
|
|
527
504
|
return this.cdpClient.setCookies(url, cookies);
|
|
528
505
|
}
|
|
506
|
+
|
|
507
|
+
async waitForNetworkIdle() {
|
|
508
|
+
let network = new NetworkManager(this.cdpClient, this.options);
|
|
509
|
+
return network.waitForNetworkIdle();
|
|
510
|
+
}
|
|
529
511
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import intel from 'intel';
|
|
2
2
|
const log = intel.getLogger('browsertime.chrome');
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
function getLargestContentfulPaintEvent(traceEvents) {
|
|
5
5
|
const lcpCandidates = traceEvents.filter(
|
|
6
6
|
task => task.name === 'largestContentfulPaint::Candidate'
|
|
7
7
|
);
|
|
@@ -20,7 +20,7 @@ export function getLargestContentfulPaintEvent(traceEvents) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
function getFirstContentFulPaintEvent(traceEvents) {
|
|
24
24
|
// Get first contentful paint
|
|
25
25
|
const fcpEvent = traceEvents.find(
|
|
26
26
|
task => task.name === 'firstContentfulPaint'
|
|
@@ -33,10 +33,7 @@ export function getFirstContentFulPaintEvent(traceEvents) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
traceEvents,
|
|
38
|
-
timestamp
|
|
39
|
-
) {
|
|
36
|
+
function getRecalculateStyleElementsAndTimeBefore(traceEvents, timestamp) {
|
|
40
37
|
const recalculatesBefore = traceEvents.filter(
|
|
41
38
|
task =>
|
|
42
39
|
task.cat === 'disabled-by-default-devtools.timeline' &&
|
|
@@ -62,3 +59,41 @@ export function getRecalculateStyleElementsAndTimeBefore(
|
|
|
62
59
|
|
|
63
60
|
return { elements, durationInMillis: duration / 1000 };
|
|
64
61
|
}
|
|
62
|
+
|
|
63
|
+
export async function getRenderBlocking(trace) {
|
|
64
|
+
const renderBlockingInfo = {};
|
|
65
|
+
|
|
66
|
+
const urlsWithBlockingInfo = trace.traceEvents.filter(
|
|
67
|
+
task =>
|
|
68
|
+
task.cat === 'devtools.timeline' &&
|
|
69
|
+
task.name === 'ResourceSendRequest' &&
|
|
70
|
+
task.args.data.url &&
|
|
71
|
+
task.args.data.renderBlocking
|
|
72
|
+
);
|
|
73
|
+
for (let asset of urlsWithBlockingInfo) {
|
|
74
|
+
renderBlockingInfo[asset.args.data.url] = asset.args.data.renderBlocking;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fcpEvent = getFirstContentFulPaintEvent(trace.traceEvents);
|
|
78
|
+
const lcpEvent = getLargestContentfulPaintEvent(trace.traceEvents);
|
|
79
|
+
|
|
80
|
+
const renderBlocking = { recalculateStyle: {}, requests: {} };
|
|
81
|
+
|
|
82
|
+
if (fcpEvent) {
|
|
83
|
+
const beforeFCP = getRecalculateStyleElementsAndTimeBefore(
|
|
84
|
+
trace.traceEvents,
|
|
85
|
+
fcpEvent.ts
|
|
86
|
+
);
|
|
87
|
+
renderBlocking.recalculateStyle.beforeFCP = beforeFCP;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (lcpEvent) {
|
|
91
|
+
const beforeLCP = getRecalculateStyleElementsAndTimeBefore(
|
|
92
|
+
trace.traceEvents,
|
|
93
|
+
lcpEvent.ts
|
|
94
|
+
);
|
|
95
|
+
renderBlocking.recalculateStyle.beforeLCP = beforeLCP;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { renderBlockingInfo, renderBlocking };
|
|
99
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import intel from 'intel';
|
|
2
|
+
|
|
3
|
+
import { getRenderBlocking } from '../../../chrome/webdriver/traceUtilities.js';
|
|
4
|
+
import { parse } from '../../../chrome/traceCategoriesParser.js';
|
|
5
|
+
import { parseCPUTrace } from '../../../chrome/parseCpuTrace.js';
|
|
6
|
+
const log = intel.getLogger('browsertime.command.chrometrace');
|
|
7
|
+
export class ChromeTrace {
|
|
8
|
+
constructor(engineDelegate, index, options, result) {
|
|
9
|
+
this.engineDelegate = engineDelegate;
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.result = result;
|
|
12
|
+
this.index = index;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.options.browser === 'chrome') {
|
|
17
|
+
if (this.options.chrome.timelineRecordingType === 'custom') {
|
|
18
|
+
return this.engineDelegate.getCDPClient().startTrace();
|
|
19
|
+
} else {
|
|
20
|
+
log.info(
|
|
21
|
+
'You need to set traceRecordingType to custom to turn on the profiler in scripting'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
throw new Error('Trace only works in Chrome');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async stop() {
|
|
30
|
+
if (this.options.browser === 'chrome') {
|
|
31
|
+
if (this.options.chrome.timelineRecordingType === 'custom') {
|
|
32
|
+
let result = this.result[0];
|
|
33
|
+
|
|
34
|
+
this.events = [];
|
|
35
|
+
this.events = await this.engineDelegate.getCDPClient().stopTrace();
|
|
36
|
+
const trace = parse(this.events, result.url);
|
|
37
|
+
const name = this.options.enableProfileRun
|
|
38
|
+
? `trace-${this.index}-extra-run.json`
|
|
39
|
+
: `trace-${this.index}.json`;
|
|
40
|
+
result.extraJson[name] = trace;
|
|
41
|
+
|
|
42
|
+
const cpu = await parseCPUTrace(trace, result.url);
|
|
43
|
+
result.cpu = cpu;
|
|
44
|
+
|
|
45
|
+
// Collect render blocking info
|
|
46
|
+
const render = await getRenderBlocking(trace);
|
|
47
|
+
|
|
48
|
+
result.renderBlocking = render.renderBlocking;
|
|
49
|
+
|
|
50
|
+
if (!this.options.skipHar) {
|
|
51
|
+
for (let harRequest of this.hars[this.index - 1].log.entries) {
|
|
52
|
+
if (render.renderBlockingInfo[harRequest.request.url]) {
|
|
53
|
+
harRequest._renderBlocking =
|
|
54
|
+
render.renderBlockingInfo[harRequest.request.url];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
throw new Error('Trace only works in Chrome');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import intel from 'intel';
|
|
2
2
|
const log = intel.getLogger('browsertime.command.geckoprofiler');
|
|
3
3
|
export class GeckoProfiler {
|
|
4
|
-
constructor(GeckoProfiler, browser, index, options) {
|
|
4
|
+
constructor(GeckoProfiler, browser, index, options, result) {
|
|
5
5
|
this.GeckoProfiler = GeckoProfiler;
|
|
6
6
|
this.browser = browser;
|
|
7
7
|
this.index = index;
|
|
8
8
|
this.options = options;
|
|
9
|
+
this.result = result;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
async start() {
|
|
@@ -25,11 +26,7 @@ export class GeckoProfiler {
|
|
|
25
26
|
async stop() {
|
|
26
27
|
if (this.options.browser === 'firefox') {
|
|
27
28
|
if (this.options.firefox.geckoProfilerRecordingType === 'custom') {
|
|
28
|
-
|
|
29
|
-
.getDriver()
|
|
30
|
-
.executeScript('return document.documentURI;');
|
|
31
|
-
|
|
32
|
-
return this.GeckoProfiler.stop(this.index, url);
|
|
29
|
+
return this.GeckoProfiler.stop(this.index, this.result[0].url);
|
|
33
30
|
}
|
|
34
31
|
} else {
|
|
35
32
|
throw new Error('Geckoprofiler only works in Firefox');
|
|
@@ -154,7 +154,11 @@ export class Measure {
|
|
|
154
154
|
await this.engineDelegate.beforeStartIteration(this.browser, this.index);
|
|
155
155
|
}
|
|
156
156
|
this.numberOfVisitedPages++;
|
|
157
|
-
return this.browser.loadAndWait(
|
|
157
|
+
return this.browser.loadAndWait(
|
|
158
|
+
url,
|
|
159
|
+
this.pageCompleteCheck,
|
|
160
|
+
this.engineDelegate
|
|
161
|
+
);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/**
|
|
@@ -229,7 +233,11 @@ export class Measure {
|
|
|
229
233
|
this.result[this.numberOfMeasuredPages].url = url;
|
|
230
234
|
try {
|
|
231
235
|
await this.engineDelegate.beforeEachURL(this.browser, url);
|
|
232
|
-
await this.browser.loadAndWait(
|
|
236
|
+
await this.browser.loadAndWait(
|
|
237
|
+
url,
|
|
238
|
+
this.pageCompleteCheck,
|
|
239
|
+
this.engineDelegate
|
|
240
|
+
);
|
|
233
241
|
return this.stop(url);
|
|
234
242
|
} catch (error) {
|
|
235
243
|
this.result[this.numberOfMeasuredPages].error = [error.name];
|
|
@@ -24,6 +24,7 @@ import { Select } from './command/select.js';
|
|
|
24
24
|
import { Debug } from './command/debug.js';
|
|
25
25
|
import { AndroidCommand } from './command/android.js';
|
|
26
26
|
import { ChromeDevelopmentToolsProtocol } from './command/chromeDevToolsProtocol.js';
|
|
27
|
+
import { ChromeTrace } from './command/chromeTrace.js';
|
|
27
28
|
import {
|
|
28
29
|
addConnectivity,
|
|
29
30
|
removeConnectivity
|
|
@@ -158,16 +159,19 @@ export class Iteration {
|
|
|
158
159
|
browserProfiler,
|
|
159
160
|
browser,
|
|
160
161
|
index,
|
|
161
|
-
this.options
|
|
162
|
+
this.options,
|
|
163
|
+
result
|
|
162
164
|
);
|
|
163
165
|
const cdp = new ChromeDevelopmentToolsProtocol(
|
|
164
166
|
engineDelegate,
|
|
165
167
|
options.browser
|
|
166
168
|
);
|
|
169
|
+
const trace = new ChromeTrace(engineDelegate, index, options, result);
|
|
167
170
|
const android = new Android(options);
|
|
168
171
|
const debug = new Debug(browser, options);
|
|
169
172
|
const commands = {
|
|
170
173
|
profiler: profiler,
|
|
174
|
+
trace: trace,
|
|
171
175
|
click: new Click(browser, this.pageCompleteCheck),
|
|
172
176
|
scroll: new Scroll(browser, options),
|
|
173
177
|
addText: new AddText(browser),
|
|
@@ -24,25 +24,40 @@ const defaults = {
|
|
|
24
24
|
const delay = ms => new Promise(res => setTimeout(res, ms));
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* @param {
|
|
31
|
-
* @param {
|
|
27
|
+
* @function timeout
|
|
28
|
+
* @description Wraps a promise with a timeout, rejecting the promise with a TimeoutError if it does not settle within the specified time.
|
|
29
|
+
*
|
|
30
|
+
* @param {Promise} promise - The promise to wrap with a timeout.
|
|
31
|
+
* @param {number} ms - The number of milliseconds to wait before timing out.
|
|
32
|
+
* @param {string} errorMessage - The error message for the TimeoutError.
|
|
33
|
+
*
|
|
34
|
+
* @returns {Promise} - A promise that resolves with the value of the input promise if it settles within time, or rejects with a TimeoutError otherwise.
|
|
32
35
|
*/
|
|
33
36
|
async function timeout(promise, ms, errorMessage) {
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
|
|
37
|
+
let timerId;
|
|
38
|
+
let finished = false;
|
|
39
|
+
|
|
40
|
+
// Create a new promise that rejects after `ms` milliseconds.
|
|
41
|
+
const timer = new Promise((_, reject) => {
|
|
42
|
+
timerId = setTimeout(() => {
|
|
43
|
+
if (!finished) {
|
|
44
|
+
// Reject with a TimeoutError if the input promise has not yet settled.
|
|
45
|
+
reject(new Error(errorMessage));
|
|
46
|
+
}
|
|
47
|
+
}, ms);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Race the input promise against the timer.
|
|
52
|
+
const result = await Promise.race([promise, timer]);
|
|
53
|
+
finished = true;
|
|
54
|
+
clearTimeout(timerId);
|
|
55
|
+
return result;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
finished = true;
|
|
58
|
+
clearTimeout(timerId);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
/**
|
|
@@ -132,14 +147,15 @@ export class SeleniumRunner {
|
|
|
132
147
|
|
|
133
148
|
async extraWait(pageCompleteCheck) {
|
|
134
149
|
await delay(this.options.beforePageCompleteWaitTime || 5000);
|
|
135
|
-
return this.
|
|
150
|
+
return this._waitOnPageCompleteCheck(pageCompleteCheck);
|
|
136
151
|
}
|
|
137
152
|
/**
|
|
138
153
|
* Wait for pageCompleteCheck to end before we return.
|
|
139
154
|
* @param {string} pageCompleteCheck - JavaScript that checks if the page has finished loading
|
|
140
155
|
* @throws {UrlLoadError}
|
|
141
156
|
*/
|
|
142
|
-
|
|
157
|
+
|
|
158
|
+
async _waitOnPageCompleteCheck(pageCompleteCheck, url) {
|
|
143
159
|
const waitTime = this.options.pageCompleteWaitTime || 5000;
|
|
144
160
|
if (!pageCompleteCheck) {
|
|
145
161
|
pageCompleteCheck = this.options.pageCompleteCheckInactivity
|
|
@@ -209,7 +225,7 @@ export class SeleniumRunner {
|
|
|
209
225
|
* @param {string} pageCompleteCheck - JavaScript that checks if the page has finished loading
|
|
210
226
|
* @throws {UrlLoadError}
|
|
211
227
|
*/
|
|
212
|
-
async loadAndWait(url, pageCompleteCheck) {
|
|
228
|
+
async loadAndWait(url, pageCompleteCheck, engine) {
|
|
213
229
|
const driver = this.driver;
|
|
214
230
|
// Browsers may normalize 'https://x.com' differently; in particular, Firefox normalizes to
|
|
215
231
|
// 'https://x.com/'. This is a first normalization attempt; there are deeper options that order
|
|
@@ -263,81 +279,86 @@ export class SeleniumRunner {
|
|
|
263
279
|
await driver.executeScript(navigate);
|
|
264
280
|
}
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
this.options.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
282
|
+
if (this.options.pageCompleteCheckNetworkIdle) {
|
|
283
|
+
return engine.waitForNetworkIdle(driver);
|
|
284
|
+
} else {
|
|
285
|
+
// If you run with default settings, the webdriver will give back
|
|
286
|
+
// control ASAP. Therefore you want to wait some extra time
|
|
287
|
+
// before you start to run your page complete check
|
|
288
|
+
this.options.pageLoadStrategy === 'none'
|
|
289
|
+
? await delay(this.options.pageCompleteCheckStartWait || 5000)
|
|
290
|
+
: await delay(2000);
|
|
291
|
+
|
|
292
|
+
// We give it a couple of times to finish loading, this makes it
|
|
293
|
+
// more stable in real case scenarios on slow servers.
|
|
294
|
+
let totalWaitTime = 0;
|
|
295
|
+
const tries = get(this.options, 'retries', 5) + 1;
|
|
296
|
+
for (let index = 0; index < tries; ++index) {
|
|
297
|
+
try {
|
|
298
|
+
await this._waitOnPageCompleteCheck(pageCompleteCheck, normalizedURI);
|
|
299
|
+
const newURI = new URL(
|
|
300
|
+
await driver.executeScript('return document.documentURI;')
|
|
301
|
+
).toString();
|
|
302
|
+
// If we use a SPA it could be that we don't test a new URL so just do one try
|
|
303
|
+
// and make sure your page complete check take care of other things
|
|
304
|
+
if (this.options.spa) {
|
|
305
|
+
break;
|
|
306
|
+
} else if (normalizedURI === startURI) {
|
|
307
|
+
// You are navigating to the current page
|
|
308
|
+
break;
|
|
309
|
+
} else if (normalizedURI.startsWith('data:text')) {
|
|
310
|
+
// Navigations between data/text seems to don't change the URI
|
|
311
|
+
break;
|
|
312
|
+
} else if (newURI === 'chrome-error://chromewebdata/') {
|
|
313
|
+
// This is the timeout URL for Chrome, just continue to try
|
|
314
|
+
throw new UrlLoadError(
|
|
315
|
+
`Could not load ${url} is the web page down?`,
|
|
316
|
+
url
|
|
317
|
+
);
|
|
318
|
+
} else if (newURI === startURI) {
|
|
319
|
+
const waitTime =
|
|
320
|
+
(this.options.retryWaitTime || 10_000) * (index + 1);
|
|
321
|
+
totalWaitTime += waitTime;
|
|
322
|
+
log.debug(
|
|
323
|
+
`URL ${url} failed to load, the ${
|
|
324
|
+
this.options.browser
|
|
325
|
+
} are still on ${startURI} , trying ${
|
|
326
|
+
tries - index - 1
|
|
327
|
+
} more time(s) but first wait for ${waitTime} ms.`
|
|
328
|
+
);
|
|
309
329
|
|
|
330
|
+
if (index === tries - 1) {
|
|
331
|
+
// If the last tries through an error, rethrow as before
|
|
332
|
+
const message = `Could not load ${url} - the navigation never happend after ${tries} tries and total wait time of ${totalWaitTime} ms`;
|
|
333
|
+
log.error(message);
|
|
334
|
+
throw new UrlLoadError(message, url);
|
|
335
|
+
} else {
|
|
336
|
+
// We add some wait time before we try again
|
|
337
|
+
await delay(waitTime);
|
|
338
|
+
log.info(
|
|
339
|
+
'Will check again if the browser has navigated to the page'
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
// We navigated to a new page, we don't need to test anymore
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
log.info(
|
|
348
|
+
`URL failed to load, trying ${tries - index - 1} more time(s): ${
|
|
349
|
+
error.message
|
|
350
|
+
}`
|
|
351
|
+
);
|
|
352
|
+
//
|
|
310
353
|
if (index === tries - 1) {
|
|
311
354
|
// If the last tries through an error, rethrow as before
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
355
|
+
log.error('Could not load URL %s', url, error);
|
|
356
|
+
throw new UrlLoadError('Failed to load ' + url, url, {
|
|
357
|
+
cause: error
|
|
358
|
+
});
|
|
315
359
|
} else {
|
|
316
|
-
|
|
317
|
-
await delay(waitTime);
|
|
318
|
-
log.info(
|
|
319
|
-
'Will check again if the browser has navigated to the page'
|
|
320
|
-
);
|
|
360
|
+
await delay(1000);
|
|
321
361
|
}
|
|
322
|
-
} else {
|
|
323
|
-
// We navigated to a new page, we don't need to test anymore
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
} catch (error) {
|
|
327
|
-
log.info(
|
|
328
|
-
`URL failed to load, trying ${tries - index - 1} more time(s): ${
|
|
329
|
-
error.message
|
|
330
|
-
}`
|
|
331
|
-
);
|
|
332
|
-
//
|
|
333
|
-
if (index === tries - 1) {
|
|
334
|
-
// If the last tries through an error, rethrow as before
|
|
335
|
-
log.error('Could not load URL %s', url, error);
|
|
336
|
-
throw new UrlLoadError('Failed to load ' + url, url, {
|
|
337
|
-
cause: error
|
|
338
|
-
});
|
|
339
|
-
} else {
|
|
340
|
-
await delay(1000);
|
|
341
362
|
}
|
|
342
363
|
}
|
|
343
364
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import intel from 'intel';
|
|
2
|
+
import get from 'lodash.get';
|
|
3
|
+
const log = intel.getLogger('browsertime.chrome.network');
|
|
4
|
+
|
|
5
|
+
export class NetworkManager {
|
|
6
|
+
constructor(bidi, browsingContextIds, options) {
|
|
7
|
+
this.bidi = bidi;
|
|
8
|
+
this.browsingContextIds = browsingContextIds;
|
|
9
|
+
this.maxTimeout = get(options, 'timeouts.pageCompleteCheck', 30_000);
|
|
10
|
+
this.idleTime = get(options, 'timeouts.networkIdle', 5000);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async waitForNetworkIdle() {
|
|
14
|
+
await this.bidi.subscribe(
|
|
15
|
+
'network.beforeRequestSent',
|
|
16
|
+
this.browsingContextIds
|
|
17
|
+
);
|
|
18
|
+
await this.bidi.subscribe(
|
|
19
|
+
'network.responseCompleted',
|
|
20
|
+
this.browsingContextIds
|
|
21
|
+
);
|
|
22
|
+
await this.bidi.subscribe('network.fetchError', this.browsingContextIds);
|
|
23
|
+
|
|
24
|
+
let inflight = 0;
|
|
25
|
+
let lastRequestTimestamp;
|
|
26
|
+
let lastResponseTimestamp;
|
|
27
|
+
this.ws = await this.bidi.socket;
|
|
28
|
+
this.ws.on('message', function (event) {
|
|
29
|
+
const { method } = JSON.parse(Buffer.from(event.toString()));
|
|
30
|
+
if (method) {
|
|
31
|
+
switch (method) {
|
|
32
|
+
case 'network.beforeRequestSent': {
|
|
33
|
+
inflight++;
|
|
34
|
+
lastRequestTimestamp = Date.now();
|
|
35
|
+
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'network.responseCompleted': {
|
|
39
|
+
inflight--;
|
|
40
|
+
lastResponseTimestamp = Date.now();
|
|
41
|
+
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
case 'network.fetchError': {
|
|
45
|
+
inflight--;
|
|
46
|
+
lastResponseTimestamp = Date.now();
|
|
47
|
+
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
// No default
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
// eslint-disable-next-line no-constant-condition
|
|
57
|
+
while (true) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const sinceLastResponseRequest =
|
|
60
|
+
now - Math.max(lastResponseTimestamp, lastRequestTimestamp);
|
|
61
|
+
const sinceStart = now - startTime;
|
|
62
|
+
|
|
63
|
+
if (sinceLastResponseRequest >= this.idleTime) {
|
|
64
|
+
if (inflight > 0) {
|
|
65
|
+
log.info(
|
|
66
|
+
'Idle time without any request/responses. Inflight requests:' +
|
|
67
|
+
inflight
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
await this.bidi.unsubscribe(
|
|
71
|
+
'network.beforeRequestSent',
|
|
72
|
+
this.browsingContextIds
|
|
73
|
+
);
|
|
74
|
+
await this.bidi.unsubscribe(
|
|
75
|
+
'network.responseCompleted',
|
|
76
|
+
this.browsingContextIds
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await this.bidi.unsubscribe(
|
|
80
|
+
'network.fetchError',
|
|
81
|
+
this.browsingContextIds
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sinceStart >= this.maxTimeout) {
|
|
88
|
+
log.info(
|
|
89
|
+
'Timeout waiting for network idle. Inflight requests:' + inflight
|
|
90
|
+
);
|
|
91
|
+
await this.bidi.unsubscribe(
|
|
92
|
+
'network.beforeRequestSent',
|
|
93
|
+
this.browsingContextIds
|
|
94
|
+
);
|
|
95
|
+
await this.bidi.unsubscribe(
|
|
96
|
+
'network.responseCompleted',
|
|
97
|
+
this.browsingContextIds
|
|
98
|
+
);
|
|
99
|
+
await this.bidi.unsubscribe(
|
|
100
|
+
'network.fetchError',
|
|
101
|
+
this.browsingContextIds
|
|
102
|
+
);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await new Promise(r => setTimeout(r, 200));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -12,6 +12,7 @@ import { findFiles } from '../../support/fileUtil.js';
|
|
|
12
12
|
import { GeckoProfiler } from '../geckoProfiler.js';
|
|
13
13
|
import { MemoryReport } from '../memoryReport.js';
|
|
14
14
|
import { PerfStats } from '../perfStats.js';
|
|
15
|
+
import { NetworkManager } from '../networkManager.js';
|
|
15
16
|
import { getHAR } from '../getHAR.js';
|
|
16
17
|
|
|
17
18
|
const log = intel.getLogger('browsertime.firefox');
|
|
@@ -323,4 +324,11 @@ export class Firefox {
|
|
|
323
324
|
* Before the browser is stopped/closed.
|
|
324
325
|
*/
|
|
325
326
|
async afterBrowserStopped() {}
|
|
327
|
+
|
|
328
|
+
async waitForNetworkIdle(driver) {
|
|
329
|
+
const windowId = await driver.getWindowHandle();
|
|
330
|
+
const bidi = await driver.getBidi();
|
|
331
|
+
let network = new NetworkManager(bidi, [windowId], this.options);
|
|
332
|
+
return network.waitForNetworkIdle();
|
|
333
|
+
}
|
|
326
334
|
}
|
package/lib/support/cli.js
CHANGED
|
@@ -176,6 +176,13 @@ export function parseCommandLine() {
|
|
|
176
176
|
'Timeout when waiting for page to complete loading, in milliseconds',
|
|
177
177
|
group: 'timeouts'
|
|
178
178
|
})
|
|
179
|
+
.option('timeouts.networkIdle', {
|
|
180
|
+
default: 5000,
|
|
181
|
+
type: 'number',
|
|
182
|
+
describe:
|
|
183
|
+
'Timeout when running pageCompleteCheckNetworkIdle, in milliseconds',
|
|
184
|
+
group: 'timeouts'
|
|
185
|
+
})
|
|
179
186
|
.option('chrome.args', {
|
|
180
187
|
describe:
|
|
181
188
|
'Extra command line arguments to pass to the Chrome process (e.g. --no-sandbox). ' +
|
|
@@ -274,6 +281,14 @@ export function parseCommandLine() {
|
|
|
274
281
|
type: 'boolean',
|
|
275
282
|
group: 'chrome'
|
|
276
283
|
})
|
|
284
|
+
.option('chrome.timelineRecordingType', {
|
|
285
|
+
alias: 'chrome.traceRecordingType',
|
|
286
|
+
describe: 'Expose the start/stop commands for the chrome trace',
|
|
287
|
+
default: 'pageload',
|
|
288
|
+
choices: ['pageload', 'custom'],
|
|
289
|
+
type: 'string',
|
|
290
|
+
group: 'chrome'
|
|
291
|
+
})
|
|
277
292
|
.option('chrome.collectPerfLog', {
|
|
278
293
|
type: 'boolean',
|
|
279
294
|
describe:
|
|
@@ -903,6 +918,12 @@ export function parseCommandLine() {
|
|
|
903
918
|
type: 'boolean',
|
|
904
919
|
default: false
|
|
905
920
|
})
|
|
921
|
+
.option('pageCompleteCheckNetworkIdle', {
|
|
922
|
+
describe:
|
|
923
|
+
'Alternative way to choose when to end your test that works in Chrome and Firefox. Uses CDP or WebDriver Bidi to look at network traffic instead of running JavaScript in the browser to know when to end the test. By default this will wait 5 seconds of inactivity in the network log (no requets/responses in 5 seconds). Use --timeouts.networkIdle to change the 5 seconds. The test will end after 2 minutes if there is still activity on the network. You can change that timout using --timeouts.pageCompleteCheck ',
|
|
924
|
+
type: 'boolean',
|
|
925
|
+
default: false
|
|
926
|
+
})
|
|
906
927
|
.option('pageCompleteCheckPollTimeout', {
|
|
907
928
|
type: 'number',
|
|
908
929
|
default: 1500,
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browsertime",
|
|
3
3
|
"description": "Get performance metrics from your web page using Browsertime.",
|
|
4
|
-
"version": "17.
|
|
4
|
+
"version": "17.18.0",
|
|
5
5
|
"bin": "./bin/browsertime.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@cypress/xvfb": "1.2.4",
|
|
9
9
|
"@devicefarmer/adbkit": "2.11.3",
|
|
10
|
-
"@sitespeed.io/chromedriver": "
|
|
10
|
+
"@sitespeed.io/chromedriver": "119.0.6045-21",
|
|
11
11
|
"@sitespeed.io/edgedriver": "115.0.1901-183",
|
|
12
12
|
"@sitespeed.io/geckodriver": "0.33.0",
|
|
13
13
|
"@sitespeed.io/throttle": "5.0.0",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"lodash.merge": "4.6.2",
|
|
30
30
|
"lodash.pick": "4.4.0",
|
|
31
31
|
"lodash.set": "4.3.2",
|
|
32
|
-
"selenium-webdriver": "4.
|
|
32
|
+
"selenium-webdriver": "4.12.0",
|
|
33
33
|
"yargs": "17.7.2"
|
|
34
34
|
},
|
|
35
35
|
"optionalDependencies": {
|