@testdriverai/agent 7.9.81-test → 7.9.91-canary

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.
@@ -0,0 +1,749 @@
1
+ /**
2
+ * Provision API for the TestDriver SDK.
3
+ *
4
+ * Exposes methods for launching applications (chrome, chromeExtension, vscode,
5
+ * installer, electron) and initializing dashcam recording inside the sandbox.
6
+ *
7
+ * All methods are wrapped with a Proxy that skips provisioning when the SDK
8
+ * is in reconnect mode.
9
+ */
10
+
11
+ /**
12
+ * Create the provision API bound to a TestDriver SDK instance.
13
+ *
14
+ * @param {object} self - The TestDriver instance. Provision methods read from
15
+ * `self.os`, `self.dashcam`, `self.dashcamEnabled`, `self.reconnect`, and
16
+ * call through `self.exec(...)`, `self.focusApplication(...)`,
17
+ * `self._getDashcamChromeExtensionPath()`, `self._waitForChromeDebuggerReady()`,
18
+ * and `self._getUrlDomainPattern(url)`.
19
+ * @returns {Proxy} The provision API object.
20
+ */
21
+ function createProvisionAPI(self) {
22
+ const provisionMethods = {
23
+ /**
24
+ * Launch Chrome browser
25
+ * @param {Object} options - Chrome launch options
26
+ * @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
27
+ * @param {boolean} [options.maximized=true] - Start maximized
28
+ * @param {boolean} [options.guest=false] - Use guest mode
29
+ * @returns {Promise<void>}
30
+ */
31
+ chrome: async (options = {}) => {
32
+ const {
33
+ url = "http://testdriver-sandbox.vercel.app/",
34
+ maximized = true,
35
+ guest = false,
36
+ } = options;
37
+
38
+ // Store the URL for domain-specific web log tracking
39
+ self._provisionedChromeUrl = url;
40
+
41
+ // Set up Chrome profile with preferences
42
+ const shell = self.os === "windows" ? "pwsh" : "sh";
43
+ const userDataDir =
44
+ self.os === "windows"
45
+ ? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
46
+ : "/tmp/testdriver-chrome-profile";
47
+
48
+ // Create user data directory and Default profile directory
49
+ const defaultProfileDir =
50
+ self.os === "windows"
51
+ ? `${userDataDir}\\Default`
52
+ : `${userDataDir}/Default`;
53
+
54
+ const createDirCmd =
55
+ self.os === "windows"
56
+ ? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
57
+ : `mkdir -p "${defaultProfileDir}"`;
58
+
59
+ await self.exec(shell, createDirCmd, 60000, true);
60
+
61
+ // Write Chrome preferences
62
+ const chromePrefs = {
63
+ credentials_enable_service: false,
64
+ profile: {
65
+ password_manager_enabled: false,
66
+ default_content_setting_values: {},
67
+ },
68
+ signin: {
69
+ allowed: false,
70
+ },
71
+ sync: {
72
+ requested: false,
73
+ first_setup_complete: true,
74
+ sync_all_os_types: false,
75
+ },
76
+ autofill: {
77
+ enabled: false,
78
+ },
79
+ local_state: {
80
+ browser: {
81
+ has_seen_welcome_page: true,
82
+ },
83
+ },
84
+ };
85
+
86
+ const prefsPath =
87
+ self.os === "windows"
88
+ ? `${defaultProfileDir}\\Preferences`
89
+ : `${defaultProfileDir}/Preferences`;
90
+
91
+ const prefsJson = JSON.stringify(chromePrefs, null, 2);
92
+ const writePrefCmd =
93
+ self.os === "windows"
94
+ ? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
95
+ `[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
96
+ : `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
97
+
98
+ await self.exec(shell, writePrefCmd, 60000, true);
99
+
100
+ // Build Chrome launch command
101
+ const chromeArgs = [];
102
+ if (maximized) chromeArgs.push("--start-maximized");
103
+ if (guest) chromeArgs.push("--guest");
104
+ chromeArgs.push(
105
+ "--disable-fre",
106
+ "--no-default-browser-check",
107
+ "--no-first-run",
108
+ "--no-experiments",
109
+ "--disable-infobars",
110
+ "--disable-features=StartupBrowserCreator",
111
+ "--disable-features=ChromeWhatsNewUI",
112
+ `--user-data-dir=${userDataDir}`,
113
+ );
114
+
115
+ // Add remote debugging port for captcha solving support
116
+ chromeArgs.push("--remote-debugging-port=9222");
117
+
118
+ // Add dashcam-chrome extension
119
+ const dashcamChromePath = await self._getDashcamChromeExtensionPath();
120
+ if (dashcamChromePath) {
121
+ chromeArgs.push(`--load-extension=${dashcamChromePath}`);
122
+ }
123
+
124
+ // Launch Chrome
125
+
126
+ if (self.os === "windows") {
127
+ const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
128
+ await self.exec(
129
+ shell,
130
+ `Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}, "${url}"`,
131
+ 30000,
132
+ );
133
+ } else {
134
+ const argsString = chromeArgs.join(" ");
135
+ await self.exec(
136
+ shell,
137
+ `chrome-for-testing ${argsString} "${url}" >/dev/null 2>&1 &`,
138
+ 30000,
139
+ );
140
+ }
141
+
142
+ // Wait for Chrome debugger port and page to be ready
143
+ await self._waitForChromeDebuggerReady();
144
+ await self.focusApplication("Google Chrome");
145
+
146
+ // Add web log tracking with domain wildcard pattern, then start dashcam
147
+ if (self.dashcamEnabled) {
148
+ const domainPattern = self._getUrlDomainPattern(url);
149
+ await self.dashcam.addWebLog(domainPattern, "Web Logs");
150
+
151
+ // Start dashcam recording after logs are configured
152
+ if (!(await self.dashcam.isRecording())) {
153
+ await self.dashcam.start();
154
+ }
155
+ }
156
+ },
157
+
158
+ /**
159
+ * Launch Chrome browser with a custom extension loaded
160
+ * @param {Object} options - Chrome extension launch options
161
+ * @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
162
+ * @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
163
+ * @param {boolean} [options.maximized=true] - Start maximized
164
+ * @returns {Promise<void>}
165
+ * @example
166
+ * // Load extension from local path
167
+ * await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
168
+ * await testdriver.provision.chromeExtension({
169
+ * extensionPath: '/tmp/extension'
170
+ * });
171
+ *
172
+ * @example
173
+ * // Load extension by Chrome Web Store ID
174
+ * await testdriver.provision.chromeExtension({
175
+ * extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm' // uBlock Origin
176
+ * });
177
+ */
178
+ chromeExtension: async (options = {}) => {
179
+ const {
180
+ extensionPath: providedExtensionPath,
181
+ extensionId,
182
+ maximized = true,
183
+ } = options;
184
+
185
+ if (!providedExtensionPath && !extensionId) {
186
+ throw new Error(
187
+ "[provision.chromeExtension] Either extensionPath or extensionId is required",
188
+ );
189
+ }
190
+
191
+ let extensionPath = providedExtensionPath;
192
+ const shell = self.os === "windows" ? "pwsh" : "sh";
193
+
194
+ // If extensionId is provided, download and extract the extension from Chrome Web Store
195
+ if (extensionId && !extensionPath) {
196
+ console.log(
197
+ `[provision.chromeExtension] Downloading extension ${extensionId} from Chrome Web Store...`,
198
+ );
199
+
200
+ const extensionDir =
201
+ self.os === "windows"
202
+ ? `C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Extensions\\${extensionId}`
203
+ : `/tmp/testdriver-extensions/${extensionId}`;
204
+
205
+ // Create extension directory
206
+ const mkdirCmd =
207
+ self.os === "windows"
208
+ ? `New-Item -ItemType Directory -Path "${extensionDir}" -Force | Out-Null`
209
+ : `mkdir -p "${extensionDir}"`;
210
+ await self.exec(shell, mkdirCmd, 60000, true);
211
+
212
+ // Download CRX from Chrome Web Store
213
+ // The CRX download URL format for Chrome Web Store
214
+ const crxUrl = `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=131.0.0.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26installsource%3Dondemand%26uc`;
215
+ const crxPath =
216
+ self.os === "windows"
217
+ ? `${extensionDir}\\extension.crx`
218
+ : `${extensionDir}/extension.crx`;
219
+
220
+ if (self.os === "windows") {
221
+ await self.exec(
222
+ "pwsh",
223
+ `Invoke-WebRequest -Uri "${crxUrl}" -OutFile "${crxPath}"`,
224
+ 60000,
225
+ true,
226
+ );
227
+ } else {
228
+ await self.exec(
229
+ "sh",
230
+ `curl -L -o "${crxPath}" "${crxUrl}"`,
231
+ 60000,
232
+ true,
233
+ );
234
+ }
235
+
236
+ // Extract the CRX file (CRX is a ZIP with a header)
237
+ // Skip the CRX header and extract as ZIP
238
+ if (self.os === "windows") {
239
+ // PowerShell: Read CRX, skip header, extract ZIP
240
+ await self.exec(
241
+ "pwsh",
242
+ `
243
+ $crxBytes = [System.IO.File]::ReadAllBytes("${crxPath}")
244
+ # CRX3 header: 4 bytes magic + 4 bytes version + 4 bytes header length + header
245
+ $magic = [System.Text.Encoding]::ASCII.GetString($crxBytes[0..3])
246
+ if ($magic -eq "Cr24") {
247
+ $headerLen = [BitConverter]::ToUInt32($crxBytes, 8)
248
+ $zipStart = 12 + $headerLen
249
+ } else {
250
+ # CRX2 format
251
+ $zipStart = 16 + [BitConverter]::ToUInt32($crxBytes, 8) + [BitConverter]::ToUInt32($crxBytes, 12)
252
+ }
253
+ $zipBytes = $crxBytes[$zipStart..($crxBytes.Length - 1)]
254
+ $zipPath = "${extensionDir}\\extension.zip"
255
+ [System.IO.File]::WriteAllBytes($zipPath, $zipBytes)
256
+ Expand-Archive -Path $zipPath -DestinationPath "${extensionDir}\\unpacked" -Force
257
+ `,
258
+ 30000,
259
+ true,
260
+ );
261
+ extensionPath = `${extensionDir}\\unpacked`;
262
+ } else {
263
+ // Linux: Use unzip with offset or python to extract
264
+ await self.exec(
265
+ "sh",
266
+ `
267
+ cd "${extensionDir}"
268
+ # Extract CRX (skip header and unzip)
269
+ # CRX3 format: magic(4) + version(4) + header_length(4) + header + zip
270
+ python3 -c "
271
+ import struct
272
+ import zipfile
273
+ import io
274
+ import os
275
+
276
+ with open('extension.crx', 'rb') as f:
277
+ data = f.read()
278
+
279
+ # Check magic number
280
+ magic = data[:4]
281
+ if magic == b'Cr24':
282
+ # CRX3 format
283
+ header_len = struct.unpack('<I', data[8:12])[0]
284
+ zip_start = 12 + header_len
285
+ else:
286
+ # CRX2 format
287
+ pub_key_len = struct.unpack('<I', data[8:12])[0]
288
+ sig_len = struct.unpack('<I', data[12:16])[0]
289
+ zip_start = 16 + pub_key_len + sig_len
290
+
291
+ zip_data = data[zip_start:]
292
+ os.makedirs('unpacked', exist_ok=True)
293
+ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
294
+ zf.extractall('unpacked')
295
+ "
296
+ `,
297
+ 30000,
298
+ true,
299
+ );
300
+ extensionPath = `${extensionDir}/unpacked`;
301
+ }
302
+
303
+ console.log(
304
+ `[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`,
305
+ );
306
+ }
307
+
308
+ // Set up Chrome profile with preferences
309
+ const userDataDir =
310
+ self.os === "windows"
311
+ ? "C:\\Users\\testdriver\\AppData\\Local\\TestDriver\\Chrome"
312
+ : "/tmp/testdriver-chrome-profile";
313
+
314
+ // Create user data directory and Default profile directory
315
+ const defaultProfileDir =
316
+ self.os === "windows"
317
+ ? `${userDataDir}\\Default`
318
+ : `${userDataDir}/Default`;
319
+
320
+ const createDirCmd =
321
+ self.os === "windows"
322
+ ? `New-Item -ItemType Directory -Path "${defaultProfileDir}" -Force | Out-Null`
323
+ : `mkdir -p "${defaultProfileDir}"`;
324
+
325
+ await self.exec(shell, createDirCmd, 60000, true);
326
+
327
+ // Write Chrome preferences
328
+ const chromePrefs = {
329
+ credentials_enable_service: false,
330
+ profile: {
331
+ password_manager_enabled: false,
332
+ default_content_setting_values: {},
333
+ },
334
+ signin: {
335
+ allowed: false,
336
+ },
337
+ sync: {
338
+ requested: false,
339
+ first_setup_complete: true,
340
+ sync_all_os_types: false,
341
+ },
342
+ autofill: {
343
+ enabled: false,
344
+ },
345
+ local_state: {
346
+ browser: {
347
+ has_seen_welcome_page: true,
348
+ },
349
+ },
350
+ };
351
+
352
+ const prefsPath =
353
+ self.os === "windows"
354
+ ? `${defaultProfileDir}\\Preferences`
355
+ : `${defaultProfileDir}/Preferences`;
356
+
357
+ const prefsJson = JSON.stringify(chromePrefs, null, 2);
358
+ const writePrefCmd =
359
+ self.os === "windows"
360
+ ? // Use compact JSON and [System.IO.File]::WriteAllText to avoid Set-Content hanging issues
361
+ `[System.IO.File]::WriteAllText("${prefsPath}", '${JSON.stringify(chromePrefs).replace(/'/g, "''")}')`
362
+ : `cat > "${prefsPath}" << 'EOF'\n${prefsJson}\nEOF`;
363
+
364
+ await self.exec(shell, writePrefCmd, 60000, true);
365
+
366
+ // Build Chrome launch command
367
+ const chromeArgs = [];
368
+ if (maximized) chromeArgs.push("--start-maximized");
369
+ chromeArgs.push(
370
+ "--disable-fre",
371
+ "--no-default-browser-check",
372
+ "--no-first-run",
373
+ "--no-experiments",
374
+ "--disable-infobars",
375
+ "--disable-features=ChromeLabs",
376
+ `--user-data-dir=${userDataDir}`,
377
+ );
378
+
379
+ // Add remote debugging port for captcha solving support
380
+ chromeArgs.push("--remote-debugging-port=9222");
381
+
382
+ // Add user extension and dashcam-chrome extension
383
+ const dashcamChromePath = await self._getDashcamChromeExtensionPath();
384
+ if (dashcamChromePath) {
385
+ // Load both user extension and dashcam-chrome for web log capture
386
+ chromeArgs.push(
387
+ `--load-extension=${extensionPath},${dashcamChromePath}`,
388
+ );
389
+ } else {
390
+ // If dashcam-chrome unavailable, just load user extension
391
+ chromeArgs.push(`--load-extension=${extensionPath}`);
392
+ }
393
+
394
+ // Launch Chrome (opens to New Tab by default)
395
+ if (self.os === "windows") {
396
+ const argsString = chromeArgs.map((arg) => `"${arg}"`).join(", ");
397
+ await self.exec(
398
+ shell,
399
+ `Start-Process "C:\\ChromeForTesting\\chrome-win64\\chrome.exe" -ArgumentList ${argsString}`,
400
+ 30000,
401
+ );
402
+ } else {
403
+ const argsString = chromeArgs.join(" ");
404
+ await self.exec(
405
+ shell,
406
+ `chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
407
+ 30000,
408
+ );
409
+ }
410
+
411
+ // Wait for Chrome debugger port and page to be ready
412
+ await self._waitForChromeDebuggerReady();
413
+ await self.focusApplication("Google Chrome");
414
+
415
+ // Start dashcam recording
416
+ if (self.dashcamEnabled && !(await self.dashcam.isRecording())) {
417
+ await self.dashcam.start();
418
+ }
419
+ },
420
+
421
+ /**
422
+ * Launch VS Code
423
+ * @param {Object} options - VS Code launch options
424
+ * @param {string} [options.workspace] - Workspace/folder to open
425
+ * @param {string[]} [options.extensions=[]] - Extensions to install
426
+ * @returns {Promise<void>}
427
+ */
428
+ vscode: async (options = {}) => {
429
+ const { workspace = null, extensions = [] } = options;
430
+
431
+ const shell = self.os === "windows" ? "pwsh" : "sh";
432
+
433
+ // Install extensions if provided
434
+ for (const extension of extensions) {
435
+ console.log(`[provision.vscode] Installing extension: ${extension}`);
436
+ await self.exec(
437
+ shell,
438
+ `code --install-extension ${extension} --force`,
439
+ 120000,
440
+ true,
441
+ );
442
+ console.log(
443
+ `[provision.vscode] ✅ Extension installed: ${extension}`,
444
+ );
445
+ }
446
+
447
+ // Launch VS Code
448
+ const workspaceArg = workspace ? `"${workspace}"` : "";
449
+
450
+ if (self.os === "windows") {
451
+ await self.exec(
452
+ shell,
453
+ `Start-Process code -ArgumentList ${workspaceArg}`,
454
+ 30000,
455
+ );
456
+ } else {
457
+ await self.exec(
458
+ shell,
459
+ `code ${workspaceArg} >/dev/null 2>&1 &`,
460
+ 30000,
461
+ );
462
+ }
463
+
464
+ // Wait for VS Code to start up
465
+ await new Promise((resolve) => setTimeout(resolve, 3000));
466
+
467
+ // Wait for VS Code to be ready
468
+ await self.focusApplication("Visual Studio Code");
469
+
470
+ // Start dashcam recording
471
+ if (self.dashcamEnabled && !(await self.dashcam.isRecording())) {
472
+ await self.dashcam.start();
473
+ }
474
+ },
475
+
476
+ /**
477
+ * Download and install an application
478
+ * @param {Object} options - Installer options
479
+ * @param {string} options.url - URL to download the installer from
480
+ * @param {string} [options.filename] - Filename to save as (auto-detected from URL if not provided)
481
+ * @param {string} [options.appName] - Application name to focus after install
482
+ * @param {boolean} [options.launch=true] - Whether to launch the app after installation
483
+ * @returns {Promise<string>} Path to the downloaded file
484
+ * @example
485
+ * // Install a .deb package on Linux (auto-detected)
486
+ * await testdriver.provision.installer({
487
+ * url: 'https://example.com/app.deb',
488
+ * appName: 'MyApp'
489
+ * });
490
+ *
491
+ * @example
492
+ * // Download and run custom commands
493
+ * const filePath = await testdriver.provision.installer({
494
+ * url: 'https://example.com/app.AppImage',
495
+ * launch: false
496
+ * });
497
+ * await testdriver.exec('sh', `chmod +x "${filePath}" && "${filePath}" &`, 10000);
498
+ */
499
+ installer: async (options = {}) => {
500
+ const { url, filename, appName, launch = true } = options;
501
+
502
+ if (!url) {
503
+ throw new Error("[provision.installer] url is required");
504
+ }
505
+
506
+ const shell = self.os === "windows" ? "pwsh" : "sh";
507
+
508
+ // Determine download directory
509
+ const downloadDir =
510
+ self.os === "windows" ? "C:\\Users\\testdriver\\Downloads" : "/tmp";
511
+
512
+ console.log(`[provision.installer] Downloading ${url}...`);
513
+
514
+ let actualFilePath;
515
+
516
+ // Download the file and get the actual filename (handles redirects)
517
+ if (self.os === "windows") {
518
+ // Simple approach: download first, then get the actual filename from the response
519
+ const tempFile = `${downloadDir}\\installer_temp_${Date.now()}`;
520
+
521
+ const downloadScript = `
522
+ $ProgressPreference = 'SilentlyContinue'
523
+ $response = Invoke-WebRequest -Uri "${url}" -OutFile "${tempFile}" -PassThru -UseBasicParsing
524
+
525
+ # Try to get filename from Content-Disposition header
526
+ $filename = $null
527
+ if ($response.Headers['Content-Disposition']) {
528
+ if ($response.Headers['Content-Disposition'] -match 'filename=\\"?([^\\"]+)\\"?') {
529
+ $filename = $matches[1]
530
+ }
531
+ }
532
+
533
+ # If no filename from header, try to get from URL or use default
534
+ if (-not $filename) {
535
+ $uri = [System.Uri]"${url}"
536
+ $filename = [System.IO.Path]::GetFileName($uri.LocalPath)
537
+ if (-not $filename -or $filename -eq '') {
538
+ $filename = "installer"
539
+ }
540
+ }
541
+
542
+ # Move temp file to final location with proper filename
543
+ $finalPath = Join-Path "${downloadDir}" $filename
544
+ Move-Item -Path "${tempFile}" -Destination $finalPath -Force
545
+ Write-Output $finalPath
546
+ `;
547
+
548
+ const result = await self.exec(shell, downloadScript, 300000, true);
549
+ actualFilePath = result ? result.trim() : null;
550
+
551
+ if (!actualFilePath) {
552
+ throw new Error("[provision.installer] Failed to download file");
553
+ }
554
+ } else {
555
+ // Use curl with options to get the final filename
556
+ const tempMarker = `installer_${Date.now()}`;
557
+ const downloadScript = `
558
+ cd "${downloadDir}"
559
+ curl -L -J -O -w "%{filename_effective}" "${url}" 2>/dev/null || echo "${tempMarker}"
560
+ `;
561
+
562
+ const result = await self.exec(shell, downloadScript, 300000, true);
563
+ const downloadedFile = result ? result.trim() : null;
564
+
565
+ if (downloadedFile && downloadedFile !== tempMarker) {
566
+ actualFilePath = `${downloadDir}/${downloadedFile}`;
567
+ } else {
568
+ // Fallback: use curl without -J and specify output file
569
+ const fallbackFilename = filename || "installer";
570
+ actualFilePath = `${downloadDir}/${fallbackFilename}`;
571
+ await self.exec(
572
+ shell,
573
+ `curl -L -o "${actualFilePath}" "${url}"`,
574
+ 300000,
575
+ true,
576
+ );
577
+ }
578
+ }
579
+
580
+ console.log(`[provision.installer] ✅ Downloaded to ${actualFilePath}`);
581
+
582
+ // Auto-detect install command based on file extension (use actualFilePath for extension detection)
583
+ const actualFilename = actualFilePath.split(/[/\\]/).pop() || "";
584
+ const ext = actualFilename.split(".").pop()?.toLowerCase();
585
+ let installCommand = null;
586
+
587
+ if (self.os === "windows") {
588
+ if (ext === "msi") {
589
+ installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
590
+ } else if (ext === "exe") {
591
+ installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
592
+ }
593
+ } else if (self.os === "linux") {
594
+ if (ext === "deb") {
595
+ installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
596
+ } else if (ext === "rpm") {
597
+ installCommand = `sudo rpm -i "${actualFilePath}"`;
598
+ } else if (ext === "appimage") {
599
+ installCommand = `chmod +x "${actualFilePath}"`;
600
+ } else if (ext === "sh") {
601
+ installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
602
+ }
603
+ } else if (self.os === "darwin") {
604
+ if (ext === "dmg") {
605
+ installCommand = `hdiutil attach "${actualFilePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
606
+ } else if (ext === "pkg") {
607
+ installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
608
+ }
609
+ }
610
+
611
+ if (installCommand) {
612
+ console.log(`[provision.installer] Installing...`);
613
+ await self.exec(shell, installCommand, 300000, true);
614
+ console.log(`[provision.installer] ✅ Installation complete`);
615
+ }
616
+
617
+ // Launch and focus the app if appName is provided and launch is true
618
+ if (appName && launch) {
619
+ await new Promise((resolve) => setTimeout(resolve, 2000));
620
+ await self.focusApplication(appName);
621
+ }
622
+
623
+ // Start dashcam recording
624
+ if (self.dashcamEnabled && !(await self.dashcam.isRecording())) {
625
+ await self.dashcam.start();
626
+ }
627
+
628
+ return actualFilePath;
629
+ },
630
+
631
+ /**
632
+ * Launch Electron app
633
+ * @param {Object} options - Electron launch options
634
+ * @param {string} options.appPath - Path to Electron app (required)
635
+ * @param {string[]} [options.args=[]] - Additional electron args
636
+ * @returns {Promise<void>}
637
+ */
638
+ electron: async (options = {}) => {
639
+ const { appPath, args = [] } = options;
640
+
641
+ if (!appPath) {
642
+ throw new Error("provision.electron requires appPath option");
643
+ }
644
+
645
+ const shell = self.os === "windows" ? "pwsh" : "sh";
646
+
647
+ const argsString = args.join(" ");
648
+
649
+ if (self.os === "windows") {
650
+ await self.exec(
651
+ shell,
652
+ `Start-Process electron -ArgumentList "${appPath}", ${argsString}`,
653
+ 30000,
654
+ );
655
+ } else {
656
+ await self.exec(
657
+ shell,
658
+ `electron "${appPath}" ${argsString} >/dev/null 2>&1 &`,
659
+ 30000,
660
+ );
661
+ }
662
+
663
+ await self.focusApplication("Electron");
664
+
665
+ // Start dashcam recording
666
+ if (self.dashcamEnabled && !(await self.dashcam.isRecording())) {
667
+ await self.dashcam.start();
668
+ }
669
+ },
670
+
671
+ /**
672
+ * Initialize Dashcam recording with logging
673
+ * @param {Object} options - Dashcam options
674
+ * @param {string} [options.logPath] - Path to log file (auto-generated if not provided)
675
+ * @param {string} [options.logName='TestDriver Log'] - Display name for the log
676
+ * @param {boolean} [options.webLogs=true] - Enable web log tracking
677
+ * @param {string} [options.title] - Custom title for the recording
678
+ * @returns {Promise<void>}
679
+ */
680
+ dashcam: async (options = {}) => {
681
+ const {
682
+ logPath,
683
+ logName = "TestDriver Log",
684
+ webLogs = true,
685
+ title,
686
+ } = options;
687
+
688
+ // Ensure dashcam is enabled
689
+ if (!self.dashcamEnabled) {
690
+ console.warn(
691
+ "[provision.dashcam] Dashcam is not enabled. Skipping.",
692
+ );
693
+ return;
694
+ }
695
+
696
+ // Set custom title if provided
697
+ if (title) {
698
+ self.dashcam.setTitle(title);
699
+ }
700
+
701
+ // Add file log tracking
702
+ const actualLogPath =
703
+ logPath ||
704
+ (self.os === "windows"
705
+ ? "C:\\Users\\testdriver\\testdriver.log"
706
+ : "/tmp/testdriver.log");
707
+
708
+ await self.dashcam.addFileLog(actualLogPath, logName);
709
+
710
+ // Add web log tracking if enabled
711
+ // Use domain pattern from provisioned Chrome URL if available
712
+ if (webLogs) {
713
+ const pattern = self._provisionedChromeUrl
714
+ ? self._getUrlDomainPattern(self._provisionedChromeUrl)
715
+ : "**";
716
+ await self.dashcam.addWebLog(pattern, "Web Logs");
717
+ }
718
+
719
+ // Start recording if not already recording
720
+ if (!(await self.dashcam.isRecording())) {
721
+ await self.dashcam.start();
722
+ }
723
+
724
+ console.log("[provision.dashcam] ✅ Dashcam recording started");
725
+ },
726
+ };
727
+
728
+ // Wrap all provision methods with reconnect check using Proxy
729
+ return new Proxy(provisionMethods, {
730
+ get(target, prop) {
731
+ const method = target[prop];
732
+ if (typeof method === "function") {
733
+ return async (...args) => {
734
+ // Skip provisioning if reconnecting to existing sandbox
735
+ if (self.reconnect) {
736
+ console.log(
737
+ `[provision.${prop}] Skipping provisioning (reconnect mode)`,
738
+ );
739
+ return;
740
+ }
741
+ return method(...args);
742
+ };
743
+ }
744
+ return method;
745
+ },
746
+ });
747
+ }
748
+
749
+ module.exports = { createProvisionAPI };