testdriverai 7.2.3 → 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 (142) hide show
  1. package/.github/workflows/publish.yaml +15 -7
  2. package/.github/workflows/testdriver.yml +163 -0
  3. package/.testdriver/last-sandbox +7 -0
  4. package/agent/events.js +1 -0
  5. package/agent/index.js +99 -163
  6. package/agent/lib/sandbox.js +11 -1
  7. package/agents.md +393 -0
  8. package/bin/testdriverai.js +8 -0
  9. package/debug/01-table-initial.png +0 -0
  10. package/debug/02-after-ai-explore.png +0 -0
  11. package/debug/02-after-scroll.png +0 -0
  12. package/debugger/index.html +37 -0
  13. package/docs/docs.json +93 -125
  14. package/docs/v7/_drafts/architecture.mdx +1 -26
  15. package/docs/v7/_drafts/caching.mdx +2 -2
  16. package/docs/v7/{getting-started → _drafts}/installation.mdx +0 -66
  17. package/docs/v7/{features/coverage.mdx → _drafts/powerful.mdx} +1 -90
  18. package/docs/v7/_drafts/quick-start-test-recording.mdx +0 -1
  19. package/docs/v7/{features → _drafts}/scalable.mdx +126 -4
  20. package/docs/v7/_drafts/screenshot.mdx +155 -0
  21. package/docs/v7/_drafts/test-recording.mdx +0 -6
  22. package/docs/v7/_drafts/writing-tests.mdx +25 -0
  23. package/docs/v7/{api/act.mdx → ai.mdx} +28 -27
  24. package/docs/v7/{api/assert.mdx → assert.mdx} +3 -3
  25. package/docs/v7/aws-setup.mdx +338 -0
  26. package/docs/v7/caching.mdx +128 -0
  27. package/docs/v7/ci-cd.mdx +605 -0
  28. package/docs/v7/{api/click.mdx → click.mdx} +4 -4
  29. package/docs/v7/cloud.mdx +120 -0
  30. package/docs/v7/customizing-devices.mdx +129 -0
  31. package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
  32. package/docs/v7/enterprise.mdx +135 -0
  33. package/docs/v7/examples.mdx +5 -0
  34. package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
  35. package/docs/v7/{api/find.mdx → find.mdx} +17 -21
  36. package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
  37. package/docs/v7/generating-tests.mdx +32 -0
  38. package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
  39. package/docs/v7/locating-elements.mdx +71 -0
  40. package/docs/v7/making-assertions.mdx +32 -0
  41. package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
  42. package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
  43. package/docs/v7/performing-actions.mdx +51 -0
  44. package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
  45. package/docs/v7/quickstart.mdx +162 -0
  46. package/docs/v7/reusable-code.mdx +240 -0
  47. package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
  48. package/docs/v7/running-tests.mdx +181 -0
  49. package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
  50. package/docs/v7/secrets.mdx +115 -0
  51. package/docs/v7/self-hosted.mdx +66 -0
  52. package/docs/v7/{api/type.mdx → type.mdx} +3 -3
  53. package/docs/v7/variables.mdx +111 -0
  54. package/docs/v7/waiting-for-elements.mdx +66 -0
  55. package/docs/v7/what-is-testdriver.mdx +54 -0
  56. package/interfaces/cli/commands/init.js +33 -19
  57. package/interfaces/cli/lib/base.js +24 -0
  58. package/interfaces/cli.js +8 -1
  59. package/interfaces/logger.js +8 -3
  60. package/interfaces/vitest-plugin.mjs +16 -71
  61. package/lib/sentry.js +343 -0
  62. package/lib/vitest/hooks.mjs +81 -81
  63. package/package.json +4 -3
  64. package/sdk-log-formatter.js +41 -0
  65. package/sdk.d.ts +22 -9
  66. package/sdk.js +344 -100
  67. package/test/manual/reconnect-provision.test.mjs +49 -0
  68. package/test/manual/reconnect-signin.test.mjs +41 -0
  69. package/test/testdriver/act.test.mjs +30 -0
  70. package/test/testdriver/ai.test.mjs +30 -0
  71. package/test/testdriver/assert.test.mjs +1 -1
  72. package/test/testdriver/hover-text.test.mjs +1 -1
  73. package/test/testdriver/setup/testHelpers.mjs +8 -119
  74. package/test/testdriver/windows-installer.test.mjs +61 -0
  75. package/tests/example.test.js +33 -0
  76. package/tests/login.js +28 -0
  77. package/tests/table-sort-enrollments.test.mjs +72 -0
  78. package/tests/table-sort-experiment.test.mjs +42 -0
  79. package/tests/table-sort-setup.test.mjs +59 -0
  80. package/vitest.config.mjs +3 -1
  81. package/agent/lib/cache.js +0 -142
  82. package/docs/v7/api/assertions.mdx +0 -403
  83. package/docs/v7/features/ai-native.mdx +0 -413
  84. package/docs/v7/features/application-logs.mdx +0 -353
  85. package/docs/v7/features/browser-logs.mdx +0 -414
  86. package/docs/v7/features/cache-management.mdx +0 -402
  87. package/docs/v7/features/continuous-testing.mdx +0 -346
  88. package/docs/v7/features/data-driven-testing.mdx +0 -441
  89. package/docs/v7/features/easy-to-write.mdx +0 -280
  90. package/docs/v7/features/enterprise.mdx +0 -656
  91. package/docs/v7/features/fast.mdx +0 -406
  92. package/docs/v7/features/managed-sandboxes.mdx +0 -384
  93. package/docs/v7/features/network-monitoring.mdx +0 -568
  94. package/docs/v7/features/parallel-execution.mdx +0 -381
  95. package/docs/v7/features/powerful.mdx +0 -531
  96. package/docs/v7/features/sandbox-customization.mdx +0 -229
  97. package/docs/v7/features/stable.mdx +0 -473
  98. package/docs/v7/features/system-performance.mdx +0 -616
  99. package/docs/v7/features/test-analytics.mdx +0 -373
  100. package/docs/v7/features/test-cases.mdx +0 -393
  101. package/docs/v7/features/test-replays.mdx +0 -408
  102. package/docs/v7/features/test-reports.mdx +0 -308
  103. package/docs/v7/getting-started/debugging-tests.mdx +0 -382
  104. package/docs/v7/getting-started/quickstart.mdx +0 -90
  105. package/docs/v7/getting-started/running-tests.mdx +0 -173
  106. package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
  107. package/docs/v7/getting-started/writing-tests.mdx +0 -534
  108. package/docs/v7/overview/what-is-testdriver.mdx +0 -386
  109. package/docs/v7/presets/chrome-extension.mdx +0 -248
  110. package/docs/v7/presets/chrome.mdx +0 -300
  111. package/docs/v7/presets/electron.mdx +0 -460
  112. package/docs/v7/presets/vscode.mdx +0 -417
  113. package/docs/v7/presets/webapp.mdx +0 -393
  114. /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
  115. /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
  116. /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
  117. /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
  118. /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
  119. /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
  120. /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
  121. /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
  122. /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
  123. /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
  124. /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
  125. /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
  126. /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
  127. /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
  128. /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
  129. /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
  130. /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
  131. /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
  132. /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
  133. /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
  134. /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
  135. /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
  136. /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
  137. /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
  138. /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
  139. /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
  140. /package/docs/v7/{api/dashcam.mdx → dashcam.mdx} +0 -0
  141. /package/docs/v7/{api/elements.mdx → elements.mdx} +0 -0
  142. /package/docs/v7/{api/sandbox.mdx → sandbox.mdx} +0 -0
