browsertime 26.3.1 → 27.0.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 +71 -2
- package/README.md +1 -1
- package/browserscripts/timings/softNavigations.js +94 -0
- package/lib/chrome/har.js +36 -5
- package/lib/chrome/settings/chromeAndroidOptions.js +2 -1
- package/lib/chrome/webdriver/chromium.js +39 -0
- package/lib/chrome/webdriver/setupChromiumOptions.js +86 -31
- package/lib/core/engine/collector.js +340 -317
- package/lib/core/engine/command/addText.js +93 -12
- package/lib/core/engine/command/click.js +239 -139
- package/lib/core/engine/command/commandHelper.js +45 -0
- package/lib/core/engine/command/cookie.js +85 -0
- package/lib/core/engine/command/element.js +37 -2
- package/lib/core/engine/command/javaScript.js +14 -24
- package/lib/core/engine/command/measure/screenshots.js +114 -0
- package/lib/core/engine/command/measure/video.js +85 -0
- package/lib/core/engine/command/measure.js +104 -180
- package/lib/core/engine/command/mouse/clickAndHold.js +6 -3
- package/lib/core/engine/command/mouse/contextClick.js +56 -4
- package/lib/core/engine/command/mouse/doubleClick.js +49 -1
- package/lib/core/engine/command/mouse/mouseMove.js +50 -1
- package/lib/core/engine/command/mouse/singleClick.js +165 -129
- package/lib/core/engine/command/navigation.js +6 -6
- package/lib/core/engine/command/select.js +95 -3
- package/lib/core/engine/command/selectorParser.js +58 -0
- package/lib/core/engine/command/set.js +55 -1
- package/lib/core/engine/command/wait.js +41 -2
- package/lib/core/engine/commands.js +550 -42
- package/lib/core/engine/index.js +206 -150
- package/lib/core/engine/iteration.js +269 -137
- package/lib/firefox/networkManager.js +22 -30
- package/lib/firefox/webdriver/builder.js +14 -0
- package/lib/firefox/webdriver/firefox.js +1 -1
- package/lib/safari/har.js +131 -0
- package/lib/safari/native/ios-capture +0 -0
- package/lib/safari/native/ios-capture.m +340 -0
- package/lib/safari/safariInspectorClient.js +322 -0
- package/lib/safari/webdriver/safari.js +303 -13
- package/lib/safari/webkitToCdpAdapter.js +241 -0
- package/lib/support/cli.js +13 -0
- package/lib/support/engineUtils.js +16 -5
- package/lib/support/getViewPort.js +5 -0
- package/lib/support/har/index.js +41 -3
- package/lib/support/usbPower.js +2 -3
- package/lib/video/postprocessing/finetune/addTextToVideo.js +4 -0
- package/lib/video/postprocessing/finetune/getFont.js +3 -3
- package/lib/video/postprocessing/finetune/getTimingMetrics.js +7 -0
- package/lib/video/postprocessing/finetune/index.js +26 -9
- package/lib/video/screenRecording/ios/iosDeviceRecorder.js +109 -0
- package/lib/video/screenRecording/recorder.js +2 -2
- package/lib/video/screenRecording/setOrangeBackground.js +3 -0
- package/package.json +12 -12
- package/types/core/engine/command/addText.d.ts +35 -6
- package/types/core/engine/command/addText.d.ts.map +1 -1
- package/types/core/engine/command/click.d.ts +87 -16
- package/types/core/engine/command/click.d.ts.map +1 -1
- package/types/core/engine/command/commandHelper.d.ts +16 -0
- package/types/core/engine/command/commandHelper.d.ts.map +1 -0
- package/types/core/engine/command/cookie.d.ts +65 -0
- package/types/core/engine/command/cookie.d.ts.map +1 -0
- package/types/core/engine/command/element.d.ts +19 -1
- package/types/core/engine/command/element.d.ts.map +1 -1
- package/types/core/engine/command/javaScript.d.ts +4 -0
- package/types/core/engine/command/javaScript.d.ts.map +1 -1
- package/types/core/engine/command/measure/screenshots.d.ts +15 -0
- package/types/core/engine/command/measure/screenshots.d.ts.map +1 -0
- package/types/core/engine/command/measure/video.d.ts +26 -0
- package/types/core/engine/command/measure/video.d.ts.map +1 -0
- package/types/core/engine/command/measure.d.ts +59 -33
- package/types/core/engine/command/measure.d.ts.map +1 -1
- package/types/core/engine/command/mouse/clickAndHold.d.ts.map +1 -1
- package/types/core/engine/command/mouse/contextClick.d.ts +28 -4
- package/types/core/engine/command/mouse/contextClick.d.ts.map +1 -1
- package/types/core/engine/command/mouse/doubleClick.d.ts +24 -4
- package/types/core/engine/command/mouse/doubleClick.d.ts.map +1 -1
- package/types/core/engine/command/mouse/mouseMove.d.ts +30 -5
- package/types/core/engine/command/mouse/mouseMove.d.ts.map +1 -1
- package/types/core/engine/command/mouse/singleClick.d.ts +50 -13
- package/types/core/engine/command/mouse/singleClick.d.ts.map +1 -1
- package/types/core/engine/command/navigation.d.ts +3 -3
- package/types/core/engine/command/select.d.ts +45 -8
- package/types/core/engine/command/select.d.ts.map +1 -1
- package/types/core/engine/command/selectorParser.d.ts +20 -0
- package/types/core/engine/command/selectorParser.d.ts.map +1 -0
- package/types/core/engine/command/set.d.ts +34 -7
- package/types/core/engine/command/set.d.ts.map +1 -1
- package/types/core/engine/command/wait.d.ts +29 -6
- package/types/core/engine/command/wait.d.ts.map +1 -1
- package/types/core/engine/commands.d.ts +296 -28
- package/types/core/engine/commands.d.ts.map +1 -1
- package/types/support/engineUtils.d.ts.map +1 -1
- package/types/support/getViewPort.d.ts.map +1 -1
- package/types/support/usbPower.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/addTextToVideo.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/getTimingMetrics.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/index.d.ts.map +1 -1
- package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts +28 -0
- package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts.map +1 -0
- package/types/video/screenRecording/recorder.d.ts +2 -2
- package/types/video/screenRecording/recorder.d.ts.map +1 -1
- package/types/video/screenRecording/setOrangeBackground.d.ts.map +1 -1
- package/types/video/video.d.ts +1 -1
- package/types/video/video.d.ts.map +1 -1
- package/visualmetrics/visualmetrics-portable.py +136 -136
- package/lib/video/screenRecording/ios/convertToMp4.js +0 -22
- package/lib/video/screenRecording/ios/iosRecorder.js +0 -68
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// const log = require('intel').getLogger('browsertime.safari');
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { execaCommand as command, execa } from 'execa';
|
|
5
|
+
import { getLogger } from '@sitespeed.io/log';
|
|
6
|
+
import { SafariInspectorClient } from '../safariInspectorClient.js';
|
|
7
|
+
import { WebkitToCdpAdapter } from '../webkitToCdpAdapter.js';
|
|
8
|
+
import { getHar } from '../har.js';
|
|
9
|
+
import { getEmptyHAR, mergeHars } from '../../support/har/index.js';
|
|
10
|
+
|
|
3
11
|
const delay = ms => new Promise(res => setTimeout(res, ms));
|
|
12
|
+
const log = getLogger('browsertime.safari');
|
|
4
13
|
|
|
5
14
|
export class Safari {
|
|
6
15
|
constructor(storageManager, options) {
|
|
7
16
|
this.options = options;
|
|
8
17
|
this.safariOptions = options.safari || {};
|
|
18
|
+
this.skipHar = options.skipHar || false;
|
|
19
|
+
this.hars = [];
|
|
20
|
+
this.aliasAndUrl = {};
|
|
21
|
+
this.inspectorClient = undefined;
|
|
22
|
+
this.adapter = undefined;
|
|
9
23
|
}
|
|
10
24
|
|
|
11
25
|
/**
|
|
@@ -13,21 +27,184 @@ export class Safari {
|
|
|
13
27
|
*/
|
|
14
28
|
async beforeBrowserStart() {
|
|
15
29
|
if (this.safariOptions.ios) {
|
|
30
|
+
// Start ios-capture server BEFORE safaridriver to keep CoreMediaIO active
|
|
31
|
+
if (this.options.video) {
|
|
32
|
+
// Wake the iPhone into screen-capture mode by asking QuickTime to
|
|
33
|
+
// open a new movie recording. cmiod only enumerates iOS devices
|
|
34
|
+
// for CMIO when something triggers it (historically QuickTime),
|
|
35
|
+
// so we drive QuickTime headlessly here to remove the manual step.
|
|
36
|
+
// Once ios-capture has grabbed the AVCaptureSession the device
|
|
37
|
+
// stays in mirror mode, so we quit QuickTime before safaridriver
|
|
38
|
+
// starts — leaving QT holding the device blocks safaridriver from
|
|
39
|
+
// opening a WebDriver session.
|
|
40
|
+
await this._primeIOSScreenCapture();
|
|
41
|
+
await this._startIOSCaptureServer();
|
|
42
|
+
await this._unprimeIOSScreenCapture();
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
this.driverProcess = command(
|
|
17
46
|
'safaridriver -p ' + this.options.safariDriverPort,
|
|
18
47
|
{
|
|
19
48
|
shell: true
|
|
20
49
|
}
|
|
21
50
|
);
|
|
22
|
-
|
|
51
|
+
await delay(2000);
|
|
52
|
+
|
|
53
|
+
// Start the inspector client for HAR capture
|
|
54
|
+
if (!this.skipHar) {
|
|
55
|
+
this.inspectorClient = new SafariInspectorClient(this.options);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Use AppleScript to launch QuickTime Player and open a new movie
|
|
62
|
+
* recording window. This is what wakes the connected iPhone into
|
|
63
|
+
* screen-capture mode so cmiod will surface it to ios-capture.
|
|
64
|
+
*
|
|
65
|
+
* QuickTime stays running for the rest of the test session — its
|
|
66
|
+
* default recording panel binds to the FaceTime camera, so it does
|
|
67
|
+
* not contend with ios-capture for the iPhone. We quit QT in
|
|
68
|
+
* afterBrowserStopped() once ios-capture has released the device.
|
|
69
|
+
*
|
|
70
|
+
* Best-effort: if osascript fails (e.g. Automation permission has
|
|
71
|
+
* not been granted), we fall through to ios-capture which will then
|
|
72
|
+
* emit its usual "no iOS device found" error and the user can fall
|
|
73
|
+
* back to opening QuickTime manually.
|
|
74
|
+
*/
|
|
75
|
+
async _primeIOSScreenCapture() {
|
|
76
|
+
log.info(
|
|
77
|
+
'Opening QuickTime Player to wake the iPhone into screen-capture mode (make sure the device is plugged in and unlocked)'
|
|
78
|
+
);
|
|
79
|
+
// launch (not activate) keeps QT from stealing focus. After the
|
|
80
|
+
// recording window appears we minimise it to the Dock and try to
|
|
81
|
+
// hide the whole process via System Events. The System Events call
|
|
82
|
+
// requires Accessibility permission for osascript on first run; if
|
|
83
|
+
// it's denied we fall through to just the minimised window.
|
|
84
|
+
const script = `
|
|
85
|
+
tell application "QuickTime Player"
|
|
86
|
+
launch
|
|
87
|
+
delay 0.5
|
|
88
|
+
if (count of (documents whose name contains "Movie Recording")) is 0 then
|
|
89
|
+
new movie recording
|
|
90
|
+
end if
|
|
91
|
+
delay 0.3
|
|
92
|
+
try
|
|
93
|
+
set miniaturized of every window to true
|
|
94
|
+
end try
|
|
95
|
+
end tell
|
|
96
|
+
try
|
|
97
|
+
tell application "System Events"
|
|
98
|
+
set visible of process "QuickTime Player" to false
|
|
99
|
+
end tell
|
|
100
|
+
end try
|
|
101
|
+
`;
|
|
102
|
+
try {
|
|
103
|
+
await execa('osascript', ['-e', script], { timeout: 10_000 });
|
|
104
|
+
// Give cmiod a beat to enumerate the now-awake iPhone before
|
|
105
|
+
// ios-capture starts looking for it.
|
|
106
|
+
await delay(2000);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
log.warn(
|
|
109
|
+
'Could not prime iOS screen capture via QuickTime: %s. ' +
|
|
110
|
+
'Open QuickTime Player → New Movie Recording manually before re-running.',
|
|
111
|
+
error.message
|
|
112
|
+
);
|
|
23
113
|
}
|
|
24
114
|
}
|
|
25
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Quit QuickTime Player without saving — paired with
|
|
118
|
+
* _primeIOSScreenCapture(). Best-effort.
|
|
119
|
+
*/
|
|
120
|
+
async _unprimeIOSScreenCapture() {
|
|
121
|
+
try {
|
|
122
|
+
await execa(
|
|
123
|
+
'osascript',
|
|
124
|
+
['-e', 'tell application "QuickTime Player" to quit saving no'],
|
|
125
|
+
{ timeout: 5000 }
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
// best-effort cleanup
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start the ios-capture server that enables CoreMediaIO and
|
|
134
|
+
* accepts START/STOP recording commands.
|
|
135
|
+
*/
|
|
136
|
+
async _startIOSCaptureServer() {
|
|
137
|
+
const iosCaptureCommand = path.resolve(
|
|
138
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
139
|
+
'..',
|
|
140
|
+
'native',
|
|
141
|
+
'ios-capture'
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
log.debug('Starting ios-capture server');
|
|
145
|
+
this.iosCaptureProcess = execa(iosCaptureCommand, ['-r', '30'], {
|
|
146
|
+
stdin: 'pipe',
|
|
147
|
+
stdout: 'pipe',
|
|
148
|
+
stderr: 'pipe',
|
|
149
|
+
reject: false
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.iosCaptureProcess.stderr.on('data', data => {
|
|
153
|
+
log.debug('ios-capture: %s', data.toString().trim());
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.iosCaptureProcess.catch(error => {
|
|
157
|
+
if (!error.isCanceled) {
|
|
158
|
+
log.error('ios-capture process ended: %s', error.message);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Wait for READY signal
|
|
163
|
+
await new Promise((resolve, reject) => {
|
|
164
|
+
let buffer = '';
|
|
165
|
+
const handler = data => {
|
|
166
|
+
buffer += data.toString();
|
|
167
|
+
if (buffer.includes('READY')) {
|
|
168
|
+
this.iosCaptureProcess.stdout.removeListener('data', handler);
|
|
169
|
+
resolve();
|
|
170
|
+
}
|
|
171
|
+
if (buffer.includes('ERROR')) {
|
|
172
|
+
this.iosCaptureProcess.stdout.removeListener('data', handler);
|
|
173
|
+
reject(new Error(`ios-capture: ${buffer.trim()}`));
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
this.iosCaptureProcess.stdout.on('data', handler);
|
|
177
|
+
setTimeout(
|
|
178
|
+
() => reject(new Error('ios-capture server startup timeout')),
|
|
179
|
+
15_000
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Store the process on options so the IOSDeviceRecorder can access it
|
|
184
|
+
this.options._iosCaptureProcess = this.iosCaptureProcess;
|
|
185
|
+
log.debug('ios-capture server ready');
|
|
186
|
+
}
|
|
187
|
+
|
|
26
188
|
/**
|
|
27
189
|
* The browser is up and running, now its time to start to
|
|
28
190
|
* configure what you need.
|
|
29
191
|
*/
|
|
30
|
-
async afterBrowserStart() {
|
|
192
|
+
async afterBrowserStart(runner) {
|
|
193
|
+
if (this.safariOptions.ios && this.inspectorClient) {
|
|
194
|
+
// iWDP exposes every open Safari tab on the device, not just the one
|
|
195
|
+
// safaridriver controls. Drive the WebDriver tab to a unique sentinel
|
|
196
|
+
// URL so the inspector can identify and pin it; otherwise an unrelated
|
|
197
|
+
// tab's Target.targetCreated may clobber the active target and the HAR
|
|
198
|
+
// comes back empty.
|
|
199
|
+
const sentinel = `data:text/html;charset=utf-8,<title>browsertime-${randomUUID()}</title>`;
|
|
200
|
+
try {
|
|
201
|
+
await runner.getDriver().get(sentinel);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
log.debug('Failed to navigate to sentinel URL: %s', error.message);
|
|
204
|
+
}
|
|
205
|
+
await this.inspectorClient.setup(page => page.url === sentinel);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
31
208
|
|
|
32
209
|
/**
|
|
33
210
|
* Before the first iteration of your tests start.
|
|
@@ -37,7 +214,36 @@ export class Safari {
|
|
|
37
214
|
/**
|
|
38
215
|
* Before each URL/test runs.
|
|
39
216
|
*/
|
|
40
|
-
async beforeEachURL() {
|
|
217
|
+
async beforeEachURL() {
|
|
218
|
+
if (this.inspectorClient) {
|
|
219
|
+
this.adapter = new WebkitToCdpAdapter();
|
|
220
|
+
this.inspectorClient.removeAllListeners();
|
|
221
|
+
|
|
222
|
+
// Register listeners for all network and page events
|
|
223
|
+
const networkEvents = [
|
|
224
|
+
'Network.requestWillBeSent',
|
|
225
|
+
'Network.responseReceived',
|
|
226
|
+
'Network.dataReceived',
|
|
227
|
+
'Network.loadingFinished',
|
|
228
|
+
'Network.loadingFailed',
|
|
229
|
+
'Network.requestServedFromMemoryCache',
|
|
230
|
+
'Page.loadEventFired',
|
|
231
|
+
'Page.domContentEventFired'
|
|
232
|
+
];
|
|
233
|
+
for (const event of networkEvents) {
|
|
234
|
+
this.inspectorClient.on(event, params => {
|
|
235
|
+
this.adapter.onWebkitEvent(event, params);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Reconnect to iwdp to pick up any new pages after safaridriver setup
|
|
240
|
+
try {
|
|
241
|
+
await this.inspectorClient.reconnect();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
log.debug('Reconnect attempt: %s', error.message);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
41
247
|
|
|
42
248
|
/**
|
|
43
249
|
* When the page has finsihed loading, this functions runs (before
|
|
@@ -45,19 +251,44 @@ export class Safari {
|
|
|
45
251
|
* stop trace logging, stop measuring etc.
|
|
46
252
|
*
|
|
47
253
|
*/
|
|
48
|
-
async afterPageCompleteCheck(runner) {
|
|
254
|
+
async afterPageCompleteCheck(runner, index, url, alias) {
|
|
49
255
|
const resources = await runner.runScript(
|
|
50
256
|
'return performance.getEntriesByType("resource");',
|
|
51
257
|
'RESOURCE_TIMINGS'
|
|
52
258
|
);
|
|
53
259
|
|
|
54
|
-
|
|
260
|
+
const result = {
|
|
55
261
|
browserScripts: {
|
|
56
262
|
timings: {
|
|
57
263
|
resourceTimings: resources
|
|
58
264
|
}
|
|
59
|
-
}
|
|
265
|
+
},
|
|
266
|
+
url,
|
|
267
|
+
alias
|
|
60
268
|
};
|
|
269
|
+
|
|
270
|
+
// Collect HAR from inspector client
|
|
271
|
+
if (this.inspectorClient && this.adapter) {
|
|
272
|
+
try {
|
|
273
|
+
const har = await getHar(
|
|
274
|
+
this.adapter,
|
|
275
|
+
result,
|
|
276
|
+
index,
|
|
277
|
+
this.inspectorClient,
|
|
278
|
+
this.safariOptions.includeResponseBodies,
|
|
279
|
+
this.aliasAndUrl,
|
|
280
|
+
this.options.cleanSensitiveHeaders
|
|
281
|
+
);
|
|
282
|
+
if (har.log.entries.length > 0) {
|
|
283
|
+
this.hars.push(har);
|
|
284
|
+
log.debug('Captured HAR with %d entries', har.log.entries.length);
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log.error('Failed to generate HAR: %s', error.message);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
61
292
|
}
|
|
62
293
|
|
|
63
294
|
/**
|
|
@@ -68,19 +299,31 @@ export class Safari {
|
|
|
68
299
|
/**
|
|
69
300
|
* This method is called if a runs fail
|
|
70
301
|
*/
|
|
71
|
-
failing() {
|
|
302
|
+
failing(url) {
|
|
303
|
+
if (!this.skipHar) {
|
|
304
|
+
this.hars.push(getEmptyHAR(url, 'Safari'));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
72
307
|
|
|
73
308
|
/**
|
|
74
309
|
* Get the HAR file for all the runs.
|
|
75
310
|
*/
|
|
76
311
|
async getHARs() {
|
|
77
|
-
|
|
312
|
+
if (this.hars.length > 0) {
|
|
313
|
+
return { har: mergeHars(this.hars) };
|
|
314
|
+
}
|
|
315
|
+
return {};
|
|
78
316
|
}
|
|
79
317
|
|
|
80
318
|
/**
|
|
81
319
|
* Before the browser is stopped/closed.
|
|
82
320
|
*/
|
|
83
|
-
async beforeBrowserStop() {
|
|
321
|
+
async beforeBrowserStop() {
|
|
322
|
+
if (this.inspectorClient) {
|
|
323
|
+
await this.inspectorClient.close();
|
|
324
|
+
this.inspectorClient = undefined;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
84
327
|
|
|
85
328
|
async afterBrowserStopped() {
|
|
86
329
|
if (this.safariOptions.useSimulator) {
|
|
@@ -92,12 +335,59 @@ export class Safari {
|
|
|
92
335
|
);
|
|
93
336
|
}
|
|
94
337
|
|
|
95
|
-
if (this.safariOptions.ios) {
|
|
96
|
-
|
|
338
|
+
if (this.safariOptions.ios && this.driverProcess) {
|
|
339
|
+
const driverProc = this.driverProcess;
|
|
340
|
+
this.driverProcess = undefined;
|
|
341
|
+
try {
|
|
342
|
+
driverProc.kill('SIGINT');
|
|
343
|
+
} catch {
|
|
344
|
+
// Already dead
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Stop ios-capture server: send QUIT, then wait for it to exit and
|
|
349
|
+
// release the AVCaptureSession on the iPhone. If we don't wait, the
|
|
350
|
+
// device can stay locked in CMIO until something else evicts it,
|
|
351
|
+
// which breaks subsequent runs (and even QuickTime).
|
|
352
|
+
if (this.iosCaptureProcess) {
|
|
353
|
+
const proc = this.iosCaptureProcess;
|
|
354
|
+
this.iosCaptureProcess = undefined;
|
|
355
|
+
try {
|
|
356
|
+
proc.stdin.write('QUIT\n');
|
|
357
|
+
} catch {
|
|
358
|
+
// stdin already closed
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await Promise.race([
|
|
362
|
+
proc,
|
|
363
|
+
new Promise((_, reject) =>
|
|
364
|
+
setTimeout(
|
|
365
|
+
() => reject(new Error('ios-capture exit timeout')),
|
|
366
|
+
5000
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
]);
|
|
370
|
+
} catch {
|
|
371
|
+
try {
|
|
372
|
+
proc.kill('SIGTERM');
|
|
373
|
+
await Promise.race([
|
|
374
|
+
proc,
|
|
375
|
+
new Promise((_, reject) =>
|
|
376
|
+
setTimeout(() => reject(new Error('SIGTERM timeout')), 2000)
|
|
377
|
+
)
|
|
378
|
+
]);
|
|
379
|
+
} catch {
|
|
380
|
+
try {
|
|
381
|
+
proc.kill('SIGKILL');
|
|
382
|
+
} catch {
|
|
383
|
+
// Ignore
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
97
387
|
}
|
|
98
388
|
}
|
|
99
389
|
|
|
100
390
|
async waitForNetworkIdle() {
|
|
101
|
-
throw new Error('waitForNetworkIdle is not implemented
|
|
391
|
+
throw new Error('waitForNetworkIdle is not yet implemented for Safari');
|
|
102
392
|
}
|
|
103
393
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { getLogger } from '@sitespeed.io/log';
|
|
2
|
+
|
|
3
|
+
const log = getLogger('browsertime.safari.adapter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Adapts WebKit Inspector Protocol network events to Chrome DevTools Protocol
|
|
7
|
+
* format so they can be processed by chrome-har.
|
|
8
|
+
*/
|
|
9
|
+
export class WebkitToCdpAdapter {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.messages = [];
|
|
12
|
+
this.responseMap = new Map();
|
|
13
|
+
this.requestTimestamps = new Map(); // requestId -> { timestamp, wallTime }
|
|
14
|
+
this.mainFrameId = undefined;
|
|
15
|
+
this.pageCreated = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
onWebkitEvent(method, params) {
|
|
19
|
+
switch (method) {
|
|
20
|
+
case 'Network.requestWillBeSent': {
|
|
21
|
+
const adapted = this._adaptRequestWillBeSent(params);
|
|
22
|
+
|
|
23
|
+
// chrome-har needs a Page.frameNavigated event to create a page.
|
|
24
|
+
// Inject it when we see the first Document request.
|
|
25
|
+
if (!this.pageCreated && adapted.type === 'Document') {
|
|
26
|
+
this.mainFrameId = adapted.frameId || 'main';
|
|
27
|
+
this.messages.push({
|
|
28
|
+
method: 'Page.frameStartedLoading',
|
|
29
|
+
params: {
|
|
30
|
+
frameId: this.mainFrameId
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.pageCreated = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.messages.push({
|
|
37
|
+
method: 'Network.requestWillBeSent',
|
|
38
|
+
params: adapted
|
|
39
|
+
});
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'Network.responseReceived': {
|
|
43
|
+
const adapted = {
|
|
44
|
+
method: 'Network.responseReceived',
|
|
45
|
+
params: this._adaptResponseReceived(params)
|
|
46
|
+
};
|
|
47
|
+
this.messages.push(adapted);
|
|
48
|
+
this.responseMap.set(params.requestId, adapted);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'Network.dataReceived': {
|
|
52
|
+
this.messages.push({
|
|
53
|
+
method: 'Network.dataReceived',
|
|
54
|
+
params: {
|
|
55
|
+
requestId: params.requestId,
|
|
56
|
+
timestamp: params.timestamp,
|
|
57
|
+
dataLength: params.dataLength,
|
|
58
|
+
encodedDataLength: params.encodedDataLength || 0
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'Network.loadingFinished': {
|
|
64
|
+
this._patchResponseWithMetrics(params);
|
|
65
|
+
this.messages.push({
|
|
66
|
+
method: 'Network.loadingFinished',
|
|
67
|
+
params: {
|
|
68
|
+
requestId: params.requestId,
|
|
69
|
+
timestamp: params.timestamp,
|
|
70
|
+
encodedDataLength:
|
|
71
|
+
params.metrics?.responseBodyBytesReceived ??
|
|
72
|
+
params.encodedDataLength ??
|
|
73
|
+
0
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'Network.loadingFailed': {
|
|
79
|
+
this.messages.push({
|
|
80
|
+
method: 'Network.loadingFailed',
|
|
81
|
+
params: {
|
|
82
|
+
requestId: params.requestId,
|
|
83
|
+
timestamp: params.timestamp,
|
|
84
|
+
errorText: params.errorText || 'Failed',
|
|
85
|
+
canceled: params.canceled || false,
|
|
86
|
+
type: params.type
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'Network.requestServedFromMemoryCache': {
|
|
92
|
+
this.messages.push({
|
|
93
|
+
method: 'Network.requestServedFromMemoryCache',
|
|
94
|
+
params
|
|
95
|
+
});
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case 'Page.loadEventFired': {
|
|
99
|
+
this.messages.push({
|
|
100
|
+
method: 'Page.loadEventFired',
|
|
101
|
+
params: { timestamp: params.timestamp }
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'Page.domContentEventFired': {
|
|
106
|
+
this.messages.push({
|
|
107
|
+
method: 'Page.domContentEventFired',
|
|
108
|
+
params: { timestamp: params.timestamp }
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
default: {
|
|
113
|
+
log.debug('Unhandled WebKit event: %s', method);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_adaptRequestWillBeSent(params) {
|
|
119
|
+
// Store timestamp mapping for converting timing.requestTime to epoch-relative
|
|
120
|
+
this.requestTimestamps.set(params.requestId, {
|
|
121
|
+
timestamp: params.timestamp,
|
|
122
|
+
wallTime: params.walltime || params.wallTime || Date.now() / 1000
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
requestId: params.requestId,
|
|
127
|
+
frameId: params.frameId,
|
|
128
|
+
loaderId: params.loaderId || params.requestId,
|
|
129
|
+
documentURL: params.documentURL || '',
|
|
130
|
+
timestamp: params.timestamp,
|
|
131
|
+
wallTime: params.walltime || params.wallTime || Date.now() / 1000,
|
|
132
|
+
type: params.type,
|
|
133
|
+
initiator: params.initiator || { type: 'other' },
|
|
134
|
+
request: {
|
|
135
|
+
url: params.request?.url || '',
|
|
136
|
+
method: params.request?.method || 'GET',
|
|
137
|
+
headers: params.request?.headers || {},
|
|
138
|
+
postData: params.request?.postData,
|
|
139
|
+
initialPriority: 'Medium'
|
|
140
|
+
},
|
|
141
|
+
redirectResponse: params.redirectResponse
|
|
142
|
+
? this._adaptResponse(params.redirectResponse)
|
|
143
|
+
: undefined
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_adaptResponseReceived(params) {
|
|
148
|
+
return {
|
|
149
|
+
requestId: params.requestId,
|
|
150
|
+
frameId: params.frameId,
|
|
151
|
+
loaderId: params.loaderId || params.requestId,
|
|
152
|
+
timestamp: params.timestamp,
|
|
153
|
+
type: params.type,
|
|
154
|
+
response: this._adaptResponse(params.response || {})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_adaptResponse(response) {
|
|
159
|
+
const timing = response.timing
|
|
160
|
+
? this._adaptTiming(response.timing)
|
|
161
|
+
: undefined;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
url: response.url || '',
|
|
165
|
+
status: response.status || 0,
|
|
166
|
+
statusText: response.statusText || '',
|
|
167
|
+
headers: response.headers || {},
|
|
168
|
+
mimeType: response.mimeType || '',
|
|
169
|
+
connectionId: String(response.connectionId || '0'),
|
|
170
|
+
remoteIPAddress: response.remoteIPAddress || '',
|
|
171
|
+
remotePort: response.remotePort || 0,
|
|
172
|
+
protocol: response.protocol || '',
|
|
173
|
+
encodedDataLength: response.encodedDataLength || 0,
|
|
174
|
+
fromDiskCache:
|
|
175
|
+
response.source === 'disk-cache' ||
|
|
176
|
+
response.source === 'memory-cache' ||
|
|
177
|
+
response.fromDiskCache === true,
|
|
178
|
+
fromServiceWorker: response.source === 'service-worker' || false,
|
|
179
|
+
timing
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_adaptTiming(timing) {
|
|
184
|
+
// WebKit timing fields are ms offsets from fetchStart, similar to CDP
|
|
185
|
+
// but secureConnectionStart can be an absolute timestamp when reused
|
|
186
|
+
let sslStart = timing.secureConnectionStart ?? -1;
|
|
187
|
+
let sslEnd = timing.connectEnd ?? -1;
|
|
188
|
+
|
|
189
|
+
// Detect invalid SSL timestamps (WebKit sends huge negative numbers for reused connections)
|
|
190
|
+
if (sslStart > 100_000 || sslStart < -1) {
|
|
191
|
+
sslStart = -1;
|
|
192
|
+
sslEnd = -1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
// WebKit uses startTime (seconds, page-relative) as the request base time
|
|
197
|
+
requestTime: Number(timing.startTime) || 0,
|
|
198
|
+
dnsStart: Number(timing.domainLookupStart ?? -1),
|
|
199
|
+
dnsEnd: Number(timing.domainLookupEnd ?? -1),
|
|
200
|
+
connectStart: Number(timing.connectStart ?? -1),
|
|
201
|
+
connectEnd: Number(timing.connectEnd ?? -1),
|
|
202
|
+
sslStart: Number(sslStart),
|
|
203
|
+
sslEnd: Number(sslEnd),
|
|
204
|
+
sendStart: Number(timing.requestStart ?? -1),
|
|
205
|
+
sendEnd: Number(timing.requestStart ?? -1),
|
|
206
|
+
receiveHeadersEnd: Number(timing.responseStart ?? -1)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_patchResponseWithMetrics(loadingFinishedParams) {
|
|
211
|
+
const metrics = loadingFinishedParams.metrics;
|
|
212
|
+
if (!metrics) return;
|
|
213
|
+
|
|
214
|
+
const responseMsg = this.responseMap.get(loadingFinishedParams.requestId);
|
|
215
|
+
if (!responseMsg) return;
|
|
216
|
+
|
|
217
|
+
const response = responseMsg.params.response;
|
|
218
|
+
if (metrics.remoteAddress && !response.remoteIPAddress) {
|
|
219
|
+
const separator = metrics.remoteAddress.lastIndexOf(':');
|
|
220
|
+
response.remoteIPAddress =
|
|
221
|
+
separator === -1
|
|
222
|
+
? metrics.remoteAddress
|
|
223
|
+
: metrics.remoteAddress.slice(0, separator);
|
|
224
|
+
}
|
|
225
|
+
if (metrics.protocol && !response.protocol) {
|
|
226
|
+
response.protocol = metrics.protocol;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
getMessages() {
|
|
231
|
+
return [...this.messages];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
reset() {
|
|
235
|
+
this.messages = [];
|
|
236
|
+
this.responseMap.clear();
|
|
237
|
+
this.requestTimestamps.clear();
|
|
238
|
+
this.pageCreated = false;
|
|
239
|
+
this.mainFrameId = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
package/lib/support/cli.js
CHANGED
|
@@ -194,6 +194,13 @@ export function parseCommandLine() {
|
|
|
194
194
|
'Timeout when running pageCompleteCheckNetworkIdle, in milliseconds',
|
|
195
195
|
group: 'timeouts'
|
|
196
196
|
})
|
|
197
|
+
.option('timeouts.elementWait', {
|
|
198
|
+
default: 6000,
|
|
199
|
+
type: 'number',
|
|
200
|
+
describe:
|
|
201
|
+
'Timeout when waiting for an element to appear before interacting with it (click, addText etc), in milliseconds. Default is 6000 (6 seconds). Set to 0 to disable.',
|
|
202
|
+
group: 'timeouts'
|
|
203
|
+
})
|
|
197
204
|
.option('chrome.args', {
|
|
198
205
|
describe:
|
|
199
206
|
'Extra command line arguments to pass to the Chrome process (e.g. --no-sandbox). ' +
|
|
@@ -905,6 +912,12 @@ export function parseCommandLine() {
|
|
|
905
912
|
type: 'boolean',
|
|
906
913
|
group: 'safari'
|
|
907
914
|
})
|
|
915
|
+
.option('safari.includeResponseBodies', {
|
|
916
|
+
describe:
|
|
917
|
+
'Include response bodies in HAR for Safari iOS. "none" (default), "html", or "all".',
|
|
918
|
+
default: 'none',
|
|
919
|
+
group: 'safari'
|
|
920
|
+
})
|
|
908
921
|
/** Screenshot */
|
|
909
922
|
.option('screenshot', {
|
|
910
923
|
type: 'boolean',
|
|
@@ -19,17 +19,28 @@ async function loadFile(script, options, throwError) {
|
|
|
19
19
|
// if the script is an AsyncFunction, return it.
|
|
20
20
|
if (script instanceof AsyncFunction) {
|
|
21
21
|
return script;
|
|
22
|
-
} else if (!script.endsWith('js')) {
|
|
22
|
+
} else if (!script.endsWith('js') && !script.endsWith('ts')) {
|
|
23
23
|
// This is an inline script
|
|
24
24
|
return script;
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
|
|
27
|
+
if (script.endsWith('ts')) {
|
|
28
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
29
|
+
if (major < 22 || (major === 22 && minor < 18)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`TypeScript scripts require Node.js 22.18.0 or later. You are running ${process.versions.node}.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// If we follow correct naming for modules (.mjs/.mts) or commonjs (.cjs/.cts)
|
|
36
|
+
// we just loads it. .js/.ts files is loaded as commonjs if you set
|
|
28
37
|
// --cjs true
|
|
29
38
|
if (
|
|
30
39
|
options.cjs === false ||
|
|
31
40
|
script.endsWith('.mjs') ||
|
|
32
|
-
script.endsWith('.cjs')
|
|
41
|
+
script.endsWith('.cjs') ||
|
|
42
|
+
script.endsWith('.mts') ||
|
|
43
|
+
script.endsWith('.cts')
|
|
33
44
|
) {
|
|
34
45
|
let myFunction = await import(pathToFileURL(path.resolve(script)));
|
|
35
46
|
return myFunction.default ?? myFunction;
|
|
@@ -90,7 +101,7 @@ export async function loadPrePostScripts(scripts, options) {
|
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
export async function loadPageCompleteScript(script) {
|
|
93
|
-
if (script && script.endsWith('js')) {
|
|
104
|
+
if (script && (script.endsWith('js') || script.endsWith('ts'))) {
|
|
94
105
|
return readFile(path.resolve(script), 'utf8');
|
|
95
106
|
}
|
|
96
107
|
return script;
|