testdriverai 7.2.9 → 7.2.10
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/.github/workflows/testdriver.yml +127 -0
- package/.testdriver/last-sandbox +7 -0
- package/agent/events.js +1 -0
- package/agent/index.js +71 -54
- package/agent/lib/sandbox.js +11 -1
- package/agents.md +393 -0
- package/debug/01-table-initial.png +0 -0
- package/debug/02-after-ai-explore.png +0 -0
- package/debug/02-after-scroll.png +0 -0
- package/docs/docs.json +93 -125
- package/docs/v7/_drafts/caching.mdx +2 -2
- package/docs/v7/{getting-started → _drafts}/installation.mdx +0 -66
- package/docs/v7/{features/coverage.mdx → _drafts/powerful.mdx} +1 -90
- package/docs/v7/{features → _drafts}/scalable.mdx +126 -4
- package/docs/v7/_drafts/screenshot.mdx +155 -0
- package/docs/v7/_drafts/writing-tests.mdx +25 -0
- package/docs/v7/{api/act.mdx → ai.mdx} +27 -27
- package/docs/v7/{api/assert.mdx → assert.mdx} +3 -3
- package/docs/v7/aws-setup.mdx +338 -0
- package/docs/v7/caching.mdx +128 -0
- package/docs/v7/ci-cd.mdx +605 -0
- package/docs/v7/{api/click.mdx → click.mdx} +4 -4
- package/docs/v7/cloud.mdx +120 -0
- package/docs/v7/customizing-devices.mdx +129 -0
- package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
- package/docs/v7/enterprise.mdx +135 -0
- package/docs/v7/examples.mdx +5 -0
- package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
- package/docs/v7/{api/find.mdx → find.mdx} +17 -21
- package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
- package/docs/v7/generating-tests.mdx +32 -0
- package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
- package/docs/v7/locating-elements.mdx +71 -0
- package/docs/v7/making-assertions.mdx +32 -0
- package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
- package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
- package/docs/v7/performing-actions.mdx +51 -0
- package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
- package/docs/v7/quickstart.mdx +162 -0
- package/docs/v7/reusable-code.mdx +240 -0
- package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
- package/docs/v7/running-tests.mdx +181 -0
- package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
- package/docs/v7/secrets.mdx +115 -0
- package/docs/v7/self-hosted.mdx +66 -0
- package/docs/v7/{api/type.mdx → type.mdx} +3 -3
- package/docs/v7/variables.mdx +111 -0
- package/docs/v7/waiting-for-elements.mdx +66 -0
- package/docs/v7/what-is-testdriver.mdx +54 -0
- package/lib/vitest/hooks.mjs +80 -68
- package/package.json +1 -1
- package/sdk.d.ts +22 -9
- package/sdk.js +177 -44
- package/test/manual/reconnect-provision.test.mjs +49 -0
- package/test/manual/reconnect-signin.test.mjs +41 -0
- package/test/testdriver/ai.test.mjs +30 -0
- package/test/testdriver/setup/testHelpers.mjs +0 -1
- package/test/testdriver/windows-installer.test.mjs +61 -0
- package/tests/table-sort-enrollments.test.mjs +72 -0
- package/tests/table-sort-experiment.test.mjs +42 -0
- package/tests/table-sort-setup.test.mjs +59 -0
- package/vitest.config.mjs +1 -0
- package/docs/v7/api/assertions.mdx +0 -403
- package/docs/v7/features/ai-native.mdx +0 -413
- package/docs/v7/features/application-logs.mdx +0 -353
- package/docs/v7/features/browser-logs.mdx +0 -414
- package/docs/v7/features/cache-management.mdx +0 -402
- package/docs/v7/features/continuous-testing.mdx +0 -346
- package/docs/v7/features/data-driven-testing.mdx +0 -441
- package/docs/v7/features/easy-to-write.mdx +0 -280
- package/docs/v7/features/enterprise.mdx +0 -656
- package/docs/v7/features/fast.mdx +0 -406
- package/docs/v7/features/managed-sandboxes.mdx +0 -384
- package/docs/v7/features/network-monitoring.mdx +0 -568
- package/docs/v7/features/parallel-execution.mdx +0 -381
- package/docs/v7/features/powerful.mdx +0 -531
- package/docs/v7/features/sandbox-customization.mdx +0 -229
- package/docs/v7/features/stable.mdx +0 -473
- package/docs/v7/features/system-performance.mdx +0 -616
- package/docs/v7/features/test-analytics.mdx +0 -373
- package/docs/v7/features/test-cases.mdx +0 -393
- package/docs/v7/features/test-replays.mdx +0 -408
- package/docs/v7/features/test-reports.mdx +0 -308
- package/docs/v7/getting-started/debugging-tests.mdx +0 -382
- package/docs/v7/getting-started/quickstart.mdx +0 -90
- package/docs/v7/getting-started/running-tests.mdx +0 -173
- package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
- package/docs/v7/getting-started/writing-tests.mdx +0 -534
- package/docs/v7/overview/what-is-testdriver.mdx +0 -386
- package/docs/v7/presets/chrome-extension.mdx +0 -248
- package/docs/v7/presets/chrome.mdx +0 -300
- package/docs/v7/presets/electron.mdx +0 -460
- package/docs/v7/presets/vscode.mdx +0 -417
- package/docs/v7/presets/webapp.mdx +0 -393
- package/vitest.config.js +0 -18
- /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
- /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
- /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
- /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
- /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
- /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
- /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
- /package/docs/v7/{api/dashcam.mdx → dashcam.mdx} +0 -0
- /package/docs/v7/{api/elements.mdx → elements.mdx} +0 -0
- /package/docs/v7/{api/sandbox.mdx → sandbox.mdx} +0 -0
package/sdk.d.ts
CHANGED
|
@@ -242,6 +242,8 @@ export interface TestDriverOptions {
|
|
|
242
242
|
sandboxInstance?: string;
|
|
243
243
|
/** Cache key for element finding operations. If provided, enables caching tied to this key */
|
|
244
244
|
cacheKey?: string;
|
|
245
|
+
/** Reconnect to the last used sandbox (throws error if no last sandbox exists) */
|
|
246
|
+
reconnect?: boolean;
|
|
245
247
|
/** Redraw configuration for screen change detection */
|
|
246
248
|
redraw?: boolean | {
|
|
247
249
|
/** Enable redraw detection (default: true) */
|
|
@@ -264,6 +266,8 @@ export interface ConnectOptions {
|
|
|
264
266
|
sandboxId?: string;
|
|
265
267
|
/** Force creation of a new sandbox */
|
|
266
268
|
newSandbox?: boolean;
|
|
269
|
+
/** Reconnect to the last used sandbox (throws error if no last sandbox exists) */
|
|
270
|
+
reconnect?: boolean;
|
|
267
271
|
/** Direct IP address to connect to a running sandbox instance */
|
|
268
272
|
ip?: string;
|
|
269
273
|
/** Custom AMI ID for sandbox instance (e.g., 'ami-1234') */
|
|
@@ -276,6 +280,8 @@ export interface ConnectOptions {
|
|
|
276
280
|
headless?: boolean;
|
|
277
281
|
/** Reuse recent connection if available (default: true) */
|
|
278
282
|
reuseConnection?: boolean;
|
|
283
|
+
/** Keep sandbox alive for specified milliseconds after disconnect (default: 60000). Set to 0 to terminate immediately on disconnect. */
|
|
284
|
+
keepAlive?: number;
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
export interface SandboxInstance {
|
|
@@ -695,6 +701,18 @@ export default class TestDriverSDK {
|
|
|
695
701
|
*/
|
|
696
702
|
disconnect(): Promise<void>;
|
|
697
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Get the last sandbox info from the stored file
|
|
706
|
+
* @returns Last sandbox info or null if not found
|
|
707
|
+
*/
|
|
708
|
+
getLastSandboxId(): {
|
|
709
|
+
sandboxId: string | null;
|
|
710
|
+
os: 'windows' | 'linux';
|
|
711
|
+
ami: string | null;
|
|
712
|
+
instanceType: string | null;
|
|
713
|
+
timestamp: string | null;
|
|
714
|
+
} | null;
|
|
715
|
+
|
|
698
716
|
// Element Finding API
|
|
699
717
|
|
|
700
718
|
/**
|
|
@@ -702,7 +720,7 @@ export default class TestDriverSDK {
|
|
|
702
720
|
* Automatically locates the element and returns it
|
|
703
721
|
*
|
|
704
722
|
* @param description - Description of the element to find
|
|
705
|
-
* @param
|
|
723
|
+
* @param options - Cache threshold (number) or options object
|
|
706
724
|
* @returns Chainable promise that resolves to Element instance
|
|
707
725
|
*
|
|
708
726
|
* @example
|
|
@@ -719,17 +737,12 @@ export default class TestDriverSDK {
|
|
|
719
737
|
* const element = await client.find('login button', 0.01);
|
|
720
738
|
*
|
|
721
739
|
* @example
|
|
722
|
-
* // Poll
|
|
723
|
-
*
|
|
724
|
-
* while (!element?.found()) {
|
|
725
|
-
* element = await client.find('login button');
|
|
726
|
-
* if (!element.found()) {
|
|
727
|
-
* await new Promise(resolve => setTimeout(resolve, 1000));
|
|
728
|
-
* }
|
|
729
|
-
* }
|
|
740
|
+
* // Poll for element with timeout (retries every 5 seconds)
|
|
741
|
+
* const element = await client.find('loading complete indicator', { timeout: 30000 });
|
|
730
742
|
* await element.click();
|
|
731
743
|
*/
|
|
732
744
|
find(description: string, cacheThreshold?: number): ChainableElementPromise;
|
|
745
|
+
find(description: string, options?: { cacheThreshold?: number; cacheKey?: string; timeout?: number }): ChainableElementPromise;
|
|
733
746
|
|
|
734
747
|
/**
|
|
735
748
|
* Find all elements matching a description
|
package/sdk.js
CHANGED
|
@@ -372,10 +372,17 @@ class Element {
|
|
|
372
372
|
/**
|
|
373
373
|
* Find the element on screen
|
|
374
374
|
* @param {string} [newDescription] - Optional new description to search for
|
|
375
|
-
* @param {Object} [options] - Optional options object with cacheThreshold and/or
|
|
375
|
+
* @param {Object} [options] - Optional options object with cacheThreshold, cacheKey, and/or timeout
|
|
376
|
+
* @param {number} [options.timeout] - Max time in ms to poll for element (polls every 5 seconds)
|
|
376
377
|
* @returns {Promise<Element>} This element instance
|
|
377
378
|
*/
|
|
378
379
|
async find(newDescription, options) {
|
|
380
|
+
// Handle timeout/polling option
|
|
381
|
+
const timeout = typeof options === 'object' ? options?.timeout : null;
|
|
382
|
+
if (timeout && timeout > 0) {
|
|
383
|
+
return this._findWithTimeout(newDescription, options, timeout);
|
|
384
|
+
}
|
|
385
|
+
|
|
379
386
|
const description = newDescription || this.description;
|
|
380
387
|
if (newDescription) {
|
|
381
388
|
this.description = newDescription;
|
|
@@ -526,6 +533,61 @@ class Element {
|
|
|
526
533
|
return this;
|
|
527
534
|
}
|
|
528
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Find element with polling/timeout support
|
|
538
|
+
* @private
|
|
539
|
+
* @param {string} [newDescription] - Optional new description to search for
|
|
540
|
+
* @param {Object} options - Options object
|
|
541
|
+
* @param {number} timeout - Max time in ms to poll for element
|
|
542
|
+
* @returns {Promise<Element>} This element instance
|
|
543
|
+
*/
|
|
544
|
+
async _findWithTimeout(newDescription, options, timeout) {
|
|
545
|
+
const POLL_INTERVAL = 5000; // 5 seconds between attempts
|
|
546
|
+
const startTime = Date.now();
|
|
547
|
+
const description = newDescription || this.description;
|
|
548
|
+
|
|
549
|
+
// Log that we're starting a polling find
|
|
550
|
+
const { events } = require("./agent/events.js");
|
|
551
|
+
this.sdk.emitter.emit(events.log.log, `🔄 Polling for "${description}" (timeout: ${timeout}ms)`);
|
|
552
|
+
|
|
553
|
+
// Create options without timeout to avoid infinite recursion
|
|
554
|
+
const findOptions = typeof options === 'object' ? { ...options } : {};
|
|
555
|
+
delete findOptions.timeout;
|
|
556
|
+
|
|
557
|
+
let attempts = 0;
|
|
558
|
+
while (Date.now() - startTime < timeout) {
|
|
559
|
+
attempts++;
|
|
560
|
+
|
|
561
|
+
// Call the regular find (without timeout option)
|
|
562
|
+
await this.find(newDescription, findOptions);
|
|
563
|
+
|
|
564
|
+
if (this._found) {
|
|
565
|
+
this.sdk.emitter.emit(events.log.log, `✅ Found "${description}" after ${attempts} attempt(s)`);
|
|
566
|
+
return this;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const elapsed = Date.now() - startTime;
|
|
570
|
+
const remaining = timeout - elapsed;
|
|
571
|
+
|
|
572
|
+
if (remaining > POLL_INTERVAL) {
|
|
573
|
+
this.sdk.emitter.emit(events.log.log, `⏳ Element not found, retrying in 5s... (${Math.round(remaining / 1000)}s remaining)`);
|
|
574
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
|
575
|
+
} else if (remaining > 0) {
|
|
576
|
+
// Less than 5s remaining, wait the remaining time and try once more
|
|
577
|
+
await new Promise(resolve => setTimeout(resolve, remaining));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Final attempt after timeout
|
|
582
|
+
await this.find(newDescription, findOptions);
|
|
583
|
+
|
|
584
|
+
if (!this._found) {
|
|
585
|
+
this.sdk.emitter.emit(events.log.log, `❌ Element "${description}" not found after ${timeout}ms (${attempts} attempts)`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return this;
|
|
589
|
+
}
|
|
590
|
+
|
|
529
591
|
/**
|
|
530
592
|
* Sanitize response by removing large base64 data to prevent memory leaks
|
|
531
593
|
* @private
|
|
@@ -1183,6 +1245,9 @@ class TestDriverSDK {
|
|
|
1183
1245
|
this.sandboxAmi = options.sandboxAmi || null;
|
|
1184
1246
|
this.sandboxInstance = options.sandboxInstance || null;
|
|
1185
1247
|
|
|
1248
|
+
// Store reconnect preference from options
|
|
1249
|
+
this.reconnect = options.reconnect !== undefined ? options.reconnect : false;
|
|
1250
|
+
|
|
1186
1251
|
// Cache threshold configuration
|
|
1187
1252
|
// threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
|
|
1188
1253
|
// By default, cache is DISABLED (threshold = -1) to avoid unnecessary AI costs
|
|
@@ -1460,22 +1525,19 @@ class TestDriverSDK {
|
|
|
1460
1525
|
* @param {Object} options - Chrome extension launch options
|
|
1461
1526
|
* @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
|
|
1462
1527
|
* @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
|
|
1463
|
-
* @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
|
|
1464
1528
|
* @param {boolean} [options.maximized=true] - Start maximized
|
|
1465
1529
|
* @returns {Promise<void>}
|
|
1466
1530
|
* @example
|
|
1467
1531
|
* // Load extension from local path
|
|
1468
1532
|
* await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
|
|
1469
1533
|
* await testdriver.provision.chromeExtension({
|
|
1470
|
-
* extensionPath: '/tmp/extension'
|
|
1471
|
-
* url: 'https://example.com'
|
|
1534
|
+
* extensionPath: '/tmp/extension'
|
|
1472
1535
|
* });
|
|
1473
1536
|
*
|
|
1474
1537
|
* @example
|
|
1475
1538
|
* // Load extension by Chrome Web Store ID
|
|
1476
1539
|
* await testdriver.provision.chromeExtension({
|
|
1477
|
-
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm'
|
|
1478
|
-
* url: 'https://example.com'
|
|
1540
|
+
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm' // uBlock Origin
|
|
1479
1541
|
* });
|
|
1480
1542
|
*/
|
|
1481
1543
|
chromeExtension: async (options = {}) => {
|
|
@@ -1485,7 +1547,6 @@ class TestDriverSDK {
|
|
|
1485
1547
|
const {
|
|
1486
1548
|
extensionPath: providedExtensionPath,
|
|
1487
1549
|
extensionId,
|
|
1488
|
-
url = 'http://testdriver-sandbox.vercel.app/',
|
|
1489
1550
|
maximized = true,
|
|
1490
1551
|
} = options;
|
|
1491
1552
|
|
|
@@ -1603,7 +1664,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1603
1664
|
console.log(`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`);
|
|
1604
1665
|
}
|
|
1605
1666
|
|
|
1606
|
-
// If dashcam is available
|
|
1667
|
+
// If dashcam is available, set up file logging
|
|
1607
1668
|
if (this._dashcam) {
|
|
1608
1669
|
// Create the log file on the remote machine
|
|
1609
1670
|
const logPath = this.os === "windows"
|
|
@@ -1615,11 +1676,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1615
1676
|
: `touch ${logPath}`;
|
|
1616
1677
|
|
|
1617
1678
|
await this.exec(shell, createLogCmd, 10000, true);
|
|
1618
|
-
|
|
1619
|
-
const urlObj = new URL(url);
|
|
1620
|
-
const domain = urlObj.hostname;
|
|
1621
|
-
const pattern = `*${domain}*`;
|
|
1622
|
-
await this._dashcam.addWebLog(pattern, 'Web Logs');
|
|
1623
1679
|
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1624
1680
|
}
|
|
1625
1681
|
|
|
@@ -1695,19 +1751,19 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1695
1751
|
chromeArgs.push(`--load-extension=${extensionPath}`);
|
|
1696
1752
|
}
|
|
1697
1753
|
|
|
1698
|
-
// Launch Chrome
|
|
1754
|
+
// Launch Chrome (opens to New Tab by default)
|
|
1699
1755
|
if (this.os === 'windows') {
|
|
1700
1756
|
const argsString = chromeArgs.map(arg => `"${arg}"`).join(', ');
|
|
1701
1757
|
await this.exec(
|
|
1702
1758
|
shell,
|
|
1703
|
-
`Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList ${argsString}
|
|
1759
|
+
`Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList ${argsString}`,
|
|
1704
1760
|
30000
|
|
1705
1761
|
);
|
|
1706
1762
|
} else {
|
|
1707
1763
|
const argsString = chromeArgs.join(' ');
|
|
1708
1764
|
await this.exec(
|
|
1709
1765
|
shell,
|
|
1710
|
-
`chrome-for-testing ${argsString}
|
|
1766
|
+
`chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
|
|
1711
1767
|
30000
|
|
1712
1768
|
);
|
|
1713
1769
|
}
|
|
@@ -1715,25 +1771,18 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1715
1771
|
// Wait for Chrome to be ready
|
|
1716
1772
|
await this.focusApplication('Google Chrome');
|
|
1717
1773
|
|
|
1718
|
-
// Wait for
|
|
1719
|
-
|
|
1720
|
-
const
|
|
1721
|
-
const domain = urlObj.hostname;
|
|
1722
|
-
|
|
1723
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1724
|
-
const result = await this.find(`${domain}`);
|
|
1774
|
+
// Wait for New Tab to appear
|
|
1775
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1776
|
+
const result = await this.find('New Tab');
|
|
1725
1777
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
}
|
|
1778
|
+
if (result.found()) {
|
|
1779
|
+
break;
|
|
1780
|
+
} else {
|
|
1781
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1731
1782
|
}
|
|
1732
|
-
|
|
1733
|
-
await this.focusApplication('Google Chrome');
|
|
1734
|
-
} catch (e) {
|
|
1735
|
-
console.warn(`[provision.chromeExtension] ⚠️ Could not parse URL "${url}":`, e.message);
|
|
1736
1783
|
}
|
|
1784
|
+
|
|
1785
|
+
await this.focusApplication('Google Chrome');
|
|
1737
1786
|
},
|
|
1738
1787
|
|
|
1739
1788
|
/**
|
|
@@ -1900,33 +1949,82 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1900
1949
|
);
|
|
1901
1950
|
}
|
|
1902
1951
|
|
|
1903
|
-
|
|
1952
|
+
// Check if the downloaded file has a proper extension, if not scan the download directory
|
|
1953
|
+
let actualFilePath = filePath;
|
|
1954
|
+
const hasValidExtension = /\.(msi|exe|deb|rpm|appimage|sh|dmg|pkg)$/i.test(detectedFilename);
|
|
1955
|
+
|
|
1956
|
+
if (!hasValidExtension && this.os === 'windows') {
|
|
1957
|
+
// On Windows, scan the download directory for .msi or .exe files
|
|
1958
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for .msi or .exe files...`);
|
|
1959
|
+
const scanResult = await this.exec(
|
|
1960
|
+
shell,
|
|
1961
|
+
`Get-ChildItem -Path "${downloadDir}" -File | Where-Object { $_.Extension -match '\\.(msi|exe)$' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`,
|
|
1962
|
+
30000,
|
|
1963
|
+
true
|
|
1964
|
+
);
|
|
1965
|
+
|
|
1966
|
+
if (scanResult && scanResult.trim()) {
|
|
1967
|
+
actualFilePath = scanResult.trim();
|
|
1968
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1969
|
+
}
|
|
1970
|
+
} else if (!hasValidExtension && this.os === 'linux') {
|
|
1971
|
+
// On Linux, scan for common installer extensions
|
|
1972
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for installer files...`);
|
|
1973
|
+
const scanResult = await this.exec(
|
|
1974
|
+
shell,
|
|
1975
|
+
`find "${downloadDir}" -maxdepth 1 -type f \\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.sh" \\) -printf '%T@ %p\\n' | sort -rn | head -1 | cut -d' ' -f2-`,
|
|
1976
|
+
30000,
|
|
1977
|
+
true
|
|
1978
|
+
);
|
|
1979
|
+
|
|
1980
|
+
if (scanResult && scanResult.trim()) {
|
|
1981
|
+
actualFilePath = scanResult.trim();
|
|
1982
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1983
|
+
}
|
|
1984
|
+
} else if (!hasValidExtension && this.os === 'darwin') {
|
|
1985
|
+
// On macOS, scan for common installer extensions
|
|
1986
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for installer files...`);
|
|
1987
|
+
const scanResult = await this.exec(
|
|
1988
|
+
shell,
|
|
1989
|
+
`find "${downloadDir}" -maxdepth 1 -type f \\( -name "*.dmg" -o -name "*.pkg" \\) -print0 | xargs -0 ls -t | head -1`,
|
|
1990
|
+
30000,
|
|
1991
|
+
true
|
|
1992
|
+
);
|
|
1993
|
+
|
|
1994
|
+
if (scanResult && scanResult.trim()) {
|
|
1995
|
+
actualFilePath = scanResult.trim();
|
|
1996
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
console.log(`[provision.installer] ✅ Downloaded to ${actualFilePath}`);
|
|
1904
2001
|
|
|
1905
|
-
// Auto-detect install command based on file extension
|
|
1906
|
-
const
|
|
2002
|
+
// Auto-detect install command based on file extension (use actualFilePath for extension detection)
|
|
2003
|
+
const actualFilename = actualFilePath.split(/[/\\]/).pop() || '';
|
|
2004
|
+
const ext = actualFilename.split('.').pop()?.toLowerCase();
|
|
1907
2005
|
let installCommand = null;
|
|
1908
2006
|
|
|
1909
2007
|
if (this.os === 'windows') {
|
|
1910
2008
|
if (ext === 'msi') {
|
|
1911
|
-
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${
|
|
2009
|
+
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
|
|
1912
2010
|
} else if (ext === 'exe') {
|
|
1913
|
-
installCommand = `Start-Process "${
|
|
2011
|
+
installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
|
|
1914
2012
|
}
|
|
1915
2013
|
} else if (this.os === 'linux') {
|
|
1916
2014
|
if (ext === 'deb') {
|
|
1917
|
-
installCommand = `sudo dpkg -i "${
|
|
2015
|
+
installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
|
|
1918
2016
|
} else if (ext === 'rpm') {
|
|
1919
|
-
installCommand = `sudo rpm -i "${
|
|
2017
|
+
installCommand = `sudo rpm -i "${actualFilePath}"`;
|
|
1920
2018
|
} else if (ext === 'appimage') {
|
|
1921
|
-
installCommand = `chmod +x "${
|
|
2019
|
+
installCommand = `chmod +x "${actualFilePath}"`;
|
|
1922
2020
|
} else if (ext === 'sh') {
|
|
1923
|
-
installCommand = `chmod +x "${
|
|
2021
|
+
installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
|
|
1924
2022
|
}
|
|
1925
2023
|
} else if (this.os === 'darwin') {
|
|
1926
2024
|
if (ext === 'dmg') {
|
|
1927
|
-
installCommand = `hdiutil attach "${
|
|
2025
|
+
installCommand = `hdiutil attach "${actualFilePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
|
|
1928
2026
|
} else if (ext === 'pkg') {
|
|
1929
|
-
installCommand = `sudo installer -pkg "${
|
|
2027
|
+
installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
|
|
1930
2028
|
}
|
|
1931
2029
|
}
|
|
1932
2030
|
|
|
@@ -1942,7 +2040,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1942
2040
|
await this.focusApplication(appName);
|
|
1943
2041
|
}
|
|
1944
2042
|
|
|
1945
|
-
return
|
|
2043
|
+
return actualFilePath;
|
|
1946
2044
|
},
|
|
1947
2045
|
|
|
1948
2046
|
/**
|
|
@@ -2039,6 +2137,29 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2039
2137
|
: this.newSandbox,
|
|
2040
2138
|
};
|
|
2041
2139
|
|
|
2140
|
+
// Handle reconnect option - use last sandbox file
|
|
2141
|
+
// Check both connectOptions and constructor options
|
|
2142
|
+
const shouldReconnect = connectOptions.reconnect !== undefined
|
|
2143
|
+
? connectOptions.reconnect
|
|
2144
|
+
: this.reconnect;
|
|
2145
|
+
|
|
2146
|
+
if (shouldReconnect) {
|
|
2147
|
+
const lastSandbox = this.agent.getLastSandboxId();
|
|
2148
|
+
if (!lastSandbox || !lastSandbox.sandboxId) {
|
|
2149
|
+
throw new Error(
|
|
2150
|
+
"Cannot reconnect: No previous sandbox found. Run a test first to create a sandbox, or remove the reconnect option."
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
this.agent.sandboxId = lastSandbox.sandboxId;
|
|
2154
|
+
buildEnvOptions.new = false;
|
|
2155
|
+
|
|
2156
|
+
// Use OS from last sandbox if not explicitly specified
|
|
2157
|
+
if (!connectOptions.os && lastSandbox.os) {
|
|
2158
|
+
this.agent.sandboxOs = lastSandbox.os;
|
|
2159
|
+
this.os = lastSandbox.os;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2042
2163
|
// Set agent properties for buildEnv to use
|
|
2043
2164
|
if (connectOptions.sandboxId) {
|
|
2044
2165
|
this.agent.sandboxId = connectOptions.sandboxId;
|
|
@@ -2068,6 +2189,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2068
2189
|
} else {
|
|
2069
2190
|
this.agent.sandboxOs = this.os;
|
|
2070
2191
|
}
|
|
2192
|
+
// Use keepAlive from connectOptions if provided
|
|
2193
|
+
if (connectOptions.keepAlive !== undefined) {
|
|
2194
|
+
this.agent.keepAlive = connectOptions.keepAlive;
|
|
2195
|
+
}
|
|
2071
2196
|
|
|
2072
2197
|
// Set redrawThreshold on agent's cliArgs.options
|
|
2073
2198
|
this.agent.cliArgs.options.redrawThreshold = this.redrawThreshold;
|
|
@@ -2150,6 +2275,14 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2150
2275
|
return this.session?.get() || null;
|
|
2151
2276
|
}
|
|
2152
2277
|
|
|
2278
|
+
/**
|
|
2279
|
+
* Get the last sandbox info from the stored file
|
|
2280
|
+
* @returns {Object|null} Last sandbox info including sandboxId, os, ami, instanceType, timestamp, or null if not found
|
|
2281
|
+
*/
|
|
2282
|
+
getLastSandboxId() {
|
|
2283
|
+
return this.agent.getLastSandboxId();
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2153
2286
|
// ====================================
|
|
2154
2287
|
// Element Finding API
|
|
2155
2288
|
// ====================================
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - Reconnect Test Part 1: Provision
|
|
3
|
+
*
|
|
4
|
+
* This test provisions a new sandbox and navigates to the login page.
|
|
5
|
+
* The sandbox ID is saved to .testdriver/last-sandbox for the next test.
|
|
6
|
+
*
|
|
7
|
+
* The sandbox has keepAlive: 120000 (2 minutes) after disconnect.
|
|
8
|
+
* Run reconnect-signin.test.mjs within 2 minutes of this test completing.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* 1. npm test -- test/testdriver/reconnect-provision.test.mjs
|
|
12
|
+
* 2. (within 2 minutes) npm test -- test/testdriver/reconnect-signin.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
16
|
+
import { TestDriver } from "../../lib/vitest/hooks.mjs";
|
|
17
|
+
|
|
18
|
+
describe("Reconnect Test - Part 1: Provision", () => {
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
// Explicitly DO NOT disconnect - we want the sandbox to stay alive
|
|
22
|
+
// for the reconnect test. The sandbox will auto-terminate after keepAlive TTL.
|
|
23
|
+
console.log("\n⚠️ NOT disconnecting - sandbox will stay alive for ~2 minutes (keepAlive: 120000)");
|
|
24
|
+
console.log(" Run reconnect-signin.test.mjs within 2 minutes to test reconnect\n");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should provision sandbox and navigate to login page", async (context) => {
|
|
28
|
+
|
|
29
|
+
const testdriver = TestDriver(context, { newSandbox: true, headless: false });
|
|
30
|
+
|
|
31
|
+
// Provision Chrome and navigate to login page
|
|
32
|
+
await testdriver.provision.chrome({
|
|
33
|
+
url: 'http://testdriver-sandbox.vercel.app/login',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// Verify we're on the login page
|
|
38
|
+
const result = await testdriver.assert("I can see a Sign In button");
|
|
39
|
+
expect(result).toBeTruthy();
|
|
40
|
+
|
|
41
|
+
// Get the sandbox ID that was saved
|
|
42
|
+
const lastSandbox = testdriver.getLastSandboxId();
|
|
43
|
+
console.log("\n✅ Sandbox provisioned:", lastSandbox?.sandboxId);
|
|
44
|
+
console.log(" Sandbox info saved to .testdriver/last-sandbox");
|
|
45
|
+
|
|
46
|
+
expect(lastSandbox).toBeTruthy();
|
|
47
|
+
expect(lastSandbox.sandboxId).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - Reconnect Test Part 2: Sign In
|
|
3
|
+
*
|
|
4
|
+
* This test reconnects to the sandbox provisioned by reconnect-provision.test.mjs
|
|
5
|
+
* and clicks the Sign In button.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Run this within 2 minutes of reconnect-provision.test.mjs completing.
|
|
8
|
+
* The sandbox auto-terminates after the keepAlive TTL (default 2 minutes).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* 1. npm test -- test/testdriver/reconnect-provision.test.mjs
|
|
12
|
+
* 2. (within 2 minutes) npm test -- test/testdriver/reconnect-signin.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { TestDriver } from "../../lib/vitest/hooks.mjs";
|
|
17
|
+
|
|
18
|
+
describe("Reconnect Test - Part 2: Sign In", () => {
|
|
19
|
+
|
|
20
|
+
it("should reconnect to existing sandbox and click Sign In", async (context) => {
|
|
21
|
+
|
|
22
|
+
const testdriver = TestDriver(context, { newSandbox: true, headless: false, reconnect: true });
|
|
23
|
+
|
|
24
|
+
// Provision Chrome and navigate to login page
|
|
25
|
+
await testdriver.provision.chrome({
|
|
26
|
+
url: 'http://testdriver-sandbox.vercel.app/login',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Click on Sign In button - the page should already be loaded from provision test
|
|
30
|
+
const signInButton = await testdriver.find(
|
|
31
|
+
"Sign In, black button below the password field",
|
|
32
|
+
);
|
|
33
|
+
await signInButton.click();
|
|
34
|
+
|
|
35
|
+
// Assert that an error shows that fields are required
|
|
36
|
+
const result = await testdriver.assert(
|
|
37
|
+
"an error shows that fields are required",
|
|
38
|
+
);
|
|
39
|
+
expect(result).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - AI Test (Vitest)
|
|
3
|
+
* Tests the AI exploratory loop (ai) functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { TestDriver } from "../../lib/vitest/hooks.mjs";
|
|
8
|
+
|
|
9
|
+
describe("AI Test", () => {
|
|
10
|
+
it("should use ai to search for testdriver on Google", async (context) => {
|
|
11
|
+
const testdriver = TestDriver(context, { newSandbox: true });
|
|
12
|
+
|
|
13
|
+
// provision.chrome() automatically calls ready() and starts dashcam
|
|
14
|
+
await testdriver.provision.chrome({
|
|
15
|
+
url: 'https://duckduckgo.com',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Use ai to search for testdriver
|
|
19
|
+
let aiRes = await testdriver.ai("click on the empty search box, type 'testdriver', and hit enter.");
|
|
20
|
+
|
|
21
|
+
console.log("AI response:", aiRes);
|
|
22
|
+
|
|
23
|
+
// Assert the search results are displayed
|
|
24
|
+
const result = await testdriver.assert(
|
|
25
|
+
"search results for testdriver are visible",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(result).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - Windows Installer Example (Vitest)
|
|
3
|
+
*
|
|
4
|
+
* This example demonstrates how to download and install a Windows application
|
|
5
|
+
* using PowerShell commands, then launch and interact with it.
|
|
6
|
+
*
|
|
7
|
+
* Based on the v6 GitButler provisioning workflow.
|
|
8
|
+
*
|
|
9
|
+
* Run: TD_OS=windows npx vitest run examples/windows-installer.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { TestDriver } from "../../lib/vitest/hooks.mjs";
|
|
14
|
+
|
|
15
|
+
const isLinux = (process.env.TD_OS || "linux") === "linux";
|
|
16
|
+
|
|
17
|
+
describe("Windows App Installation", () => {
|
|
18
|
+
|
|
19
|
+
it.skipIf(isLinux)("should download, install, and launch GitButler on Windows", async (context) => {
|
|
20
|
+
// Alternative approach using provision.installer helper
|
|
21
|
+
const testdriver = TestDriver(context, {
|
|
22
|
+
newSandbox: true,
|
|
23
|
+
os: 'windows'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Download the MSI installer
|
|
27
|
+
const installerPath = await testdriver.provision.installer({
|
|
28
|
+
url: 'https://app.gitbutler.com/downloads/release/windows/x86_64/msi',
|
|
29
|
+
launch: false, // Don't auto-launch, we'll install manually
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// The installer should be an .msi or .exe file
|
|
33
|
+
expect(installerPath).toMatch(/\.(msi|exe)$/i);
|
|
34
|
+
|
|
35
|
+
// Install the MSI silently (check which type it is)
|
|
36
|
+
if (installerPath.toLowerCase().endsWith('.msi')) {
|
|
37
|
+
await testdriver.exec('pwsh',
|
|
38
|
+
`Start-Process msiexec.exe -ArgumentList "/i \`"${installerPath}\`" /qn /norestart" -Wait`,
|
|
39
|
+
120000
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
await testdriver.exec('pwsh',
|
|
43
|
+
`Start-Process "${installerPath}" -ArgumentList "/S" -Wait`,
|
|
44
|
+
120000
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Verify installation by checking if executable exists
|
|
49
|
+
const verifyScript = `
|
|
50
|
+
$exePath = "C:\\Program Files\\GitButler\\gitbutler-tauri.exe"
|
|
51
|
+
if (Test-Path $exePath) {
|
|
52
|
+
Write-Host "GitButler installed successfully at $exePath"
|
|
53
|
+
} else {
|
|
54
|
+
Write-Error "GitButler not found"
|
|
55
|
+
exit 1
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
await testdriver.exec('pwsh', verifyScript, 5000);
|
|
60
|
+
});
|
|
61
|
+
});
|