package/sdk.js CHANGED
@@ -263,6 +263,48 @@ class ElementNotFoundError extends Error {
263
263
  }
264
264
  }
265
265
 
266
+ /**
267
+ * Custom error class for act() failures
268
+ * Includes task execution details and retry information
269
+ */
270
+ class ActError extends Error {
271
+ /**
272
+ * @param {string} message - Error message
273
+ * @param {Object} details - Additional details about the failure
274
+ * @param {string} details.task - The task that was attempted
275
+ * @param {number} details.tries - Number of check attempts made
276
+ * @param {number} details.maxTries - Maximum tries that were allowed
277
+ * @param {number} details.duration - Total execution time in milliseconds
278
+ * @param {Error} [details.cause] - The underlying error that caused the failure
279
+ */
280
+ constructor(message, details = {}) {
281
+ super(message);
282
+ this.name = "ActError";
283
+ this.task = details.task;
284
+ this.tries = details.tries;
285
+ this.maxTries = details.maxTries;
286
+ this.duration = details.duration;
287
+ this.cause = details.cause;
288
+ this.timestamp = new Date().toISOString();
289
+
290
+ // Capture stack trace
291
+ if (Error.captureStackTrace) {
292
+ Error.captureStackTrace(this, ActError);
293
+ }
294
+
295
+ // Enhance error message with execution details
296
+ this.message += `\n\n=== Act Execution Details ===`;
297
+ this.message += `\nTask: "${this.task}"`;
298
+ this.message += `\nTries: ${this.tries}/${this.maxTries}`;
299
+ this.message += `\nDuration: ${this.duration}ms`;
300
+ this.message += `\nTimestamp: ${this.timestamp}`;
301
+
302
+ if (this.cause) {
303
+ this.message += `\nUnderlying error: ${this.cause.message}`;
304
+ }
305
+ }
306
+ }
307
+
266
308
  /**
267
309
  * Element class representing a located or to-be-located element
268
310
  */
