browsertime 26.3.2 → 27.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +72 -0
  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 +40 -1
  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 +268 -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 +277 -137
  31. package/lib/firefox/networkManager.js +22 -30
  32. package/lib/firefox/webdriver/builder.js +14 -0
  33. package/lib/safari/har.js +131 -0
  34. package/lib/safari/native/ios-capture +0 -0
  35. package/lib/safari/native/ios-capture.m +340 -0
  36. package/lib/safari/safariInspectorClient.js +322 -0
  37. package/lib/safari/webdriver/safari.js +303 -13
  38. package/lib/safari/webkitToCdpAdapter.js +241 -0
  39. package/lib/support/cli.js +13 -0
  40. package/lib/support/engineUtils.js +16 -5
  41. package/lib/support/getViewPort.js +5 -0
  42. package/lib/support/har/index.js +41 -3
  43. package/lib/support/usbPower.js +2 -3
  44. package/lib/video/postprocessing/finetune/addTextToVideo.js +4 -0
  45. package/lib/video/postprocessing/finetune/getFont.js +3 -3
  46. package/lib/video/postprocessing/finetune/getTimingMetrics.js +7 -0
  47. package/lib/video/postprocessing/finetune/index.js +26 -9
  48. package/lib/video/screenRecording/ios/iosDeviceRecorder.js +109 -0
  49. package/lib/video/screenRecording/recorder.js +2 -2
  50. package/lib/video/screenRecording/setOrangeBackground.js +3 -0
  51. package/package.json +11 -11
  52. package/types/core/engine/command/addText.d.ts +35 -6
  53. package/types/core/engine/command/addText.d.ts.map +1 -1
  54. package/types/core/engine/command/click.d.ts +87 -16
  55. package/types/core/engine/command/click.d.ts.map +1 -1
  56. package/types/core/engine/command/commandHelper.d.ts +16 -0
  57. package/types/core/engine/command/commandHelper.d.ts.map +1 -0
  58. package/types/core/engine/command/cookie.d.ts +65 -0
  59. package/types/core/engine/command/cookie.d.ts.map +1 -0
  60. package/types/core/engine/command/element.d.ts +19 -1
  61. package/types/core/engine/command/element.d.ts.map +1 -1
  62. package/types/core/engine/command/javaScript.d.ts +4 -0
  63. package/types/core/engine/command/javaScript.d.ts.map +1 -1
  64. package/types/core/engine/command/measure/screenshots.d.ts +15 -0
  65. package/types/core/engine/command/measure/screenshots.d.ts.map +1 -0
  66. package/types/core/engine/command/measure/video.d.ts +26 -0
  67. package/types/core/engine/command/measure/video.d.ts.map +1 -0
  68. package/types/core/engine/command/measure.d.ts +59 -33
  69. package/types/core/engine/command/measure.d.ts.map +1 -1
  70. package/types/core/engine/command/mouse/clickAndHold.d.ts.map +1 -1
  71. package/types/core/engine/command/mouse/contextClick.d.ts +28 -4
  72. package/types/core/engine/command/mouse/contextClick.d.ts.map +1 -1
  73. package/types/core/engine/command/mouse/doubleClick.d.ts +24 -4
  74. package/types/core/engine/command/mouse/doubleClick.d.ts.map +1 -1
  75. package/types/core/engine/command/mouse/mouseMove.d.ts +30 -5
  76. package/types/core/engine/command/mouse/mouseMove.d.ts.map +1 -1
  77. package/types/core/engine/command/mouse/singleClick.d.ts +50 -13
  78. package/types/core/engine/command/mouse/singleClick.d.ts.map +1 -1
  79. package/types/core/engine/command/navigation.d.ts +3 -3
  80. package/types/core/engine/command/select.d.ts +45 -8
  81. package/types/core/engine/command/select.d.ts.map +1 -1
  82. package/types/core/engine/command/selectorParser.d.ts +20 -0
  83. package/types/core/engine/command/selectorParser.d.ts.map +1 -0
  84. package/types/core/engine/command/set.d.ts +34 -7
  85. package/types/core/engine/command/set.d.ts.map +1 -1
  86. package/types/core/engine/command/wait.d.ts +29 -6
  87. package/types/core/engine/command/wait.d.ts.map +1 -1
  88. package/types/core/engine/commands.d.ts +296 -28
  89. package/types/core/engine/commands.d.ts.map +1 -1
  90. package/types/support/engineUtils.d.ts.map +1 -1
  91. package/types/support/getViewPort.d.ts.map +1 -1
  92. package/types/support/usbPower.d.ts.map +1 -1
  93. package/types/video/postprocessing/finetune/addTextToVideo.d.ts.map +1 -1
  94. package/types/video/postprocessing/finetune/getTimingMetrics.d.ts.map +1 -1
  95. package/types/video/postprocessing/finetune/index.d.ts.map +1 -1
  96. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts +28 -0
  97. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts.map +1 -0
  98. package/types/video/screenRecording/recorder.d.ts +2 -2
  99. package/types/video/screenRecording/recorder.d.ts.map +1 -1
  100. package/types/video/screenRecording/setOrangeBackground.d.ts.map +1 -1
  101. package/types/video/video.d.ts +1 -1
  102. package/types/video/video.d.ts.map +1 -1
  103. package/visualmetrics/visualmetrics-portable.py +136 -136
  104. package/lib/video/screenRecording/ios/convertToMp4.js +0 -22
  105. package/lib/video/screenRecording/ios/iosRecorder.js +0 -68
