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.
Files changed (106) hide show
  1. package/CHANGELOG.md +71 -2
  2. package/README.md +1 -1
  3. package/browserscripts/timings/softNavigations.js +94 -0
  4. package/lib/chrome/har.js +36 -5
  5. package/lib/chrome/settings/chromeAndroidOptions.js +2 -1
  6. package/lib/chrome/webdriver/chromium.js +39 -0
  7. package/lib/chrome/webdriver/setupChromiumOptions.js +86 -31
  8. package/lib/core/engine/collector.js +340 -317
  9. package/lib/core/engine/command/addText.js +93 -12
  10. package/lib/core/engine/command/click.js +239 -139
  11. package/lib/core/engine/command/commandHelper.js +45 -0
  12. package/lib/core/engine/command/cookie.js +85 -0
  13. package/lib/core/engine/command/element.js +37 -2
  14. package/lib/core/engine/command/javaScript.js +14 -24
  15. package/lib/core/engine/command/measure/screenshots.js +114 -0
  16. package/lib/core/engine/command/measure/video.js +85 -0
  17. package/lib/core/engine/command/measure.js +104 -180
  18. package/lib/core/engine/command/mouse/clickAndHold.js +6 -3
  19. package/lib/core/engine/command/mouse/contextClick.js +56 -4
  20. package/lib/core/engine/command/mouse/doubleClick.js +49 -1
  21. package/lib/core/engine/command/mouse/mouseMove.js +50 -1
  22. package/lib/core/engine/command/mouse/singleClick.js +165 -129
  23. package/lib/core/engine/command/navigation.js +6 -6
  24. package/lib/core/engine/command/select.js +95 -3
  25. package/lib/core/engine/command/selectorParser.js +58 -0
  26. package/lib/core/engine/command/set.js +55 -1
  27. package/lib/core/engine/command/wait.js +41 -2
  28. package/lib/core/engine/commands.js +550 -42
  29. package/lib/core/engine/index.js +206 -150
  30. package/lib/core/engine/iteration.js +269 -137
  31. package/lib/firefox/networkManager.js +22 -30
  32. package/lib/firefox/webdriver/builder.js +14 -0
  33. package/lib/firefox/webdriver/firefox.js +1 -1
  34. package/lib/safari/har.js +131 -0
  35. package/lib/safari/native/ios-capture +0 -0
  36. package/lib/safari/native/ios-capture.m +340 -0
  37. package/lib/safari/safariInspectorClient.js +322 -0
  38. package/lib/safari/webdriver/safari.js +303 -13
  39. package/lib/safari/webkitToCdpAdapter.js +241 -0
  40. package/lib/support/cli.js +13 -0
  41. package/lib/support/engineUtils.js +16 -5
  42. package/lib/support/getViewPort.js +5 -0
  43. package/lib/support/har/index.js +41 -3
  44. package/lib/support/usbPower.js +2 -3
  45. package/lib/video/postprocessing/finetune/addTextToVideo.js +4 -0
  46. package/lib/video/postprocessing/finetune/getFont.js +3 -3
  47. package/lib/video/postprocessing/finetune/getTimingMetrics.js +7 -0
  48. package/lib/video/postprocessing/finetune/index.js +26 -9
  49. package/lib/video/screenRecording/ios/iosDeviceRecorder.js +109 -0
  50. package/lib/video/screenRecording/recorder.js +2 -2
  51. package/lib/video/screenRecording/setOrangeBackground.js +3 -0
  52. package/package.json +12 -12
  53. package/types/core/engine/command/addText.d.ts +35 -6
  54. package/types/core/engine/command/addText.d.ts.map +1 -1
  55. package/types/core/engine/command/click.d.ts +87 -16
  56. package/types/core/engine/command/click.d.ts.map +1 -1
  57. package/types/core/engine/command/commandHelper.d.ts +16 -0
  58. package/types/core/engine/command/commandHelper.d.ts.map +1 -0
  59. package/types/core/engine/command/cookie.d.ts +65 -0
  60. package/types/core/engine/command/cookie.d.ts.map +1 -0
  61. package/types/core/engine/command/element.d.ts +19 -1
  62. package/types/core/engine/command/element.d.ts.map +1 -1
  63. package/types/core/engine/command/javaScript.d.ts +4 -0
  64. package/types/core/engine/command/javaScript.d.ts.map +1 -1
  65. package/types/core/engine/command/measure/screenshots.d.ts +15 -0
  66. package/types/core/engine/command/measure/screenshots.d.ts.map +1 -0
  67. package/types/core/engine/command/measure/video.d.ts +26 -0
  68. package/types/core/engine/command/measure/video.d.ts.map +1 -0
  69. package/types/core/engine/command/measure.d.ts +59 -33
  70. package/types/core/engine/command/measure.d.ts.map +1 -1
  71. package/types/core/engine/command/mouse/clickAndHold.d.ts.map +1 -1
  72. package/types/core/engine/command/mouse/contextClick.d.ts +28 -4
  73. package/types/core/engine/command/mouse/contextClick.d.ts.map +1 -1
  74. package/types/core/engine/command/mouse/doubleClick.d.ts +24 -4
  75. package/types/core/engine/command/mouse/doubleClick.d.ts.map +1 -1
  76. package/types/core/engine/command/mouse/mouseMove.d.ts +30 -5
  77. package/types/core/engine/command/mouse/mouseMove.d.ts.map +1 -1
  78. package/types/core/engine/command/mouse/singleClick.d.ts +50 -13
  79. package/types/core/engine/command/mouse/singleClick.d.ts.map +1 -1
  80. package/types/core/engine/command/navigation.d.ts +3 -3
  81. package/types/core/engine/command/select.d.ts +45 -8
  82. package/types/core/engine/command/select.d.ts.map +1 -1
  83. package/types/core/engine/command/selectorParser.d.ts +20 -0
  84. package/types/core/engine/command/selectorParser.d.ts.map +1 -0
  85. package/types/core/engine/command/set.d.ts +34 -7
  86. package/types/core/engine/command/set.d.ts.map +1 -1
  87. package/types/core/engine/command/wait.d.ts +29 -6
  88. package/types/core/engine/command/wait.d.ts.map +1 -1
  89. package/types/core/engine/commands.d.ts +296 -28
  90. package/types/core/engine/commands.d.ts.map +1 -1
  91. package/types/support/engineUtils.d.ts.map +1 -1
  92. package/types/support/getViewPort.d.ts.map +1 -1
  93. package/types/support/usbPower.d.ts.map +1 -1
  94. package/types/video/postprocessing/finetune/addTextToVideo.d.ts.map +1 -1
  95. package/types/video/postprocessing/finetune/getTimingMetrics.d.ts.map +1 -1
  96. package/types/video/postprocessing/finetune/index.d.ts.map +1 -1
  97. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts +28 -0
  98. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts.map +1 -0
  99. package/types/video/screenRecording/recorder.d.ts +2 -2
  100. package/types/video/screenRecording/recorder.d.ts.map +1 -1
  101. package/types/video/screenRecording/setOrangeBackground.d.ts.map +1 -1
  102. package/types/video/video.d.ts +1 -1
  103. package/types/video/video.d.ts.map +1 -1
  104. package/visualmetrics/visualmetrics-portable.py +136 -136
  105. package/lib/video/screenRecording/ios/convertToMp4.js +0 -22
  106. package/lib/video/screenRecording/ios/iosRecorder.js +0 -68
@@ -1,11 +1,25 @@
1
1
  // const log = require('intel').getLogger('browsertime.safari');
2
- import { execaCommand as command } from 'execa';
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
- return delay(2000);
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
- return {
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
- return [];
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
- await this.driverProcess.kill('SIGINT');
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 in Safari');
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
+ }
@@ -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
- // If we follow correct naming for modules (.mjs) or commonjs (.cjs)
27
- // we just loads it. .js files is loaded as commonjs if you set
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;