testdriverai 7.2.9 → 7.2.11

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 +87 -126
  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/dashcam.mdx → dashcam.mdx} +0 -78
  26. package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
  27. package/docs/v7/{api/elements.mdx → elements.mdx} +1 -54
  28. package/docs/v7/enterprise.mdx +116 -0
  29. package/docs/v7/examples.mdx +5 -0
  30. package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
  31. package/docs/v7/{api/find.mdx → find.mdx} +17 -21
  32. package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
  33. package/docs/v7/generating-tests.mdx +36 -0
  34. package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
  35. package/docs/v7/locating-elements.mdx +71 -0
  36. package/docs/v7/making-assertions.mdx +32 -0
  37. package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
  38. package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
  39. package/docs/v7/performing-actions.mdx +51 -0
  40. package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
  41. package/docs/v7/quickstart.mdx +162 -0
  42. package/docs/v7/reusable-code.mdx +240 -0
  43. package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
  44. package/docs/v7/running-tests.mdx +181 -0
  45. package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
  46. package/docs/v7/secrets.mdx +115 -0
  47. package/docs/v7/self-hosted.mdx +66 -0
  48. package/docs/v7/{api/type.mdx → type.mdx} +3 -3
  49. package/docs/v7/variables.mdx +111 -0
  50. package/docs/v7/waiting-for-elements.mdx +66 -0
  51. package/docs/v7/what-is-testdriver.mdx +54 -0
  52. package/lib/vitest/hooks.mjs +80 -68
  53. package/package.json +1 -1
  54. package/sdk.d.ts +22 -9
  55. package/sdk.js +177 -44
  56. package/test/manual/reconnect-provision.test.mjs +49 -0
  57. package/test/manual/reconnect-signin.test.mjs +41 -0
  58. package/test/testdriver/ai.test.mjs +30 -0
  59. package/test/testdriver/setup/testHelpers.mjs +0 -1
  60. package/test/testdriver/windows-installer.test.mjs +61 -0
  61. package/tests/table-sort-enrollments.test.mjs +72 -0
  62. package/tests/table-sort-experiment.test.mjs +42 -0
  63. package/tests/table-sort-setup.test.mjs +59 -0
  64. package/vitest.config.mjs +1 -0
  65. package/docs/v7/api/assertions.mdx +0 -403
  66. package/docs/v7/api/sandbox.mdx +0 -404
  67. package/docs/v7/features/ai-native.mdx +0 -413
  68. package/docs/v7/features/application-logs.mdx +0 -353
  69. package/docs/v7/features/browser-logs.mdx +0 -414
  70. package/docs/v7/features/cache-management.mdx +0 -402
  71. package/docs/v7/features/continuous-testing.mdx +0 -346
  72. package/docs/v7/features/data-driven-testing.mdx +0 -441
  73. package/docs/v7/features/easy-to-write.mdx +0 -280
  74. package/docs/v7/features/enterprise.mdx +0 -656
  75. package/docs/v7/features/fast.mdx +0 -406
  76. package/docs/v7/features/managed-sandboxes.mdx +0 -384
  77. package/docs/v7/features/network-monitoring.mdx +0 -568
  78. package/docs/v7/features/parallel-execution.mdx +0 -381
  79. package/docs/v7/features/powerful.mdx +0 -531
  80. package/docs/v7/features/sandbox-customization.mdx +0 -229
  81. package/docs/v7/features/stable.mdx +0 -473
  82. package/docs/v7/features/system-performance.mdx +0 -616
  83. package/docs/v7/features/test-analytics.mdx +0 -373
  84. package/docs/v7/features/test-cases.mdx +0 -393
  85. package/docs/v7/features/test-replays.mdx +0 -408
  86. package/docs/v7/features/test-reports.mdx +0 -308
  87. package/docs/v7/getting-started/debugging-tests.mdx +0 -382
  88. package/docs/v7/getting-started/quickstart.mdx +0 -90
  89. package/docs/v7/getting-started/running-tests.mdx +0 -173
  90. package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
  91. package/docs/v7/getting-started/writing-tests.mdx +0 -534
  92. package/docs/v7/overview/what-is-testdriver.mdx +0 -386
  93. package/docs/v7/presets/chrome-extension.mdx +0 -248
  94. package/docs/v7/presets/chrome.mdx +0 -300
  95. package/docs/v7/presets/electron.mdx +0 -460
  96. package/docs/v7/presets/vscode.mdx +0 -417
  97. package/docs/v7/presets/webapp.mdx +0 -393
  98. package/vitest.config.js +0 -18
  99. /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
  100. /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
  101. /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
  102. /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
  103. /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
  104. /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
  105. /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
  106. /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
  107. /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
  108. /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
  109. /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
  110. /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
  111. /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
  112. /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
  113. /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
  114. /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
  115. /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
  116. /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
  117. /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
  118. /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
  119. /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
  120. /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
  121. /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
  122. /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
  123. /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
  124. /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