@@ -330,10 +372,17 @@ class Element {
330
372
  /**
331
373
  * Find the element on screen
332
374
  * @param {string} [newDescription] - Optional new description to search for
333
- * @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)
334
377
  * @returns {Promise<Element>} This element instance
335
378
  */
336
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
+
337
386
  const description = newDescription || this.description;
338
387
  if (newDescription) {
339
388
  this.description = newDescription;
@@ -484,6 +533,61 @@ class Element {
484
533
  return this;
485
534
  }
486
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
+
487
591
  /**
488
592
  * Sanitize response by removing large base64 data to prevent memory leaks
489
593
  * @private
@@ -1141,6 +1245,9 @@ class TestDriverSDK {
1141
1245
  this.sandboxAmi = options.sandboxAmi || null;
1142
1246
  this.sandboxInstance = options.sandboxInstance || null;
1143
1247
 
1248
+ // Store reconnect preference from options
1249
+ this.reconnect = options.reconnect !== undefined ? options.reconnect : false;
1250
+
1144
1251
  // Cache threshold configuration
1145
1252
  // threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
1146
1253
  // By default, cache is DISABLED (threshold = -1) to avoid unnecessary AI costs
@@ -1418,22 +1525,19 @@ class TestDriverSDK {
1418
1525
  * @param {Object} options - Chrome extension launch options
1419
1526
  * @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
1420
1527
  * @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
1421
- * @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
1422
1528
  * @param {boolean} [options.maximized=true] - Start maximized
1423
1529
  * @returns {Promise<void>}
1424
1530
  * @example
1425
1531
  * // Load extension from local path
1426
1532
  * await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
1427
1533
  * await testdriver.provision.chromeExtension({
1428
- * extensionPath: '/tmp/extension',
1429
- * url: 'https://example.com'
1534
+ * extensionPath: '/tmp/extension'
1430
1535
  * });
1431
1536
  *
1432
1537
  * @example
1433
1538
  * // Load extension by Chrome Web Store ID
1434
1539
  * await testdriver.provision.chromeExtension({
1435
- * extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm', // uBlock Origin
1436
- * url: 'https://example.com'
1540
+ * extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm' // uBlock Origin
1437
1541
  * });
1438
1542
  */
1439
1543
  chromeExtension: async (options = {}) => {
@@ -1443,7 +1547,6 @@ class TestDriverSDK {
1443
1547
  const {
1444
1548
  extensionPath: providedExtensionPath,
1445
1549
  extensionId,
1446
- url = 'http://testdriver-sandbox.vercel.app/',
1447
1550
  maximized = true,
1448
1551
  } = options;
1449
1552
 
@@ -1561,7 +1664,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1561
1664
  console.log(`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`);
1562
1665
  }
1563
1666
 
1564
- // If dashcam is available and recording, add web logs for this domain
1667
+ // If dashcam is available, set up file logging
1565
1668
  if (this._dashcam) {
1566
1669
  // Create the log file on the remote machine
1567
1670
  const logPath = this.os === "windows"
@@ -1573,11 +1676,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1573
1676
  : `touch ${logPath}`;
1574
1677
 
1575
1678
  await this.exec(shell, createLogCmd, 10000, true);
1576
-
1577
- const urlObj = new URL(url);
1578
- const domain = urlObj.hostname;
1579
- const pattern = `*${domain}*`;
1580
- await this._dashcam.addWebLog(pattern, 'Web Logs');
1581
1679
  await this._dashcam.addFileLog(logPath, "TestDriver Log");
1582
1680
  }
1583
1681
 
@@ -1653,19 +1751,19 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1653
1751
  chromeArgs.push(`--load-extension=${extensionPath}`);
1654
1752
  }
1655
1753
 
1656
- // Launch Chrome
1754
+ // Launch Chrome (opens to New Tab by default)
1657
1755
  if (this.os === 'windows') {
1658
1756
  const argsString = chromeArgs.map(arg => `"${arg}"`).join(', ');
1659
1757
  await this.exec(
1660
1758
  shell,
1661
- `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}`,
1662
1760
  30000
1663
1761
  );
1664
1762
  } else {
1665
1763
  const argsString = chromeArgs.join(' ');
1666
1764
  await this.exec(
1667
1765
  shell,
1668
- `chrome-for-testing ${argsString} "${url}" >/dev/null 2>&1 &`,
1766
+ `chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
1669
1767
  30000
1670
1768
  );
1671
1769
  }
@@ -1673,25 +1771,18 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1673
1771
  // Wait for Chrome to be ready
1674
1772
  await this.focusApplication('Google Chrome');
1675
1773
 
1676
- // Wait for URL to load
1677
- try {
1678
- const urlObj = new URL(url);
1679
- const domain = urlObj.hostname;
1680
-
1681
- for (let attempt = 0; attempt < 30; attempt++) {
1682
- 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');
1683
1777
 
1684
- if (result.found()) {
1685
- break;
1686
- } else {
1687
- await new Promise(resolve => setTimeout(resolve, 1000));
1688
- }
1778
+ if (result.found()) {
1779
+ break;
1780
+ } else {
1781
+ await new Promise(resolve => setTimeout(resolve, 1000));
1689
1782
  }
1690
-
1691
- await this.focusApplication('Google Chrome');
1692
- } catch (e) {
1693
- console.warn(`[provision.chromeExtension] ⚠️ Could not parse URL "${url}":`, e.message);
1694
1783
  }
1784
+
1785
+ await this.focusApplication('Google Chrome');
1695
1786
  },
1696
1787
 
1697
1788
  /**
@@ -1858,33 +1949,82 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1858
1949
  );
1859
1950
  }
1860
1951
 
1861
- 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}`);
1862
2001
 
1863
- // Auto-detect install command based on file extension
1864
- 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();
1865
2005
  let installCommand = null;
1866
2006
 
1867
2007
  if (this.os === 'windows') {
1868
2008
  if (ext === 'msi') {
1869
- installCommand = `Start-Process msiexec -ArgumentList '/i', '"${filePath}"', '/quiet', '/norestart' -Wait`;
2009
+ installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
1870
2010
  } else if (ext === 'exe') {
1871
- installCommand = `Start-Process "${filePath}" -ArgumentList '/S' -Wait`;
2011
+ installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
1872
2012
  }
1873
2013
  } else if (this.os === 'linux') {
1874
2014
  if (ext === 'deb') {
1875
- installCommand = `sudo dpkg -i "${filePath}" && sudo apt-get install -f -y`;
2015
+ installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
1876
2016
  } else if (ext === 'rpm') {
1877
- installCommand = `sudo rpm -i "${filePath}"`;
2017
+ installCommand = `sudo rpm -i "${actualFilePath}"`;
1878
2018
  } else if (ext === 'appimage') {
1879
- installCommand = `chmod +x "${filePath}"`;
2019
+ installCommand = `chmod +x "${actualFilePath}"`;
1880
2020
  } else if (ext === 'sh') {
1881
- installCommand = `chmod +x "${filePath}" && "${filePath}"`;
2021
+ installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
1882
2022
  }
1883
2023
  } else if (this.os === 'darwin') {
1884
2024
  if (ext === 'dmg') {
1885
- 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`;
1886
2026
  } else if (ext === 'pkg') {
1887
- installCommand = `sudo installer -pkg "${filePath}" -target /`;
2027
+ installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
1888
2028
  }
1889
2029
  }
1890
2030
 
@@ -1900,7 +2040,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1900
2040
  await this.focusApplication(appName);
1901
2041
  }
1902
2042
 
1903
- return filePath;
2043
+ return actualFilePath;
1904
2044
  },
1905
2045
 
1906
2046
  /**
@@ -1997,6 +2137,29 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
1997
2137
  : this.newSandbox,
1998
2138
  };
1999
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
+
2000
2163
  // Set agent properties for buildEnv to use
2001
2164
  if (connectOptions.sandboxId) {
2002
2165
  this.agent.sandboxId = connectOptions.sandboxId;
@@ -2026,6 +2189,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2026
2189
  } else {
2027
2190
  this.agent.sandboxOs = this.os;
2028
2191
  }
2192
+ // Use keepAlive from connectOptions if provided
2193
+ if (connectOptions.keepAlive !== undefined) {
2194
+ this.agent.keepAlive = connectOptions.keepAlive;
2195
+ }
2029
2196
 
2030
2197
  // Set redrawThreshold on agent's cliArgs.options
2031
2198
  this.agent.cliArgs.options.redrawThreshold = this.redrawThreshold;
@@ -2108,6 +2275,14 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2108
2275
  return this.session?.get() || null;
2109
2276
  }
2110
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
+
2111
2286
  // ====================================
2112
2287
  // Element Finding API
2113
2288
  // ====================================
@@ -2188,10 +2363,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2188
2363
  const absoluteTimestamp = Date.now();
2189
2364
  const startTime = absoluteTimestamp;
2190
2365
 
2191
- // Log finding all action
2192
2366
  const { events } = require("./agent/events.js");
2193
- const findingMessage = formatter.formatElementsFinding(description);
2194
- this.emitter.emit(events.log.log, findingMessage);
2195
2367
 
2196
2368
  try {
2197
2369
  const screenshot = await this.system.captureScreenBase64();
@@ -2257,16 +2429,16 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2257
2429
  const duration = Date.now() - startTime;
2258
2430
 
2259
2431
  if (response && response.elements && response.elements.length > 0) {
2260
- // Log found elements
2261
- const foundMessage = formatter.formatElementsFound(
2432
+ // Single log at the end - found elements
2433
+ const formattedMessage = formatter.formatFindAllSingleLine(
2262
2434
  description,
2263
2435
  response.elements.length,
2264
2436
  {
2265
- duration: `${duration}ms`,
2437
+ duration: duration,
2266
2438
  cacheHit: response.cached || false,
2267
2439
  },
2268
2440
  );
2269
- this.emitter.emit(events.log.log, foundMessage);
2441
+ this.emitter.emit(events.log.narration, formattedMessage, true);
2270
2442
 
2271
2443
  // Create Element instances for each found element
2272
2444
  const elements = response.elements.map((elementData) => {
@@ -2316,7 +2488,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2316
2488
 
2317
2489
  // Log debug information when elements are found
2318
2490
  if (process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG) {
2319
- const { events } = require("./agent/events.js");
2320
2491
  this.emitter.emit(
2321
2492
  events.log.debug,
2322
2493
  `✓ Found ${elements.length} element(s): "${description}"`,
@@ -2330,6 +2501,19 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2330
2501
 
2331
2502
  return elements;
2332
2503
  } else {
2504
+ const duration = Date.now() - startTime;
2505
+
2506
+ // Single log at the end - no elements found
2507
+ const formattedMessage = formatter.formatFindAllSingleLine(
2508
+ description,
2509
+ 0,
2510
+ {
2511
+ duration: duration,
2512
+ cacheHit: response?.cached || false,
2513
+ },
2514
+ );
2515
+ this.emitter.emit(events.log.narration, formattedMessage, true);
2516
+
2333
2517
  // No elements found - track interaction (fire-and-forget, don't block)
2334
2518
  const sessionId = this.getSessionId();
2335
2519
  if (sessionId && this.sandbox?.send) {
@@ -2354,6 +2538,18 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2354
2538
  return [];
2355
2539
  }
2356
2540
  } catch (error) {
2541
+ const duration = Date.now() - startTime;
2542
+
2543
+ // Single log at the end - error
2544
+ const formattedMessage = formatter.formatFindAllSingleLine(
2545
+ description,
2546
+ 0,
2547
+ {
2548
+ duration: duration,
2549
+ },
2550
+ );
2551
+ this.emitter.emit(events.log.narration, formattedMessage, true);
2552
+
2357
2553
  // Track findAll error interaction (fire-and-forget, don't block)
2358
2554
  const sessionId = this.getSessionId();
2359
2555
  if (sessionId && this.sandbox?.send) {
@@ -2371,8 +2567,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2371
2567
  });
2372
2568
  }
2373
2569
 
2374
- const { events } = require("./agent/events.js");
2375
- this.emitter.emit(events.log.log, `Error in findAll: ${error.message}`);
2376
2570
  return [];
2377
2571
  }
2378
2572
  }
@@ -2771,6 +2965,11 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2771
2965
  // handles forwarding to sandbox. This prevents duplicate output to server.
2772
2966
  this.emitter.on("log:**", (message) => {
2773
2967
  const event = this.emitter.event;
2968
+
2969
+ if (event.includes("markdown")) {
2970
+ return;
2971
+ }
2972
+
2774
2973
  if (event === events.log.debug && !debugMode) return;
2775
2974
  if (this.loggingEnabled && message) {
2776
2975
  const prefixedMessage = this.testContext
@@ -2920,39 +3119,11 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
2920
3119
  const platform = options.platform || this.config.TD_PLATFORM || "linux";
2921
3120
 
2922
3121
  // Auto-detect sandbox ID from the active sandbox if not provided
2923
- const sandboxId = options.sandboxId || this.agent?.sandbox?.id || null;
3122
+ // For E2B (Linux), the instance has sandboxId; for AWS (Windows), it has instanceId
3123
+ const sandboxId = options.sandboxId || this.instance?.sandboxId || this.instance?.instanceId || this.agent?.sandboxId || null;
2924
3124
 
2925
3125
  // Get or create session ID using the agent's newSession method
2926
3126
  let sessionId = this.agent?.sessionInstance?.get() || null;
2927
-
2928
- // If no session exists, create one using the agent's method
2929
- if (!sessionId && this.agent?.newSession) {
2930
- try {
2931
- await this.agent.newSession();
2932
- sessionId = this.agent.sessionInstance.get();
2933
-
2934
- // Save session ID to file for reuse across test runs
2935
- if (sessionId) {
2936
- const sessionFile = path.join(os.homedir(), '.testdriverai-session');
2937
- fs.writeFileSync(sessionFile, sessionId, { encoding: 'utf-8' });
2938
- }
2939
- } catch (error) {
2940
- // Log but don't fail - tests can run without a session
2941
- console.warn('Failed to create session:', error.message);
2942
- }
2943
- }
2944
-
2945
- // If still no session, try reading from file (for reporter/separate processes)
2946
- if (!sessionId) {
2947
- try {
2948
- const sessionFile = path.join(os.homedir(), '.testdriverai-session');
2949
- if (fs.existsSync(sessionFile)) {
2950
- sessionId = fs.readFileSync(sessionFile, 'utf-8').trim();
2951
- }
2952
- } catch (error) {
2953
- // Ignore file read errors
2954
- }
2955
- }
2956
3127
 
2957
3128
  const data = {
2958
3129
  runId: options.runId,
@@ -3076,26 +3247,98 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
3076
3247
  * This is the SDK equivalent of the CLI's exploratory loop
3077
3248
  *
3078
3249
  * @param {string} task - Natural language description of what to do
3079
- * @param {Object} options - Execution options
3080
- * @param {boolean} [options.validateAndLoop=false] - Whether to validate completion and retry if incomplete
3081
- * @returns {Promise<string|void>} Final AI response if validateAndLoop is true
3250
+ * @param {Object} [options] - Execution options
3251
+ * @param {number} [options.tries=7] - Maximum number of check/retry attempts before giving up
3252
+ * @returns {Promise<ActResult>} Result object with success status and details
3253
+ * @throws {ActError} When the task fails after all tries are exhausted
3254
+ *
3255
+ * @typedef {Object} ActResult
3256
+ * @property {boolean} success - Whether the task completed successfully
3257
+ * @property {string} task - The original task that was executed
3258
+ * @property {number} tries - Number of check attempts made
3259
+ * @property {number} maxTries - Maximum tries that were allowed
3260
+ * @property {number} duration - Total execution time in milliseconds
3261
+ * @property {string} [response] - AI's final response if available
3082
3262
  *
3083
3263
  * @example
3084
3264
  * // Simple execution
3085
- * await client.act('Click the submit button');
3265
+ * const result = await client.act('Click the submit button');
3266
+ * console.log(result.success); // true
3086
3267
  *
3087
3268
  * @example
3088
- * // With validation loop
3089
- * const result = await client.act('Fill out the contact form', { validateAndLoop: true });
3090
- * console.log(result); // AI's final assessment
3269
+ * // With custom retry limit
3270
+ * const result = await client.act('Fill out the contact form', { tries: 10 });
3271
+ * console.log(`Completed in ${result.tries} tries`);
3272
+ *
3273
+ * @example
3274
+ * // Handle failures
3275
+ * try {
3276
+ * await client.act('Complete the checkout process', { tries: 3 });
3277
+ * } catch (error) {
3278
+ * console.log(`Failed after ${error.tries} tries: ${error.message}`);
3279
+ * }
3091
3280
  */
3092
- async act(task) {
3281
+ async act(task, options = {}) {
3093
3282
  this._ensureConnected();
3094
3283
 
3095
- this.analytics.track("sdk.act", { task });
3284
+ const { tries = 7 } = options;
3285
+
3286
+ this.analytics.track("sdk.act", { task, tries });
3096
3287
 
3097
- // Use the agent's exploratoryLoop method directly
3098
- return await this.agent.exploratoryLoop(task, false, true, false);
3288
+ const { events } = require("./agent/events.js");
3289
+ const startTime = Date.now();
3290
+
3291
+ // Store original checkLimit and set custom one if provided
3292
+ const originalCheckLimit = this.agent.checkLimit;
3293
+ this.agent.checkLimit = tries;
3294
+
3295
+ // Reset check count for this act() call
3296
+ const originalCheckCount = this.agent.checkCount;
3297
+ this.agent.checkCount = 0;
3298
+
3299
+ // Emit scoped start marker for act()
3300
+ this.emitter.emit(events.log.log, formatter.formatActStart(task));
3301
+
3302
+ try {
3303
+ // Use the agent's exploratoryLoop method directly
3304
+ const response = await this.agent.exploratoryLoop(task, false, true, false);
3305
+
3306
+ const duration = Date.now() - startTime;
3307
+ const triesUsed = this.agent.checkCount;
3308
+
3309
+ this.emitter.emit(events.log.log, formatter.formatActComplete(duration, true));
3310
+
3311
+ // Restore original checkLimit
3312
+ this.agent.checkLimit = originalCheckLimit;
3313
+ this.agent.checkCount = originalCheckCount;
3314
+
3315
+ return {
3316
+ success: true,
3317
+ task,
3318
+ tries: triesUsed,
3319
+ maxTries: tries,
3320
+ duration,
3321
+ response: response || undefined,
3322
+ };
3323
+ } catch (error) {
3324
+ const duration = Date.now() - startTime;
3325
+ const triesUsed = this.agent.checkCount;
3326
+
3327
+ this.emitter.emit(events.log.log, formatter.formatActComplete(duration, false, error.message));
3328
+
3329
+ // Restore original checkLimit
3330
+ this.agent.checkLimit = originalCheckLimit;
3331
+ this.agent.checkCount = originalCheckCount;
3332
+
3333
+ // Create an enhanced error with additional context using ActError class
3334
+ throw new ActError(`Act failed: ${error.message}`, {
3335
+ task,
3336
+ tries: triesUsed,
3337
+ maxTries: tries,
3338
+ duration,
3339
+ cause: error,
3340
+ });
3341
+ }
3099
3342
  }
3100
3343
 
3101
3344
  /**
@@ -3103,15 +3346,16 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
3103
3346
  * Execute a natural language task using AI
3104
3347
  *
3105
3348
  * @param {string} task - Natural language description of what to do
3106
- * @param {Object} options - Execution options
3107
- * @param {boolean} [options.validateAndLoop=false] - Whether to validate completion and retry if incomplete
3108
- * @returns {Promise<string|void>} Final AI response if validateAndLoop is true
3349
+ * @param {Object} [options] - Execution options
3350
+ * @param {number} [options.tries=7] - Maximum number of check/retry attempts
3351
+ * @returns {Promise<ActResult>} Result object with success status and details
3109
3352
  */
3110
- async ai(task) {
3111
- return await this.act(task);
3353
+ async ai(task, options) {
3354
+ return await this.act(task, options);
3112
3355
  }
3113
3356
  }
3114
3357
 
3115
3358
  module.exports = TestDriverSDK;
3116
3359
  module.exports.Element = Element;
3117
3360
  module.exports.ElementNotFoundError = ElementNotFoundError;
3361
+ module.exports.ActError = ActError;