@@ -82,18 +82,10 @@ export class Iteration {
82
82
  const batteryTemperature = {};
83
83
 
84
84
  const engineDelegate = this.engineDelegate;
85
+ let afterBrowserStoppedRan = false;
85
86
 
86
87
  try {
87
- if (options.flushDNS) {
88
- await flushDNS(options);
89
- }
90
- if (options.connectivity && options.connectivity.variance) {
91
- await addConnectivity(options);
92
- }
93
-
94
- await engineDelegate.beforeBrowserStart();
95
- await browser.start();
96
- await engineDelegate.afterBrowserStart(browser);
88
+ await this._startBrowser(browser, engineDelegate, options);
97
89
 
98
90
  // The data we push to all selenium scripts
99
91
  const context = new Context(
@@ -106,35 +98,23 @@ export class Iteration {
106
98
  browser.getDriver()
107
99
  );
108
100
 
109
- const commands = new Commands(
101
+ const commands = new Commands({
110
102
  browser,
111
103
  engineDelegate,
112
104
  index,
113
105
  result,
114
- this.storageManager,
115
- this.pageCompleteCheck,
106
+ storageManager: this.storageManager,
107
+ pageCompleteCheck: this.pageCompleteCheck,
116
108
  context,
117
109
  videos,
118
110
  screenshotManager,
119
- this.scriptsByCategory,
120
- this.asyncScriptsByCategory,
121
- this.postURLScripts,
111
+ scriptsByCategory: this.scriptsByCategory,
112
+ asyncScriptsByCategory: this.asyncScriptsByCategory,
113
+ postURLScripts: this.postURLScripts,
122
114
  options
123
- );
115
+ });
124
116
 
125
- // Safari can't load data:text URLs at startup, so you can either choose
126
- // your own URL or use the blank one
127
- const startURL =
128
- options.safari && options.safari.startURL
129
- ? options.safari.startURL
130
- : 'data:text/html;charset=utf-8,<html><body></body></html>';
131
- if (options.spa) {
132
- await setResourceTimingBufferSize(startURL, browser.getDriver(), 1000);
133
- } else if (!options.processStartTime) {
134
- // Before we needed Chrome to open once to get correct values for Chrome HAR
135
- // but now Firefox first visual change is slower if we remove this ...
136
- await browser.getDriver().get(startURL);
137
- }
117
+ await this._loadStartURL(browser, options);
138
118
 
139
119
  // On slowish Android phones it takes some time for the
140
120
  // browser to get ready
@@ -150,32 +130,14 @@ export class Iteration {
150
130
  await this.video.record(0, index);
151
131
  }
152
132
 
153
- for (const preScript of this.preScripts) {
154
- await preScript(context, commands);
155
- }
156
-
157
- if (options.preURL) {
158
- await preURL(browser, engineDelegate, this.pageCompleteCheck, options);
159
- }
160
-
161
- try {
162
- await navigationScript(context, commands, this.postURLScripts);
163
- } catch (error) {
164
- commands.error(error.message);
165
- commands.markAsFailure(error.message);
166
- throw error;
167
- }
168
- if (commands.measure.areWeMeasuring === true) {
169
- // someone forgot to call stop();
170
- log.info(
171
- 'It looks like you missed to call measure.stop() after calling measure.start(..) (without using a URL). Checkout https://www.sitespeed.io/documentation/sitespeed.io/scripting/#measurestartalias'
172
- );
173
- await commands.measure.stop();
174
- }
175
-
176
- for (const postScript of this.postScripts) {
177
- await postScript(context, commands);
178
- }
133
+ await this._runScripts(
134
+ navigationScript,
135
+ context,
136
+ commands,
137
+ options,
138
+ browser,
139
+ engineDelegate
140
+ );
179
141
 
180
142
  if (recordVideo && options.videoParams.debug) {
181
143
  // Just use the first URL
@@ -184,95 +146,33 @@ export class Iteration {
184
146
  await this.video.stop(url);
185
147
  }
186
148
 
187
- await engineDelegate.beforeBrowserStop(browser, index, result);
188
-
189
- // if we are in debig mode we wait on a continue command
190
- if (options.debug) {
191
- await commands.debug.breakpoint('End-of-iteration');
192
- }
193
-
194
- if (options.browser === 'firefox' && options.debug) {
195
- log.info('Firefox is kept open in debug mode');
196
- } else {
197
- if (isAndroidConfigured(options) && options.ignoreShutdownFailures) {
198
- try {
199
- log.info('Ignoring shutdown failures on android...');
200
- await browser.stop();
201
- } catch {
202
- // Ignore shutdown failures on android
203
- log.info('Shutdown problem hit, ignoring...');
204
- }
205
- } else {
206
- await browser.stop();
207
- }
208
- }
209
- // Give the browsers some time to stop
210
- await delay(2000);
211
- await engineDelegate.afterBrowserStopped();
149
+ await this._stopBrowser(
150
+ browser,
151
+ engineDelegate,
152
+ options,
153
+ result,
154
+ index,
155
+ commands
156
+ );
157
+ afterBrowserStoppedRan = true;
212
158
 
213
159
  if (isAndroidConfigured(options)) {
214
160
  batteryTemperature.stop = await this.android.getTemperature();
215
161
  await this.android.removeDevtoolsFw();
216
162
  }
217
163
 
218
- if (options.visualMetrics && !options.videoParams.debug) {
219
- let index_ = 0;
220
- for (let myVideo of videos) {
221
- try {
222
- const videoMetrics = await myVideo.postProcessing(
223
- result[index_].browserScripts.timings.pageTimings,
224
- result[index_].browserScripts.pageinfo.visualElements
225
- );
226
-
227
- for (let progress of [
228
- 'VisualProgress',
229
- 'ContentfulSpeedIndexProgress',
230
- 'PerceptualSpeedIndexProgress'
231
- ]) {
232
- if (videoMetrics.visualMetrics[progress]) {
233
- videoMetrics.visualMetrics[progress] = jsonifyVisualProgress(
234
- videoMetrics.visualMetrics[progress]
235
- );
236
- }
237
- }
238
- if (videoMetrics.visualMetrics['KeyColorFrames']) {
239
- videoMetrics.visualMetrics['KeyColorFrames'] =
240
- jsonifyKeyColorFrames(
241
- videoMetrics.visualMetrics['KeyColorFrames']
242
- );
243
- }
244
- result[index_].videoRecordingStart =
245
- videoMetrics.videoRecordingStart;
246
- result[index_].visualMetrics = videoMetrics.visualMetrics;
247
- index_++;
248
- } catch (error) {
249
- // If one of the Visual Metrics runs fail, try the next one
250
- log.error('Visual Metrics failed to analyse the video', error);
251
- if (result[index_]) {
252
- result[index_].error.push(error.message);
253
- }
254
- }
255
- }
256
- }
257
-
258
- result.screenshots = screenshotManager.getSaved();
164
+ await this._processVisualMetrics(videos, result, options);
259
165
 
260
- if (engineDelegate.postWork) {
261
- await engineDelegate.postWork(index, result);
262
- }
263
-
264
- if (isAndroidConfigured(options)) {
265
- result.batteryTemperature = batteryTemperature;
266
- }
267
-
268
- if (commands.meta.description) {
269
- result.description = commands.meta.description;
270
- }
271
- if (commands.meta.title) {
272
- result.title = commands.meta.title;
273
- }
166
+ this._collectResults(
167
+ result,
168
+ screenshotManager,
169
+ engineDelegate,
170
+ index,
171
+ options,
172
+ commands,
173
+ batteryTemperature
174
+ );
274
175
 
275
- result.processesAtStart = this.processesAtStart;
276
176
  return result;
277
177
  } catch (error) {
278
178
  log.error(error);
@@ -306,6 +206,246 @@ export class Iteration {
306
206
  } catch {
307
207
  // Most cases the browser been stopped already
308
208
  }
209
+ // Ensure delegate cleanup runs even on failure paths so that
210
+ // long-lived helpers (e.g. ios-capture, safaridriver) are released.
211
+ // Skip when _stopBrowser already reached afterBrowserStopped on the
212
+ // happy path — running the cleanup twice surfaced as ENOENT noise on
213
+ // the Chrome user-data dir cleanup and could double-kill processes
214
+ // on Safari.
215
+ if (!afterBrowserStoppedRan) {
216
+ try {
217
+ await engineDelegate.afterBrowserStopped();
218
+ } catch {
219
+ // best-effort — helpers may already have been released
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ async _startBrowser(browser, engineDelegate, options) {
226
+ if (options.flushDNS) {
227
+ await flushDNS(options);
228
+ }
229
+ if (options.connectivity && options.connectivity.variance) {
230
+ await addConnectivity(options);
231
+ }
232
+
233
+ await engineDelegate.beforeBrowserStart();
234
+ await browser.start();
235
+ // The runner deep-merges options into a fresh object before passing them
236
+ // to the browser-specific builder, so anything the builder records there
237
+ // never reaches the engine. Copy it back to the engine's options object
238
+ // so result.info.browser can pick it up. Issue #1622.
239
+ if (browser.options && browser.options.recordedBrowserSettings) {
240
+ this.options.recordedBrowserSettings =
241
+ browser.options.recordedBrowserSettings;
242
+ }
243
+ await engineDelegate.afterBrowserStart(browser);
244
+ }
245
+
246
+ async _loadStartURL(browser, options) {
247
+ // Safari can't load data:text URLs at startup, so you can either choose
248
+ // your own URL or use the blank one
249
+ const startURL =
250
+ options.safari && options.safari.startURL
251
+ ? options.safari.startURL
252
+ : 'data:text/html;charset=utf-8,<html><body></body></html>';
253
+ if (options.spa) {
254
+ await setResourceTimingBufferSize(startURL, browser.getDriver(), 1000);
255
+ } else if (!options.processStartTime) {
256
+ // Before we needed Chrome to open once to get correct values for Chrome HAR
257
+ // but now Firefox first visual change is slower if we remove this ...
258
+ await browser.getDriver().get(startURL);
259
+ }
260
+ }
261
+
262
+ async _runScripts(
263
+ navigationScript,
264
+ context,
265
+ commands,
266
+ options,
267
+ browser,
268
+ engineDelegate
269
+ ) {
270
+ for (const preScript of this.preScripts) {
271
+ await preScript(context, commands);
272
+ }
273
+
274
+ if (options.preURL) {
275
+ await preURL(browser, engineDelegate, this.pageCompleteCheck, options);
276
+ }
277
+
278
+ try {
279
+ await navigationScript(context, commands, this.postURLScripts);
280
+ } catch (error) {
281
+ commands.error(error.message);
282
+ commands.markAsFailure(error.message);
283
+ throw error;
284
+ }
285
+ if (commands.measure.areWeMeasuring === true) {
286
+ // someone forgot to call stop();
287
+ log.info(
288
+ 'It looks like you missed to call measure.stop() after calling measure.start(..) (without using a URL). Checkout https://www.sitespeed.io/documentation/sitespeed.io/scripting/#measurestartalias'
289
+ );
290
+ await commands.measure.stop();
291
+ }
292
+
293
+ for (const postScript of this.postScripts) {
294
+ await postScript(context, commands);
295
+ }
296
+ }
297
+
298
+ async _stopBrowser(
299
+ browser,
300
+ engineDelegate,
301
+ options,
302
+ result,
303
+ index,
304
+ commands
305
+ ) {
306
+ await engineDelegate.beforeBrowserStop(browser, index, result);
307
+
308
+ // if we are in debig mode we wait on a continue command
309
+ if (options.debug) {
310
+ await commands.debug.breakpoint('End-of-iteration');
311
+ }
312
+
313
+ if (options.browser === 'firefox' && options.debug) {
314
+ log.info('Firefox is kept open in debug mode');
315
+ } else {
316
+ if (isAndroidConfigured(options) && options.ignoreShutdownFailures) {
317
+ try {
318
+ log.info('Ignoring shutdown failures on android...');
319
+ await browser.stop();
320
+ } catch {
321
+ // Ignore shutdown failures on android
322
+ log.info('Shutdown problem hit, ignoring...');
323
+ }
324
+ } else {
325
+ await browser.stop();
326
+ }
327
+ }
328
+ // Give the browsers some time to stop
329
+ await delay(2000);
330
+ await engineDelegate.afterBrowserStopped();
331
+ }
332
+
333
+ async _processVisualMetrics(videos, result, options) {
334
+ if (options.visualMetrics && !options.videoParams.debug) {
335
+ let index_ = 0;
336
+ for (let myVideo of videos) {
337
+ try {
338
+ const videoMetrics = await myVideo.postProcessing(
339
+ result[index_].browserScripts.timings.pageTimings,
340
+ result[index_].browserScripts.pageinfo.visualElements
341
+ );
342
+
343
+ for (let progress of [
344
+ 'VisualProgress',
345
+ 'ContentfulSpeedIndexProgress',
346
+ 'PerceptualSpeedIndexProgress'
347
+ ]) {
348
+ if (videoMetrics.visualMetrics[progress]) {
349
+ videoMetrics.visualMetrics[progress] = jsonifyVisualProgress(
350
+ videoMetrics.visualMetrics[progress]
351
+ );
352
+ }
353
+ }
354
+ if (videoMetrics.visualMetrics['KeyColorFrames']) {
355
+ videoMetrics.visualMetrics['KeyColorFrames'] =
356
+ jsonifyKeyColorFrames(
357
+ videoMetrics.visualMetrics['KeyColorFrames']
358
+ );
359
+ }
360
+ result[index_].videoRecordingStart = videoMetrics.videoRecordingStart;
361
+ result[index_].visualMetrics = videoMetrics.visualMetrics;
362
+
363
+ // iOS real device video has USB mirroring latency (~1s).
364
+ // Sync visual metrics to FCP so firstVisualChange matches
365
+ // the browser-reported first contentful paint.
366
+ if (options.safari?.ios) {
367
+ syncVisualMetricsToFCP(result[index_]);
368
+ }
369
+
370
+ index_++;
371
+ } catch (error) {
372
+ // If one of the Visual Metrics runs fail, try the next one
373
+ log.error('Visual Metrics failed to analyse the video', error);
374
+ if (result[index_]) {
375
+ result[index_].error.push(error.message);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ _collectResults(
383
+ result,
384
+ screenshotManager,
385
+ engineDelegate,
386
+ index,
387
+ options,
388
+ commands,
389
+ batteryTemperature
390
+ ) {
391
+ result.screenshots = screenshotManager.getSaved();
392
+
393
+ if (engineDelegate.postWork) {
394
+ engineDelegate.postWork(index, result);
395
+ }
396
+
397
+ if (isAndroidConfigured(options)) {
398
+ result.batteryTemperature = batteryTemperature;
399
+ }
400
+
401
+ if (commands.meta.description) {
402
+ result.description = commands.meta.description;
403
+ }
404
+ if (commands.meta.title) {
405
+ result.title = commands.meta.title;
406
+ }
407
+
408
+ result.processesAtStart = this.processesAtStart;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Compensate for USB screen mirroring latency on iOS real devices.
414
+ * The video stream is delayed ~1s compared to what happens on the device.
415
+ * We align visual metrics to the browser-reported FCP (First Contentful Paint)
416
+ * since both measure the same event — when content first appears on screen.
417
+ */
418
+ function syncVisualMetricsToFCP(resultEntry) {
419
+ const vm = resultEntry.visualMetrics;
420
+ if (!vm) return;
421
+
422
+ const fcp =
423
+ resultEntry.browserScripts?.timings?.paintTiming?.[
424
+ 'first-contentful-paint'
425
+ ];
426
+ if (!fcp || fcp <= 0) return;
427
+ if (!vm.FirstVisualChange || vm.FirstVisualChange <= fcp) return;
428
+
429
+ const offset = vm.FirstVisualChange - fcp;
430
+
431
+ for (const key of [
432
+ 'FirstVisualChange',
433
+ 'LastVisualChange',
434
+ 'SpeedIndex',
435
+ 'VisualComplete85',
436
+ 'VisualComplete95',
437
+ 'VisualComplete99'
438
+ ]) {
439
+ if (vm[key] > offset) {
440
+ vm[key] -= offset;
441
+ }
442
+ }
443
+
444
+ if (vm.VisualProgress) {
445
+ for (const entry of vm.VisualProgress) {
446
+ if (entry.timestamp > offset) {
447
+ entry.timestamp -= offset;
448
+ }
309
449
  }
310
450
  }
311
451
  }
@@ -14,6 +14,21 @@ export class NetworkManager {
14
14
  this.idleTime = getProperty(options, 'timeouts.networkIdle', 5000);
15
15
  }
16
16
 
17
+ async _cleanup(messageHandler) {
18
+ if (messageHandler && this.ws) {
19
+ this.ws.removeListener('message', messageHandler);
20
+ }
21
+ await this.bidi.unsubscribe(
22
+ 'network.beforeRequestSent',
23
+ this.browsingContextIds
24
+ );
25
+ await this.bidi.unsubscribe(
26
+ 'network.responseCompleted',
27
+ this.browsingContextIds
28
+ );
29
+ await this.bidi.unsubscribe('network.fetchError', this.browsingContextIds);
30
+ }
31
+
17
32
  async waitForNetworkIdle() {
18
33
  await this.bidi.subscribe(
19
34
  'network.beforeRequestSent',
@@ -26,10 +41,10 @@ export class NetworkManager {
26
41
  await this.bidi.subscribe('network.fetchError', this.browsingContextIds);
27
42
 
28
43
  let inflight = 0;
29
- let lastRequestTimestamp;
30
- let lastResponseTimestamp;
44
+ let lastRequestTimestamp = Date.now();
45
+ let lastResponseTimestamp = Date.now();
31
46
  this.ws = await this.bidi.socket;
32
- this.ws.on('message', function (event) {
47
+ const messageHandler = function (event) {
33
48
  const { method } = JSON.parse(Buffer.from(event.toString()));
34
49
  if (method) {
35
50
  switch (method) {
@@ -54,7 +69,8 @@ export class NetworkManager {
54
69
  // No default
55
70
  }
56
71
  }
57
- });
72
+ };
73
+ this.ws.on('message', messageHandler);
58
74
 
59
75
  const startTime = Date.now();
60
76
 
@@ -71,20 +87,7 @@ export class NetworkManager {
71
87
  inflight
72
88
  );
73
89
  }
74
- await this.bidi.unsubscribe(
75
- 'network.beforeRequestSent',
76
- this.browsingContextIds
77
- );
78
- await this.bidi.unsubscribe(
79
- 'network.responseCompleted',
80
- this.browsingContextIds
81
- );
82
-
83
- await this.bidi.unsubscribe(
84
- 'network.fetchError',
85
- this.browsingContextIds
86
- );
87
-
90
+ await this._cleanup(messageHandler);
88
91
  break;
89
92
  }
90
93
 
@@ -92,18 +95,7 @@ export class NetworkManager {
92
95
  log.info(
93
96
  'Timeout waiting for network idle. Inflight requests:' + inflight
94
97
  );
95
- await this.bidi.unsubscribe(
96
- 'network.beforeRequestSent',
97
- this.browsingContextIds
98
- );
99
- await this.bidi.unsubscribe(
100
- 'network.responseCompleted',
101
- this.browsingContextIds
102
- );
103
- await this.bidi.unsubscribe(
104
- 'network.fetchError',
105
- this.browsingContextIds
106
- );
98
+ await this._cleanup(messageHandler);
107
99
  break;
108
100
  }
109
101
 
@@ -266,4 +266,18 @@ export async function configureBuilder(builder, baseDir, options) {
266
266
  }
267
267
 
268
268
  builder.setFirefoxOptions(ffOptions);
269
+
270
+ // Snapshot every argument / preference / capability we just applied so it
271
+ // can be surfaced in the result JSON for debugging — issue #1622. The
272
+ // runner deep-merges options into a fresh container before this is called,
273
+ // so iteration.js threads it back to the engine after browser.start().
274
+ const ffInternals = ffOptions.firefoxOptions_();
275
+ options.recordedBrowserSettings = {
276
+ args: [...(ffInternals.args || [])],
277
+ preferences: { ...ffInternals.prefs },
278
+ binary: ffInternals.binary,
279
+ profile: ffInternals.profile,
280
+ androidPackage: ffInternals.androidPackage,
281
+ androidActivity: ffInternals.androidActivity
282
+ };
269
283
  }
@@ -0,0 +1,131 @@
1
+ import { harFromMessages } from 'chrome-har';
2
+ import { getLogger } from '@sitespeed.io/log';
3
+ import { cleanSensitiveHeaders } from '../support/har/index.js';
4
+
5
+ const log = getLogger('browsertime.safari.har');
6
+
7
+ export async function getHar(
8
+ adapter,
9
+ result,
10
+ index,
11
+ inspectorClient,
12
+ includeResponseBodies,
13
+ aliasAndUrl,
14
+ cleanHeaders
15
+ ) {
16
+ const messages = adapter.getMessages();
17
+
18
+ let har;
19
+ try {
20
+ har = harFromMessages(messages);
21
+ } catch (error) {
22
+ log.error('Failed to generate HAR from messages: %s', error.message);
23
+ log.verbose(error);
24
+ return getEmptyHar();
25
+ }
26
+
27
+ // Fix timing values and remove cached entries without timing data
28
+ har.log.entries = har.log.entries.filter(entry => {
29
+ // Remove entries without _requestTime — these are cached resources
30
+ // that WebKit reported but have no useful timing data
31
+ const noTiming =
32
+ entry._requestTime === undefined || entry._requestTime === 0;
33
+ if (noTiming) {
34
+ return false;
35
+ }
36
+
37
+ // Fix any remaining NaN/non-number timing values
38
+ if (entry.timings) {
39
+ for (const key of Object.keys(entry.timings)) {
40
+ if (key === 'comment') {
41
+ // comment must be a string per HAR spec
42
+ if (typeof entry.timings[key] !== 'string') {
43
+ delete entry.timings[key];
44
+ }
45
+ } else if (
46
+ typeof entry.timings[key] !== 'number' ||
47
+ !Number.isFinite(entry.timings[key])
48
+ ) {
49
+ entry.timings[key] = 0;
50
+ }
51
+ }
52
+ }
53
+ if (typeof entry.time !== 'number' || !Number.isFinite(entry.time)) {
54
+ entry.time = 0;
55
+ }
56
+ return true;
57
+ });
58
+
59
+ // Add browser info
60
+ har.log.creator = {
61
+ name: 'Browsertime',
62
+ version: '1.0'
63
+ };
64
+ har.log.browser = {
65
+ name: 'Safari',
66
+ version: ''
67
+ };
68
+
69
+ // Clean sensitive headers if requested
70
+ if (cleanHeaders === true) {
71
+ for (const entry of har.log.entries ?? []) {
72
+ for (const header of entry.request?.headers ?? []) {
73
+ header.value = cleanSensitiveHeaders(header.name, header.value);
74
+ }
75
+ for (const header of entry.response?.headers ?? []) {
76
+ header.value = cleanSensitiveHeaders(header.name, header.value);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Handle response bodies
82
+ if (
83
+ inspectorClient &&
84
+ (includeResponseBodies === 'html' || includeResponseBodies === 'all')
85
+ ) {
86
+ for (const entry of har.log.entries) {
87
+ if (
88
+ includeResponseBodies === 'html' &&
89
+ !entry.response.content.mimeType?.includes('text/html')
90
+ ) {
91
+ continue;
92
+ }
93
+ try {
94
+ const response = await inspectorClient.send('Network.getResponseBody', {
95
+ requestId: entry._requestId
96
+ });
97
+ if (response?.result?.body) {
98
+ entry.response.content.text = response.result.body;
99
+ if (response.result.base64Encoded) {
100
+ entry.response.content.encoding = 'base64';
101
+ }
102
+ }
103
+ } catch {
104
+ log.debug('Could not get response body for %s', entry.request.url);
105
+ }
106
+ }
107
+ }
108
+
109
+ // Set page title and URL
110
+ if (har.log.pages.length > 0) {
111
+ const url = result.url || '';
112
+ har.log.pages[0].title = `${url} run ${index}`;
113
+ const alias = aliasAndUrl[url] || url;
114
+ har.log.pages[0]._url = url;
115
+ har.log.pages[0]._alias = alias;
116
+ }
117
+
118
+ return har;
119
+ }
120
+
121
+ function getEmptyHar() {
122
+ return {
123
+ log: {
124
+ version: '1.2',
125
+ creator: { name: 'Browsertime', version: '1.0' },
126
+ browser: { name: 'Safari', version: '' },
127
+ pages: [],
128
+ entries: []
129
+ }
130
+ };
131
+ }
Binary file