@@ -154,9 +154,16 @@ export function TestDriver(context, options = {}) {
154
154
  throw new Error('TestDriver() requires Vitest context. Pass the context parameter from your test function: test("name", async (context) => { ... })');
155
155
  }
156
156
 
157
- // Return existing instance if already created for this test
157
+ // Return existing instance if already created for this test AND it's still connected
158
+ // On retry, the previous instance will be disconnected, so we need to create a new one
158
159
  if (testDriverInstances.has(context.task)) {
159
- return testDriverInstances.get(context.task);
160
+ const existingInstance = testDriverInstances.get(context.task);
161
+ if (existingInstance.connected) {
162
+ return existingInstance;
163
+ }
164
+ // Instance exists but is disconnected (likely a retry) - remove it and create fresh
165
+ testDriverInstances.delete(context.task);
166
+ lifecycleHandlers.delete(context.task);
160
167
  }
161
168
 
162
169
  // Get global plugin options if available
@@ -194,7 +201,6 @@ export function TestDriver(context, options = {}) {
194
201
 
195
202
  if (autoConnect) {
196
203
  testdriver.__connectionPromise = (async () => {
197
- console.log('[testdriver] Connecting to sandbox...');
198
204
  if (debugConsoleSpy) {
199
205
  console.log('[DEBUG] Before auth - sandbox.instanceSocketConnected:', testdriver.sandbox?.instanceSocketConnected);
200
206
  }
@@ -231,74 +237,80 @@ export function TestDriver(context, options = {}) {
231
237
  }
232
238
 
233
239
  // Register cleanup handler with dashcam.stop()
234
- if (!lifecycleHandlers.has(context.task)) {
235
- const cleanup = async () => {
236
- try {
237
- // Stop dashcam if it was started - with timeout to prevent hanging
238
- if (testdriver._dashcam && testdriver._dashcam.recording) {
239
- try {
240
- const dashcamUrl = await testdriver.dashcam.stop();
241
- console.log('');
242
- console.log('🎥' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
243
- console.log('');
244
-
245
- // Set test metadata directly on the Vitest task context
246
- // This is the proper way to pass data from test to reporter
247
- const platform = testdriver.os || 'linux';
248
- const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
249
- const projectRoot = process.cwd();
250
- const testFile = absolutePath !== 'unknown'
251
- ? path.relative(projectRoot, absolutePath)
252
- : absolutePath;
253
-
254
- // Set metadata on the task for the reporter to read
255
- context.task.meta.dashcamUrl = dashcamUrl || null;
256
- context.task.meta.platform = platform;
257
- context.task.meta.testFile = testFile;
258
- context.task.meta.testOrder = 0;
259
- context.task.meta.sessionId = testdriver.getSessionId();
260
-
261
- // Also register in memory if plugin is available (for cross-process scenarios)
262
- if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
263
- globalThis.__testdriverPlugin.registerDashcamUrl(context.task.id, dashcamUrl, platform);
264
- }
265
- } catch (error) {
266
- // Log more detailed error information for debugging
267
- console.error('❌ Failed to stop dashcam:', error.name || error.constructor?.name || 'Error');
268
- if (error.message) console.error(' Message:', error.message);
269
- // NotFoundError during cleanup is expected if sandbox already terminated
270
- if (error.name === 'NotFoundError' || error.responseData?.error === 'NotFoundError') {
271
- console.log(' ℹ️ Sandbox session already terminated - dashcam stop skipped');
272
- }
273
- // Mark as not recording to prevent retries
274
- if (testdriver._dashcam) {
275
- testdriver._dashcam.recording = false;
276
- }
240
+ // We always register a new cleanup handler because on retry we need to clean up the new instance
241
+ const cleanup = async () => {
242
+ // Get the current instance from the WeakMap (not from closure)
243
+ // This ensures we clean up the correct instance on retries
244
+ const currentInstance = testDriverInstances.get(context.task);
245
+ if (!currentInstance) {
246
+ return; // Already cleaned up
247
+ }
248
+
249
+ try {
250
+ // Stop dashcam if it was started - with timeout to prevent hanging
251
+ if (currentInstance._dashcam && currentInstance._dashcam.recording) {
252
+ try {
253
+ const dashcamUrl = await currentInstance.dashcam.stop();
254
+ console.log('');
255
+ console.log('🎥' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
256
+ console.log('');
257
+
258
+ // Set test metadata directly on the Vitest task context
259
+ // This is the proper way to pass data from test to reporter
260
+ const platform = currentInstance.os || 'linux';
261
+ const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
262
+ const projectRoot = process.cwd();
263
+ const testFile = absolutePath !== 'unknown'
264
+ ? path.relative(projectRoot, absolutePath)
265
+ : absolutePath;
266
+
267
+ // Set metadata on the task for the reporter to read
268
+ context.task.meta.dashcamUrl = dashcamUrl || null;
269
+ context.task.meta.platform = platform;
270
+ context.task.meta.testFile = testFile;
271
+ context.task.meta.testOrder = 0;
272
+ context.task.meta.sessionId = currentInstance.getSessionId();
273
+
274
+ // Also register in memory if plugin is available (for cross-process scenarios)
275
+ if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
276
+ globalThis.__testdriverPlugin.registerDashcamUrl(context.task.id, dashcamUrl, platform);
277
+ }
278
+ } catch (error) {
279
+ // Log more detailed error information for debugging
280
+ console.error('❌ Failed to stop dashcam:', error.name || error.constructor?.name || 'Error');
281
+ if (error.message) console.error(' Message:', error.message);
282
+ // NotFoundError during cleanup is expected if sandbox already terminated
283
+ if (error.name === 'NotFoundError' || error.responseData?.error === 'NotFoundError') {
284
+ console.log(' ℹ️ Sandbox session already terminated - dashcam stop skipped');
285
+ }
286
+ // Mark as not recording to prevent retries
287
+ if (currentInstance._dashcam) {
288
+ currentInstance._dashcam.recording = false;
277
289
  }
278
290
  }
279
-
280
- // Clean up console spies
281
- cleanupConsoleSpy(testdriver);
282
-
283
- // Wait for connection to finish if it was initiated
284
- if (testdriver.__connectionPromise) {
285
- await testdriver.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
286
- }
287
-
288
- // Disconnect with timeout
289
- await Promise.race([
290
- testdriver.disconnect(),
291
- new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
292
- ]);
293
- } catch (error) {
294
- console.error('Error disconnecting client:', error);
295
291
  }
296
- };
297
- lifecycleHandlers.set(context.task, cleanup);
298
-
299
- // Vitest will call this automatically after the test
300
- context.onTestFinished?.(cleanup);
301
- }
292
+
293
+ // Clean up console spies
294
+ cleanupConsoleSpy(currentInstance);
295
+
296
+ // Wait for connection to finish if it was initiated
297
+ if (currentInstance.__connectionPromise) {
298
+ await currentInstance.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
299
+ }
300
+
301
+ // Disconnect with timeout
302
+ await Promise.race([
303
+ currentInstance.disconnect(),
304
+ new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
305
+ ]);
306
+ } catch (error) {
307
+ console.error('Error disconnecting client:', error);
308
+ }
309
+ };
310
+ lifecycleHandlers.set(context.task, cleanup);
311
+
312
+ // Vitest will call this automatically after the test (each retry attempt)
313
+ context.onTestFinished?.(cleanup);
302
314
 
303
315
  return testdriver;
304
316
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.2.9",
3
+ "version": "7.2.11",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "exports": {
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
+ });