@testdriverai/agent 7.9.81-test → 7.9.91-test
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/lib/provision-commands.js +38 -11
- package/docs/_data/examples-manifest.json +52 -52
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +30 -13
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/findall-coffee-icons.mdx +1 -1
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text-with-description.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/parse.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/examples/chrome-extension.test.mjs +29 -12
- package/lib/core/Dashcam.js +18 -0
- package/lib/provision.js +749 -0
- package/package.json +1 -1
- package/sdk.js +2 -727
package/lib/provision.js
ADDED
|
@@ -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 };
|