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.
Files changed (124) hide show
  1. package/.github/workflows/testdriver.yml +127 -0
  2. package/.testdriver/last-sandbox +7 -0
  3. package/agent/events.js +1 -0
  4. package/agent/index.js +71 -54
  5. package/agent/lib/sandbox.js +11 -1
  6. package/agents.md +393 -0
  7. package/debug/01-table-initial.png +0 -0
  8. package/debug/02-after-ai-explore.png +0 -0
  9. package/debug/02-after-scroll.png +0 -0
  10. package/docs/docs.json +93 -125
  11. package/docs/v7/_drafts/caching.mdx +2 -2
  12. package/docs/v7/{getting-started → _drafts}/installation.mdx +0 -66
  13. package/docs/v7/{features/coverage.mdx → _drafts/powerful.mdx} +1 -90
  14. package/docs/v7/{features → _drafts}/scalable.mdx +126 -4
  15. package/docs/v7/_drafts/screenshot.mdx +155 -0
  16. package/docs/v7/_drafts/writing-tests.mdx +25 -0
  17. package/docs/v7/{api/act.mdx → ai.mdx} +27 -27
  18. package/docs/v7/{api/assert.mdx → assert.mdx} +3 -3
  19. package/docs/v7/aws-setup.mdx +338 -0
  20. package/docs/v7/caching.mdx +128 -0
  21. package/docs/v7/ci-cd.mdx +605 -0
  22. package/docs/v7/{api/click.mdx → click.mdx} +4 -4
  23. package/docs/v7/cloud.mdx +120 -0
  24. package/docs/v7/customizing-devices.mdx +129 -0
  25. package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
  26. package/docs/v7/enterprise.mdx +135 -0
  27. package/docs/v7/examples.mdx +5 -0
  28. package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
  29. package/docs/v7/{api/find.mdx → find.mdx} +17 -21
  30. package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
  31. package/docs/v7/generating-tests.mdx +32 -0
  32. package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
  33. package/docs/v7/locating-elements.mdx +71 -0
  34. package/docs/v7/making-assertions.mdx +32 -0
  35. package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
  36. package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
  37. package/docs/v7/performing-actions.mdx +51 -0
  38. package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
  39. package/docs/v7/quickstart.mdx +162 -0
  40. package/docs/v7/reusable-code.mdx +240 -0
  41. package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
  42. package/docs/v7/running-tests.mdx +181 -0
  43. package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
  44. package/docs/v7/secrets.mdx +115 -0
  45. package/docs/v7/self-hosted.mdx +66 -0
  46. package/docs/v7/{api/type.mdx → type.mdx} +3 -3
  47. package/docs/v7/variables.mdx +111 -0
  48. package/docs/v7/waiting-for-elements.mdx +66 -0
  49. package/docs/v7/what-is-testdriver.mdx +54 -0
  50. package/lib/vitest/hooks.mjs +80 -68
  51. package/package.json +1 -1
  52. package/sdk.d.ts +22 -9
  53. package/sdk.js +177 -44
  54. package/test/manual/reconnect-provision.test.mjs +49 -0
  55. package/test/manual/reconnect-signin.test.mjs +41 -0
  56. package/test/testdriver/ai.test.mjs +30 -0
  57. package/test/testdriver/setup/testHelpers.mjs +0 -1
  58. package/test/testdriver/windows-installer.test.mjs +61 -0
  59. package/tests/table-sort-enrollments.test.mjs +72 -0
  60. package/tests/table-sort-experiment.test.mjs +42 -0
  61. package/tests/table-sort-setup.test.mjs +59 -0
  62. package/vitest.config.mjs +1 -0
  63. package/docs/v7/api/assertions.mdx +0 -403
  64. package/docs/v7/features/ai-native.mdx +0 -413
  65. package/docs/v7/features/application-logs.mdx +0 -353
  66. package/docs/v7/features/browser-logs.mdx +0 -414
  67. package/docs/v7/features/cache-management.mdx +0 -402
  68. package/docs/v7/features/continuous-testing.mdx +0 -346
  69. package/docs/v7/features/data-driven-testing.mdx +0 -441
  70. package/docs/v7/features/easy-to-write.mdx +0 -280
  71. package/docs/v7/features/enterprise.mdx +0 -656
  72. package/docs/v7/features/fast.mdx +0 -406
  73. package/docs/v7/features/managed-sandboxes.mdx +0 -384
  74. package/docs/v7/features/network-monitoring.mdx +0 -568
  75. package/docs/v7/features/parallel-execution.mdx +0 -381
  76. package/docs/v7/features/powerful.mdx +0 -531
  77. package/docs/v7/features/sandbox-customization.mdx +0 -229
  78. package/docs/v7/features/stable.mdx +0 -473
  79. package/docs/v7/features/system-performance.mdx +0 -616
  80. package/docs/v7/features/test-analytics.mdx +0 -373
  81. package/docs/v7/features/test-cases.mdx +0 -393
  82. package/docs/v7/features/test-replays.mdx +0 -408
  83. package/docs/v7/features/test-reports.mdx +0 -308
  84. package/docs/v7/getting-started/debugging-tests.mdx +0 -382
  85. package/docs/v7/getting-started/quickstart.mdx +0 -90
  86. package/docs/v7/getting-started/running-tests.mdx +0 -173
  87. package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
  88. package/docs/v7/getting-started/writing-tests.mdx +0 -534
  89. package/docs/v7/overview/what-is-testdriver.mdx +0 -386
  90. package/docs/v7/presets/chrome-extension.mdx +0 -248
  91. package/docs/v7/presets/chrome.mdx +0 -300
  92. package/docs/v7/presets/electron.mdx +0 -460
  93. package/docs/v7/presets/vscode.mdx +0 -417
  94. package/docs/v7/presets/webapp.mdx +0 -393
  95. package/vitest.config.js +0 -18
  96. /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
  97. /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
  98. /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
  99. /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
  100. /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
  101. /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
  102. /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
  103. /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
  104. /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
  105. /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
  106. /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
  107. /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
  108. /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
  109. /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
  110. /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
  111. /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
  112. /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
  113. /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
  114. /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
  115. /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
  116. /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
  117. /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
  118. /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
  119. /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
  120. /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
  121. /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
  122. /package/docs/v7/{api/dashcam.mdx → dashcam.mdx} +0 -0
  123. /package/docs/v7/{api/elements.mdx → elements.mdx} +0 -0
  124. /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 cacheThreshold - Cache threshold for this specific find (overrides global setting)
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 until element is found
723
- * let element;
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 cacheKey
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', // uBlock Origin
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 and recording, add web logs for this domain
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}, "${url}"`,
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} "${url}" >/dev/null 2>&1 &`,
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 URL to load
1719
- try {
1720
- const urlObj = new URL(url);
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
- if (result.found()) {
1727
- break;
1728
- } else {
1729
- await new Promise(resolve => setTimeout(resolve, 1000));
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
- console.log(`[provision.installer] Downloaded to ${filePath}`);
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 ext = detectedFilename.split('.').pop()?.toLowerCase();
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', '"${filePath}"', '/quiet', '/norestart' -Wait`;
2009
+ installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
1912
2010
  } else if (ext === 'exe') {
1913
- installCommand = `Start-Process "${filePath}" -ArgumentList '/S' -Wait`;
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 "${filePath}" && sudo apt-get install -f -y`;
2015
+ installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
1918
2016
  } else if (ext === 'rpm') {
1919
- installCommand = `sudo rpm -i "${filePath}"`;
2017
+ installCommand = `sudo rpm -i "${actualFilePath}"`;
1920
2018
  } else if (ext === 'appimage') {
1921
- installCommand = `chmod +x "${filePath}"`;
2019
+ installCommand = `chmod +x "${actualFilePath}"`;
1922
2020
  } else if (ext === 'sh') {
1923
- installCommand = `chmod +x "${filePath}" && "${filePath}"`;
2021
+ installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
1924
2022
  }
1925
2023
  } else if (this.os === 'darwin') {
1926
2024
  if (ext === 'dmg') {
1927
- installCommand = `hdiutil attach "${filePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
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 "${filePath}" -target /`;
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 filePath;
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
+ });
@@ -5,7 +5,6 @@
5
5
 
6
6
  import crypto from "crypto";
7
7
  import { config } from "dotenv";
8
- import fs from "fs";
9
8
  import path, { dirname } from "path";
10
9
  import { fileURLToPath } from "url";
11
10
  import TestDriver from "../../../sdk.js";
@@ -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
+ });