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
@@ -0,0 +1,340 @@
1
+ /*
2
+ * iOS screen capture server for Browsertime.
3
+ *
4
+ * Enables CoreMediaIO screen capture devices, finds the connected iOS device,
5
+ * and accepts commands via stdin to start/stop recording.
6
+ *
7
+ * Commands (via stdin):
8
+ * START <filepath> - Start recording to the given file path
9
+ * STOP - Stop recording
10
+ * QUIT - Stop recording and exit
11
+ *
12
+ * Responses (via stdout):
13
+ * READY - Device found, ready to accept commands
14
+ * RECORDING - Recording started
15
+ * STOPPED - Recording stopped, file written
16
+ * ERROR <message> - An error occurred
17
+ *
18
+ * Compile:
19
+ * clang -framework CoreMediaIO -framework AVFoundation -framework CoreVideo \
20
+ * -framework CoreMedia -framework Foundation ios-capture.m -o ios-capture
21
+ */
22
+
23
+ #import <Foundation/Foundation.h>
24
+ #import <AVFoundation/AVFoundation.h>
25
+ #import <CoreMediaIO/CMIOHardwareSystem.h>
26
+ #import <CoreMediaIO/CMIOHardwareDevice.h>
27
+ #import <CoreVideo/CoreVideo.h>
28
+ #import <CoreMedia/CoreMedia.h>
29
+ #import <signal.h>
30
+ #import <unistd.h>
31
+
32
+ static volatile sig_atomic_t shouldQuit = 0;
33
+
34
+ /*
35
+ * Signal handler: set the quit flag and close stdin so the main loop's
36
+ * blocking fgets() returns immediately. We can't safely call AVFoundation
37
+ * teardown from a signal handler, so we just unblock the loop and let
38
+ * main() do the [session stopRunning] / pclose on its way out.
39
+ * close() is async-signal-safe per POSIX.
40
+ */
41
+ void handleSignal(int sig) {
42
+ shouldQuit = 1;
43
+ close(STDIN_FILENO);
44
+ }
45
+
46
+ /*
47
+ * Enumerate all CoreMediaIO devices directly (bypasses AVFoundation filtering)
48
+ * and return their unique IDs. On modern macOS, AVCaptureDeviceDiscoverySession
49
+ * sometimes hides iOS devices that CMIO has registered; iterating CMIO directly
50
+ * is the most reliable way to discover them.
51
+ */
52
+ static NSArray<NSString *> *enumerateCMIODeviceUIDs(void) {
53
+ NSMutableArray<NSString *> *uids = [NSMutableArray array];
54
+
55
+ CMIOObjectPropertyAddress addr = {
56
+ kCMIOHardwarePropertyDevices,
57
+ kCMIOObjectPropertyScopeGlobal,
58
+ kCMIOObjectPropertyElementMain
59
+ };
60
+
61
+ UInt32 dataSize = 0;
62
+ if (CMIOObjectGetPropertyDataSize(kCMIOObjectSystemObject, &addr, 0, NULL, &dataSize) != 0) {
63
+ return uids;
64
+ }
65
+ UInt32 count = dataSize / sizeof(CMIOObjectID);
66
+ if (count == 0) return uids;
67
+
68
+ CMIOObjectID *deviceIDs = (CMIOObjectID *)malloc(dataSize);
69
+ if (CMIOObjectGetPropertyData(kCMIOObjectSystemObject, &addr, 0, NULL, dataSize, &dataSize, deviceIDs) != 0) {
70
+ free(deviceIDs);
71
+ return uids;
72
+ }
73
+
74
+ for (UInt32 i = 0; i < count; i++) {
75
+ CFStringRef uid = NULL;
76
+ UInt32 propSize = sizeof(uid);
77
+ CMIOObjectPropertyAddress uidAddr = {
78
+ kCMIODevicePropertyDeviceUID,
79
+ kCMIOObjectPropertyScopeGlobal,
80
+ kCMIOObjectPropertyElementMain
81
+ };
82
+ if (CMIOObjectGetPropertyData(deviceIDs[i], &uidAddr, 0, NULL, propSize, &propSize, &uid) == 0 && uid) {
83
+ [uids addObject:(__bridge NSString *)uid];
84
+ CFRelease(uid);
85
+ }
86
+ }
87
+ free(deviceIDs);
88
+ return uids;
89
+ }
90
+
91
+ /*
92
+ * Try to find an iOS device by checking every plausible AVCaptureDeviceType
93
+ * AND every UID reported by CMIO directly. Returns the first device whose
94
+ * media type includes muxed (audio+video) — the signature of an iOS device.
95
+ */
96
+ static AVCaptureDevice *findIOSCaptureDevice(void) {
97
+ NSArray<AVCaptureDeviceType> *types = @[
98
+ AVCaptureDeviceTypeExternal,
99
+ AVCaptureDeviceTypeContinuityCamera,
100
+ AVCaptureDeviceTypeBuiltInWideAngleCamera,
101
+ AVCaptureDeviceTypeDeskViewCamera
102
+ ];
103
+ NSArray<AVMediaType> *medias = @[AVMediaTypeMuxed, AVMediaTypeVideo];
104
+
105
+ for (AVCaptureDeviceType t in types) {
106
+ for (AVMediaType m in medias) {
107
+ AVCaptureDeviceDiscoverySession *ds = [AVCaptureDeviceDiscoverySession
108
+ discoverySessionWithDeviceTypes:@[t]
109
+ mediaType:m
110
+ position:AVCaptureDevicePositionUnspecified];
111
+ for (AVCaptureDevice *d in ds.devices) {
112
+ if ([d hasMediaType:AVMediaTypeMuxed]) {
113
+ return d;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /* Fallback: bridge each CMIO UID to AVCaptureDevice and pick a muxed one */
120
+ for (NSString *uid in enumerateCMIODeviceUIDs()) {
121
+ AVCaptureDevice *d = [AVCaptureDevice deviceWithUniqueID:uid];
122
+ if (d && [d hasMediaType:AVMediaTypeMuxed]) {
123
+ fprintf(stderr, "Found iOS device via CMIO bridge: %s (uid=%s)\n",
124
+ [[d localizedName] UTF8String], [uid UTF8String]);
125
+ return d;
126
+ }
127
+ }
128
+
129
+ return nil;
130
+ }
131
+
132
+ @interface Recorder : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
133
+ @property (nonatomic, strong) AVCaptureSession *session;
134
+ @property (nonatomic, assign) int frameCount;
135
+ @property (nonatomic, assign) int width;
136
+ @property (nonatomic, assign) int height;
137
+ @property (nonatomic, assign) BOOL isRecording;
138
+ @property (nonatomic, assign) FILE *ffmpegPipe;
139
+ @property (nonatomic, assign) int framerate;
140
+ @end
141
+
142
+ @implementation Recorder
143
+
144
+ - (void)captureOutput:(AVCaptureOutput *)output
145
+ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
146
+ fromConnection:(AVCaptureConnection *)connection {
147
+
148
+ CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
149
+ if (!imageBuffer) return;
150
+
151
+ CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
152
+
153
+ size_t width = CVPixelBufferGetWidth(imageBuffer);
154
+ size_t height = CVPixelBufferGetHeight(imageBuffer);
155
+
156
+ /* Always update dimensions from incoming frames */
157
+ if (self.width == 0) {
158
+ self.width = (int)width;
159
+ self.height = (int)height;
160
+ fprintf(stderr, "Device resolution: %dx%d\n", self.width, self.height);
161
+ }
162
+
163
+ /* Only write frames when recording */
164
+ if (self.isRecording && self.ffmpegPipe) {
165
+ size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
166
+ void *baseAddress = CVPixelBufferGetBaseAddress(imageBuffer);
167
+ if (baseAddress) {
168
+ for (size_t row = 0; row < height; row++) {
169
+ fwrite((uint8_t *)baseAddress + row * bytesPerRow, 1, width * 4, self.ffmpegPipe);
170
+ }
171
+ self.frameCount++;
172
+ }
173
+ }
174
+
175
+ CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
176
+ }
177
+
178
+ - (BOOL)startRecordingToFile:(NSString *)path {
179
+ if (self.isRecording) {
180
+ [self stopRecording];
181
+ }
182
+
183
+ self.frameCount = 0;
184
+
185
+ /* Wait for dimensions if we haven't seen a frame yet */
186
+ if (self.width == 0) {
187
+ fprintf(stderr, "Waiting for first frame...\n");
188
+ for (int i = 0; i < 100 && self.width == 0; i++) {
189
+ [NSThread sleepForTimeInterval:0.1];
190
+ }
191
+ if (self.width == 0) {
192
+ fprintf(stderr, "Could not determine video dimensions\n");
193
+ return NO;
194
+ }
195
+ }
196
+
197
+ int evenWidth = (self.width / 2) * 2;
198
+ int evenHeight = (self.height / 2) * 2;
199
+
200
+ NSString *cmd = [NSString stringWithFormat:
201
+ @"ffmpeg -f rawvideo -pix_fmt bgra -s %dx%d -r %d -i - "
202
+ @"-vf 'scale=%d:%d' "
203
+ @"-c:v libx264 -preset ultrafast -pix_fmt yuv420p -y '%@' 2>/dev/null",
204
+ self.width, self.height, self.framerate,
205
+ evenWidth, evenHeight,
206
+ path];
207
+
208
+ self.ffmpegPipe = popen([cmd UTF8String], "w");
209
+ if (!self.ffmpegPipe) {
210
+ fprintf(stderr, "Failed to start ffmpeg\n");
211
+ return NO;
212
+ }
213
+
214
+ self.isRecording = YES;
215
+ self.frameCount = 0;
216
+ return YES;
217
+ }
218
+
219
+ - (void)stopRecording {
220
+ self.isRecording = NO;
221
+
222
+ if (self.ffmpegPipe) {
223
+ pclose(self.ffmpegPipe);
224
+ self.ffmpegPipe = NULL;
225
+ }
226
+
227
+ fprintf(stderr, "Recorded %d frames\n", self.frameCount);
228
+ }
229
+
230
+ @end
231
+
232
+ int main(int argc, const char *argv[]) {
233
+ @autoreleasepool {
234
+ signal(SIGINT, handleSignal);
235
+ signal(SIGTERM, handleSignal);
236
+
237
+ int framerate = 30;
238
+ for (int i = 1; i < argc; i++) {
239
+ if (strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
240
+ framerate = atoi(argv[++i]);
241
+ }
242
+ }
243
+
244
+ /* Step 1: Enable screen capture devices */
245
+ CMIOObjectPropertyAddress prop = {
246
+ kCMIOHardwarePropertyAllowScreenCaptureDevices,
247
+ kCMIOObjectPropertyScopeGlobal,
248
+ kCMIOObjectPropertyElementMain
249
+ };
250
+ UInt32 allow = 1;
251
+ CMIOObjectSetPropertyData(kCMIOObjectSystemObject, &prop, 0, NULL, sizeof(allow), &allow);
252
+
253
+ /* Step 2: Warm up AVCaptureDevice */
254
+ #pragma clang diagnostic push
255
+ #pragma clang diagnostic ignored "-Wdeprecated-declarations"
256
+ [AVCaptureDevice devices];
257
+ #pragma clang diagnostic pop
258
+ [NSThread sleepForTimeInterval:1.0];
259
+
260
+ /* Log what CMIO sees directly — useful when discovery fails */
261
+ NSArray<NSString *> *cmioUIDs = enumerateCMIODeviceUIDs();
262
+ fprintf(stderr, "CMIO reports %lu device(s)\n", (unsigned long)cmioUIDs.count);
263
+ for (NSString *uid in cmioUIDs) {
264
+ fprintf(stderr, " CMIO UID: %s\n", [uid UTF8String]);
265
+ }
266
+
267
+ /* Step 3: Find iOS device — multi-type filter + CMIO bridge fallback */
268
+ AVCaptureDevice *device = nil;
269
+ for (int attempt = 0; attempt < 20 && !device; attempt++) {
270
+ device = findIOSCaptureDevice();
271
+ if (!device) [NSThread sleepForTimeInterval:0.5];
272
+ }
273
+
274
+ if (!device) {
275
+ fprintf(stdout, "ERROR No iOS device found\n");
276
+ fflush(stdout);
277
+ return 1;
278
+ }
279
+
280
+ fprintf(stderr, "Found device: %s\n", [[device localizedName] UTF8String]);
281
+
282
+ /* Step 4: Set up capture session */
283
+ AVCaptureSession *session = [[AVCaptureSession alloc] init];
284
+ NSError *error = nil;
285
+ AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error];
286
+ if (!input) {
287
+ fprintf(stdout, "ERROR Failed to create capture input\n");
288
+ fflush(stdout);
289
+ return 1;
290
+ }
291
+ [session addInput:input];
292
+
293
+ AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init];
294
+ videoOutput.videoSettings = @{
295
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)
296
+ };
297
+
298
+ Recorder *recorder = [[Recorder alloc] init];
299
+ recorder.framerate = framerate;
300
+
301
+ dispatch_queue_t queue = dispatch_queue_create("capture", DISPATCH_QUEUE_SERIAL);
302
+ [videoOutput setSampleBufferDelegate:recorder queue:queue];
303
+ [session addOutput:videoOutput];
304
+
305
+ [session startRunning];
306
+
307
+ /* Signal ready */
308
+ fprintf(stdout, "READY\n");
309
+ fflush(stdout);
310
+
311
+ /* Step 5: Read commands from stdin */
312
+ char line[4096];
313
+ while (!shouldQuit && fgets(line, sizeof(line), stdin)) {
314
+ /* Remove newline */
315
+ size_t len = strlen(line);
316
+ if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
317
+
318
+ if (strncmp(line, "START ", 6) == 0) {
319
+ NSString *path = [NSString stringWithUTF8String:line + 6];
320
+ if ([recorder startRecordingToFile:path]) {
321
+ fprintf(stdout, "RECORDING\n");
322
+ } else {
323
+ fprintf(stdout, "ERROR Failed to start recording\n");
324
+ }
325
+ fflush(stdout);
326
+ } else if (strcmp(line, "STOP") == 0) {
327
+ [recorder stopRecording];
328
+ fprintf(stdout, "STOPPED\n");
329
+ fflush(stdout);
330
+ } else if (strcmp(line, "QUIT") == 0) {
331
+ [recorder stopRecording];
332
+ shouldQuit = YES;
333
+ }
334
+ }
335
+
336
+ [recorder stopRecording];
337
+ [session stopRunning];
338
+ return 0;
339
+ }
340
+ }
@@ -0,0 +1,322 @@
1
+ /* global WebSocket */
2
+ import { execa } from 'execa';
3
+ import { getLogger } from '@sitespeed.io/log';
4
+
5
+ const log = getLogger('browsertime.safari.inspector');
6
+
7
+ export class SafariInspectorClient {
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.safariOptions = options.safari || {};
11
+ this.port = options.iwdpPort || 9222;
12
+ this.connections = [];
13
+ this.targetId = undefined;
14
+ this.targetWs = undefined; // The WebSocket that owns the active target
15
+ this.pinnedWs = undefined; // When set, only this ws may set targetId
16
+ this.messageId = 0;
17
+ this.pendingMessages = new Map();
18
+ this.eventListeners = new Map();
19
+ this.iwdpProcess = undefined;
20
+ }
21
+
22
+ async setup(targetMatcher) {
23
+ await this._verifyIwdpInstalled();
24
+ await this._startIwdp();
25
+ await this._waitForIwdp();
26
+ await this._connectWebSockets(targetMatcher);
27
+ await this._waitForTarget();
28
+ }
29
+
30
+ async _verifyIwdpInstalled() {
31
+ try {
32
+ await execa('which', ['ios_webkit_debug_proxy']);
33
+ } catch {
34
+ throw new Error(
35
+ 'ios_webkit_debug_proxy is not installed. Install it with: brew install ios-webkit-debug-proxy'
36
+ );
37
+ }
38
+ }
39
+
40
+ async _startIwdp() {
41
+ const args = ['-F'];
42
+ if (this.safariOptions.deviceUDID) {
43
+ args.push('-u', this.safariOptions.deviceUDID);
44
+ }
45
+ log.debug('Starting ios_webkit_debug_proxy on port %d', this.port);
46
+ this.iwdpProcess = execa('ios_webkit_debug_proxy', args, {
47
+ stdio: 'ignore'
48
+ });
49
+ this.iwdpProcess.catch(error => {
50
+ if (!error.isCanceled) {
51
+ log.error('ios_webkit_debug_proxy failed: %s', error.message);
52
+ }
53
+ });
54
+ }
55
+
56
+ async _waitForIwdp() {
57
+ const maxRetries = 20;
58
+ const retryDelay = 500;
59
+ for (let i = 0; i < maxRetries; i++) {
60
+ try {
61
+ const response = await fetch(`http://localhost:${this.port}/json`);
62
+ if (response.ok) {
63
+ log.debug('ios_webkit_debug_proxy is ready');
64
+ return;
65
+ }
66
+ } catch {
67
+ // Not ready yet
68
+ }
69
+ await new Promise(r => setTimeout(r, retryDelay));
70
+ }
71
+ throw new Error(
72
+ `ios_webkit_debug_proxy did not start within ${(maxRetries * retryDelay) / 1000} seconds`
73
+ );
74
+ }
75
+
76
+ async _connectWebSockets(targetMatcher) {
77
+ const response = await fetch(`http://localhost:${this.port}/json`);
78
+ const pages = await response.json();
79
+
80
+ for (const page of pages) {
81
+ if (!page.webSocketDebuggerUrl) continue;
82
+ try {
83
+ const ws = await new Promise((resolve, reject) => {
84
+ const socket = new WebSocket(page.webSocketDebuggerUrl);
85
+ socket.addEventListener('open', () => resolve(socket));
86
+ socket.addEventListener('error', () =>
87
+ reject(new Error('WebSocket error'))
88
+ );
89
+ setTimeout(() => reject(new Error('timeout')), 5000);
90
+ });
91
+
92
+ // Pin BEFORE the message listener is attached so the very first
93
+ // Target.targetCreated from any other tab cannot set targetId.
94
+ if (targetMatcher && targetMatcher(page)) {
95
+ this.pinnedWs = ws;
96
+ }
97
+
98
+ ws.addEventListener('message', event => {
99
+ this._handleMessage(ws, JSON.parse(event.data));
100
+ });
101
+
102
+ this.connections.push({ ws, page });
103
+ log.debug('Connected to page: %s', page.title || page.url || 'unknown');
104
+ } catch {
105
+ // Skip pages we can't connect to
106
+ }
107
+ }
108
+
109
+ if (this.connections.length === 0) {
110
+ throw new Error(
111
+ 'No inspectable Safari pages found via ios_webkit_debug_proxy'
112
+ );
113
+ }
114
+
115
+ if (targetMatcher && !this.pinnedWs) {
116
+ log.warn(
117
+ 'Could not identify the WebDriver Safari tab via iWDP — HAR may be empty if other tabs are open on the device'
118
+ );
119
+ }
120
+ log.debug('Connected to %d inspectable pages', this.connections.length);
121
+ }
122
+
123
+ async _waitForTarget() {
124
+ // Wait for targetCreated events
125
+ await new Promise(r => setTimeout(r, 1500));
126
+
127
+ if (this.targetId) {
128
+ log.debug('Network and Page enabled on target %s', this.targetId);
129
+ } else {
130
+ log.debug(
131
+ 'No page target found yet — will enable Network when a target appears'
132
+ );
133
+ }
134
+ }
135
+
136
+ _handleMessage(ws, msg) {
137
+ // Handle Target.targetCreated — auto-enable Network on new page targets
138
+ if (msg.method === 'Target.targetCreated') {
139
+ const target = msg.params?.targetInfo;
140
+ if (target?.type === 'page') {
141
+ // If we've pinned the WebDriver-controlled tab, ignore targetCreated
142
+ // from unrelated tabs so they can't clobber the active target.
143
+ if (this.pinnedWs && ws !== this.pinnedWs) return;
144
+ this.targetId = target.targetId;
145
+ this.targetWs = ws; // Remember which WebSocket owns this target
146
+ log.debug('Found page target: %s', this.targetId);
147
+ // Enable Network and Page on this target immediately
148
+ this._sendOnWs(ws, 'Target.sendMessageToTarget', {
149
+ targetId: target.targetId,
150
+ message: JSON.stringify({
151
+ id: ++this.messageId,
152
+ method: 'Network.enable',
153
+ params: {}
154
+ })
155
+ });
156
+ this._sendOnWs(ws, 'Target.sendMessageToTarget', {
157
+ targetId: target.targetId,
158
+ message: JSON.stringify({
159
+ id: ++this.messageId,
160
+ method: 'Page.enable',
161
+ params: {}
162
+ })
163
+ });
164
+ }
165
+ }
166
+
167
+ // Unwrap Target.dispatchMessageFromTarget — only from the active target
168
+ if (msg.method === 'Target.dispatchMessageFromTarget') {
169
+ try {
170
+ const innerMsg = JSON.parse(msg.params.message);
171
+ // Only dispatch events from the active target to avoid cross-tab noise
172
+ const fromTargetId = msg.params.targetId;
173
+ if (innerMsg.method && fromTargetId === this.targetId) {
174
+ const listeners = this.eventListeners.get(innerMsg.method) || [];
175
+ for (const cb of listeners) {
176
+ cb(innerMsg.params);
177
+ }
178
+ }
179
+ // Resolve pending commands
180
+ if (
181
+ innerMsg.id !== undefined &&
182
+ this.pendingMessages.has(innerMsg.id)
183
+ ) {
184
+ const { resolve } = this.pendingMessages.get(innerMsg.id);
185
+ this.pendingMessages.delete(innerMsg.id);
186
+ resolve(innerMsg);
187
+ }
188
+ } catch {
189
+ // Not valid JSON
190
+ }
191
+ }
192
+
193
+ // Resolve top-level responses
194
+ if (msg.id !== undefined && this.pendingMessages.has(msg.id)) {
195
+ const { resolve } = this.pendingMessages.get(msg.id);
196
+ this.pendingMessages.delete(msg.id);
197
+ resolve(msg);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Send a command to the active page target via Target.sendMessageToTarget.
203
+ */
204
+ async send(method, params = {}) {
205
+ if (!this.targetId || !this.targetWs) {
206
+ throw new Error('No target available for sending commands');
207
+ }
208
+ const innerId = ++this.messageId;
209
+ const innerMsg = JSON.stringify({ id: innerId, method, params });
210
+
211
+ return new Promise((resolve, reject) => {
212
+ this.pendingMessages.set(innerId, { resolve, reject });
213
+ try {
214
+ const outerId = ++this.messageId;
215
+ this.targetWs.send(
216
+ JSON.stringify({
217
+ id: outerId,
218
+ method: 'Target.sendMessageToTarget',
219
+ params: { targetId: this.targetId, message: innerMsg }
220
+ })
221
+ );
222
+ } catch (error) {
223
+ this.pendingMessages.delete(innerId);
224
+ reject(error);
225
+ }
226
+ setTimeout(() => {
227
+ if (this.pendingMessages.has(innerId)) {
228
+ this.pendingMessages.delete(innerId);
229
+ resolve({ error: 'timeout' });
230
+ }
231
+ }, 10_000);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Fire-and-forget send on a specific WebSocket.
237
+ */
238
+ _sendOnWs(ws, method, params) {
239
+ try {
240
+ ws.send(JSON.stringify({ id: ++this.messageId, method, params }));
241
+ } catch (error) {
242
+ log.debug('Send error: %s', error.message);
243
+ }
244
+ }
245
+
246
+ on(eventName, callback) {
247
+ if (!this.eventListeners.has(eventName)) {
248
+ this.eventListeners.set(eventName, []);
249
+ }
250
+ this.eventListeners.get(eventName).push(callback);
251
+ }
252
+
253
+ removeAllListeners() {
254
+ this.eventListeners.clear();
255
+ }
256
+
257
+ /**
258
+ * Reconnect to iwdp to discover new pages that appeared after initial setup.
259
+ * This is needed because safaridriver may create new tabs that weren't
260
+ * in the original page list.
261
+ */
262
+ async reconnect() {
263
+ const response = await fetch(`http://localhost:${this.port}/json`);
264
+ const pages = await response.json();
265
+
266
+ // Find pages we're not already connected to
267
+ const connectedUrls = new Set(
268
+ this.connections.map(c => c.page.webSocketDebuggerUrl)
269
+ );
270
+
271
+ for (const page of pages) {
272
+ if (!page.webSocketDebuggerUrl) continue;
273
+ if (connectedUrls.has(page.webSocketDebuggerUrl)) continue;
274
+
275
+ try {
276
+ const ws = await new Promise((resolve, reject) => {
277
+ const socket = new WebSocket(page.webSocketDebuggerUrl);
278
+ socket.addEventListener('open', () => resolve(socket));
279
+ socket.addEventListener('error', () =>
280
+ reject(new Error('WebSocket error'))
281
+ );
282
+ setTimeout(() => reject(new Error('timeout')), 3000);
283
+ });
284
+
285
+ ws.addEventListener('message', event => {
286
+ this._handleMessage(ws, JSON.parse(event.data));
287
+ });
288
+
289
+ this.connections.push({ ws, page });
290
+ log.debug(
291
+ 'Reconnected to new page: %s (%s)',
292
+ page.title || 'untitled',
293
+ page.url || 'no url'
294
+ );
295
+ } catch {
296
+ // Skip
297
+ }
298
+ }
299
+ }
300
+
301
+ async close() {
302
+ for (const conn of this.connections) {
303
+ try {
304
+ conn.ws.close();
305
+ } catch {
306
+ // Ignore close errors
307
+ }
308
+ }
309
+ this.connections = [];
310
+ this.targetWs = undefined;
311
+ this.pinnedWs = undefined;
312
+ if (this.iwdpProcess) {
313
+ try {
314
+ this.iwdpProcess.kill();
315
+ } catch {
316
+ // Ignore kill errors
317
+ }
318
+ this.iwdpProcess = undefined;
319
+ }
320
+ log.debug('Safari inspector client closed');
321
+ }
322
+ }