testdriverai 7.3.33 → 7.3.35

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.
@@ -507,15 +507,64 @@ it("should incrementally build test", async (context) => {
507
507
 
508
508
  ```javascript
509
509
  const testdriver = TestDriver(context, {
510
- newSandbox: true, // Create new sandbox (default: true)
511
- preview: "browser", // "browser" | "ide" | "none" (default: "browser")
512
- reconnect: false, // Reconnect to last sandbox (default: false)
513
- keepAlive: 30000, // Keep sandbox alive after test (default: 30000ms / 30 seconds)
514
- os: "linux", // 'linux' | 'windows' (default: 'linux')
515
- resolution: "1366x768", // Sandbox resolution
516
- cache: true, // Enable element caching (default: true)
517
- cacheKey: "my-test", // Cache key for element finding
518
- autoScreenshots: true, // Capture screenshots before/after each command (default: true)
510
+ // === Sandbox & Connection ===
511
+ newSandbox: true, // Force creation of a new sandbox (default: true)
512
+ reconnect: false, // Reconnect to last sandbox (default: false)
513
+ keepAlive: 30000, // Keep sandbox alive after test in ms (default: 30000)
514
+ os: "linux", // 'linux' | 'windows' (default: 'linux')
515
+ resolution: "1366x768", // Sandbox resolution (e.g., '1920x1080')
516
+ ip: "203.0.113.42", // Direct IP for self-hosted sandbox
517
+ sandboxAmi: "ami-1234", // Custom AMI ID (AWS deployments)
518
+ sandboxInstance: "i3.metal", // EC2 instance type (AWS deployments)
519
+
520
+ // === Preview & Debugging ===
521
+ preview: "browser", // "browser" | "ide" | "none" (default: "browser")
522
+ headless: false, // @deprecated - use preview: "none" instead
523
+ debugOnFailure: false, // Keep sandbox alive on test failure for debugging
524
+
525
+ // === Caching ===
526
+ cache: true, // Enable element caching (default: true)
527
+ // Or use advanced caching config:
528
+ // cache: {
529
+ // enabled: true,
530
+ // thresholds: {
531
+ // find: { screen: 0.05, element: 0.8 },
532
+ // assert: 0.05
533
+ // }
534
+ // },
535
+ cacheKey: "my-test", // Cache key for element finding operations
536
+
537
+ // === Recording & Screenshots ===
538
+ dashcam: true, // Enable/disable Dashcam video recording (default: true)
539
+ autoScreenshots: true, // Capture screenshots before/after each command (default: true)
540
+
541
+ // === AI Configuration ===
542
+ ai: { // Global AI sampling configuration
543
+ temperature: 0, // 0 = deterministic, higher = more creative
544
+ top: {
545
+ p: 0.9, // Top-P nucleus sampling (0-1)
546
+ k: 40, // Top-K sampling (1 = most likely, 0 = disabled)
547
+ },
548
+ },
549
+
550
+ // === Screen Change Detection ===
551
+ redraw: true, // Enable redraw detection (default: true)
552
+ // Or use advanced redraw config:
553
+ // redraw: {
554
+ // enabled: true,
555
+ // thresholds: {
556
+ // screen: 0.05, // Pixel diff threshold (0-1), false to disable
557
+ // network: false, // Monitor network activity (default: false)
558
+ // }
559
+ // },
560
+
561
+ // === Logging & Analytics ===
562
+ logging: true, // Enable console logging output (default: true)
563
+ analytics: true, // Enable analytics tracking (default: true)
564
+
565
+ // === Advanced ===
566
+ apiRoot: "https://...", // API endpoint URL (for self-hosted deployments)
567
+ environment: {}, // Additional environment variables for the sandbox
519
568
  });
520
569
  ```
521
570
 
@@ -22,7 +22,7 @@ on:
22
22
 
23
23
  jobs:
24
24
  test:
25
- runs-on: ubuntu-latest
25
+ runs-on: testdriver-32
26
26
 
27
27
  steps:
28
28
  - name: Checkout repository
@@ -39,19 +39,8 @@ jobs:
39
39
  - name: Install dependencies
40
40
  run: npm ci
41
41
 
42
- - name: Debug Environment
43
- run: |
44
- echo "Checking environment variables..."
45
- if [ -n "${{ secrets.TWOCAPTCHA_API_KEY }}" ]; then
46
- echo "TWOCAPTCHA_API_KEY is set (length: ${#TWOCAPTCHA_API_KEY})"
47
- else
48
- echo "TWOCAPTCHA_API_KEY is NOT set"
49
- fi
50
- env:
51
- TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }}
52
-
53
- - name: Run Windows tests with self-hosted instances
54
- run: set -o pipefail && npx vitest run ${{ inputs.test_pattern }} 2>&1 | tee test-output.log
42
+ - name: Run Windows tests
43
+ run: set -o pipefail && npx vitest run ${{ inputs.test_pattern }} --maxWorkers 32 --sequence.concurrent 2>&1 | tee test-output.log
55
44
  env:
56
45
  TD_API_KEY: ${{ secrets.TD_API_KEY }}
57
46
  TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }}
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [7.3.35](https://github.com/testdriverai/testdriverai/compare/v7.3.34...v7.3.35) (2026-02-24)
2
+
3
+
4
+
5
+ ## [7.3.34](https://github.com/testdriverai/testdriverai/compare/v7.3.33...v7.3.34) (2026-02-24)
6
+
7
+
8
+
1
9
  ## [7.3.33](https://github.com/testdriverai/testdriverai/compare/v7.3.32...v7.3.33) (2026-02-24)
2
10
 
3
11
 
package/agent/index.js CHANGED
@@ -1635,105 +1635,6 @@ ${regression}
1635
1635
  this.emitter.emit(events.log.log, `${inputFile} (end)`);
1636
1636
  }
1637
1637
 
1638
- // Returns the path to the last sandbox file
1639
- getLastSandboxFilePath() {
1640
- const testdriverDir = path.join(process.cwd(), ".testdriver");
1641
- return path.join(testdriverDir, "last-sandbox");
1642
- }
1643
-
1644
- // Returns full sandbox info from last-sandbox file (no timeout - let API validate)
1645
- getLastSandboxId() {
1646
- const lastSandboxFile = this.getLastSandboxFilePath();
1647
-
1648
- if (fs.existsSync(lastSandboxFile)) {
1649
- try {
1650
- const fileContent = fs.readFileSync(lastSandboxFile, "utf-8").trim();
1651
-
1652
- // Parse sandbox info (supports both old format and new format)
1653
- let sandboxInfo;
1654
- try {
1655
- sandboxInfo = JSON.parse(fileContent);
1656
- } catch {
1657
- return { sandboxId: fileContent || null };
1658
- }
1659
-
1660
- return {
1661
- sandboxId: sandboxInfo.sandboxId || sandboxInfo.instanceId || null,
1662
- os: sandboxInfo.os || "linux",
1663
- ami: sandboxInfo.ami || null,
1664
- instanceType: sandboxInfo.instanceType || null,
1665
- timestamp: sandboxInfo.timestamp || null,
1666
- };
1667
- } catch {
1668
- // ignore errors
1669
- }
1670
- }
1671
- return null;
1672
- }
1673
-
1674
- // Returns sandboxId to use if AMI/instance type match current requirements
1675
- getRecentSandboxId() {
1676
- const sandboxInfo = this.getLastSandboxId();
1677
-
1678
- if (!sandboxInfo || !sandboxInfo.sandboxId) {
1679
- return null;
1680
- }
1681
-
1682
- // Check if AMI and instance type match current requirements
1683
- const currentAmi = this.sandboxAmi || null;
1684
- const currentInstance = this.sandboxInstance || null;
1685
- const storedAmi = sandboxInfo.ami || null;
1686
- const storedInstance = sandboxInfo.instanceType || null;
1687
-
1688
- if (currentAmi === storedAmi && currentInstance === storedInstance) {
1689
- return sandboxInfo.sandboxId;
1690
- } else {
1691
- this.emitter.emit(
1692
- events.log.log,
1693
- theme.dim(
1694
- "Recent sandbox found but AMI/instance type doesn't match current requirements",
1695
- ),
1696
- );
1697
- return null;
1698
- }
1699
- }
1700
-
1701
- saveLastSandboxId(sandboxId, osType = "linux") {
1702
- const lastSandboxFile = this.getLastSandboxFilePath();
1703
- const testdriverDir = path.dirname(lastSandboxFile);
1704
-
1705
- try {
1706
- // Ensure .testdriver directory exists
1707
- if (!fs.existsSync(testdriverDir)) {
1708
- fs.mkdirSync(testdriverDir, { recursive: true });
1709
- }
1710
-
1711
- const sandboxInfo = {
1712
- sandboxId: sandboxId,
1713
- os: osType,
1714
- ami: this.sandboxAmi || null,
1715
- instanceType: this.sandboxInstance || null,
1716
- timestamp: new Date().toISOString(),
1717
- };
1718
- fs.writeFileSync(lastSandboxFile, JSON.stringify(sandboxInfo, null, 2), {
1719
- encoding: "utf-8",
1720
- });
1721
- } catch {
1722
- // ignore errors
1723
- }
1724
- }
1725
-
1726
- clearRecentSandboxId() {
1727
- const lastSandboxFile = this.getLastSandboxFilePath();
1728
- try {
1729
- if (fs.existsSync(lastSandboxFile)) {
1730
- fs.unlinkSync(lastSandboxFile);
1731
- }
1732
- } catch {
1733
- // ignore errors
1734
- }
1735
- }
1736
-
1737
1638
  async buildEnv(options = {}) {
1738
1639
  // If instance already exists, do not build environment again
1739
1640
  if (this.instance) {
@@ -1762,10 +1663,8 @@ ${regression}
1762
1663
 
1763
1664
  if (heal) this.healMode = heal;
1764
1665
 
1765
- // If createNew flag is set, clear the recent sandbox file to force creating a new sandbox
1666
+ // If createNew flag is set, clear sandboxId to prevent reconnection attempts
1766
1667
  if (createNew) {
1767
- this.clearRecentSandboxId();
1768
- // Also clear this.sandboxId to prevent reconnection attempts
1769
1668
  this.sandboxId = null;
1770
1669
  if (!this.config.CI && !this.newSandbox) {
1771
1670
  this.emitter.emit(events.log.log, theme.dim("Creating a new sandbox"));
@@ -1780,8 +1679,6 @@ ${regression}
1780
1679
  // order is important!
1781
1680
  await this.connectToSandboxService();
1782
1681
 
1783
- const recentId = createNew ? null : this.getRecentSandboxId();
1784
-
1785
1682
  // Set sandbox ID for reconnection (only if not creating new and recent ID exists)
1786
1683
  if (this.ip) {
1787
1684
  let instance = await this.sandbox.send({
@@ -1794,13 +1691,13 @@ ${regression}
1794
1691
  // Store connection params for reconnection
1795
1692
  // For direct IP connections, store as a direct type so reconnection
1796
1693
  // sends a 'direct' message instead of 'connect' with an IP as sandboxId
1797
- this.sandbox._lastConnectParams = {
1694
+ this.sandbox.setConnectionParams({
1798
1695
  type: 'direct',
1799
1696
  ip: this.ip,
1800
1697
  sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || null,
1801
1698
  persist: true,
1802
1699
  keepAlive: this.keepAlive,
1803
- };
1700
+ });
1804
1701
 
1805
1702
  // Mark instance socket as connected so console logs are forwarded
1806
1703
  this.sandbox.instanceSocketConnected = true;
@@ -1811,33 +1708,6 @@ ${regression}
1811
1708
  await this.runLifecycle("provision");
1812
1709
 
1813
1710
  return;
1814
- } else if (!createNew && recentId) {
1815
- // Only attempt to connect to existing sandbox if not in CI mode and not creating new
1816
- this.emitter.emit(
1817
- events.log.narration,
1818
- theme.dim(`using recent sandbox: ${recentId}`),
1819
- );
1820
- this.sandboxId = recentId;
1821
-
1822
- try {
1823
- let instance = await this.connectToSandboxDirect(
1824
- this.sandboxId,
1825
- true, // always persist by default
1826
- this.keepAlive, // pass keepAlive TTL
1827
- );
1828
-
1829
- this.instance = instance;
1830
-
1831
- await this.renderSandbox(instance, headless);
1832
- return;
1833
- } catch (error) {
1834
- // If connection fails, fall through to creating a new sandbox
1835
- this.emitter.emit(
1836
- events.log.narration,
1837
- theme.dim(`failed to connect to recent sandbox, creating new one...`),
1838
- );
1839
- console.error("Failed to reconnect to sandbox:", error);
1840
- }
1841
1711
  } else if (!createNew && this.sandboxId && !this.config.CI) {
1842
1712
  // Only attempt to connect to existing sandbox if not in CI mode and not creating new
1843
1713
  // Attempt to connect to known instance
@@ -1892,9 +1762,6 @@ ${regression}
1892
1762
  this.sandboxId =
1893
1763
  newSandbox?.sandbox?.sandboxId || newSandbox?.sandbox?.instanceId;
1894
1764
 
1895
- // Use the configured sandbox OS type
1896
- this.saveLastSandboxId(this.sandboxId, this.sandboxOs);
1897
-
1898
1765
  let instance = await this.connectToSandboxDirect(
1899
1766
  this.sandboxId,
1900
1767
  true, // always persist by default
@@ -2332,12 +2199,6 @@ Please check your network connection, TD_API_KEY, or the service status.`,
2332
2199
  }
2333
2200
 
2334
2201
  // Success - got a sandbox
2335
- if (response.sandbox && response.sandbox.sandboxId) {
2336
- this.saveLastSandboxId(response.sandbox.sandboxId, this.sandboxOs);
2337
- } else if (response.sandbox && response.sandbox.instanceId) {
2338
- this.saveLastSandboxId(response.sandbox.instanceId, this.sandboxOs);
2339
- }
2340
-
2341
2202
  return response;
2342
2203
  }
2343
2204
  }
@@ -8,6 +8,8 @@ const logger = require("./logger");
8
8
  let server = null;
9
9
  let wss = null;
10
10
  let clients = new Set();
11
+ let refCount = 0; // Number of active consumers (for concurrent test safety)
12
+ let debuggerUrl = null; // Stored URL of running debugger
11
13
 
12
14
  function createDebuggerServer(config = {}) {
13
15
  const port = config.TD_DEBUGGER_PORT || 0; // 0 means find available port
@@ -76,7 +78,12 @@ function createDebuggerServer(config = {}) {
76
78
 
77
79
  // Start server on available port
78
80
  server.listen(port, "localhost", () => {
79
- const actualPort = server.address().port;
81
+ const address = server.address();
82
+ if (!address) {
83
+ reject(new Error("Server started but address is not available"));
84
+ return;
85
+ }
86
+ const actualPort = address.port;
80
87
  resolve({ port: actualPort, server, wss });
81
88
  });
82
89
 
@@ -124,7 +131,43 @@ async function startDebugger(config = {}, emitter) {
124
131
  }
125
132
  }
126
133
 
127
- function stopDebugger() {
134
+ /**
135
+ * Acquire a reference to the debugger server.
136
+ * Starts the server on first call; subsequent calls reuse the existing server.
137
+ * Each call increments a reference count — call releaseDebugger() when done.
138
+ *
139
+ * @param {Object} config - Debugger configuration
140
+ * @param {EventEmitter} emitter - Event emitter for broadcasting
141
+ * @returns {Promise<{port: number, url: string}>} Debugger connection info
142
+ */
143
+ async function acquireDebugger(config = {}, emitter) {
144
+ refCount++;
145
+ if (server && debuggerUrl) {
146
+ // Server already running — reuse it
147
+ return { url: debuggerUrl };
148
+ }
149
+ // First consumer — start the server
150
+ const result = await startDebugger(config, emitter);
151
+ debuggerUrl = result.url;
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Release a reference to the debugger server.
157
+ * Only actually stops the server when the last consumer releases.
158
+ */
159
+ function releaseDebugger() {
160
+ if (refCount > 0) refCount--;
161
+ if (refCount > 0) return; // Other tests still using it
162
+ forceStopDebugger();
163
+ }
164
+
165
+ /**
166
+ * Forcefully stop the debugger server regardless of reference count.
167
+ * Used for process exit cleanup.
168
+ */
169
+ function forceStopDebugger() {
170
+ refCount = 0;
128
171
  if (wss) {
129
172
  wss.close();
130
173
  wss = null;
@@ -136,12 +179,23 @@ function stopDebugger() {
136
179
  }
137
180
 
138
181
  clients.clear();
182
+ debuggerUrl = null;
183
+ module.exports.debuggerUrl = null;
184
+ module.exports.config = null;
139
185
  logger.log("Debugger server stopped");
140
186
  }
141
187
 
188
+ // Keep stopDebugger as alias for forceStopDebugger for backward compatibility
189
+ function stopDebugger() {
190
+ forceStopDebugger();
191
+ }
192
+
142
193
  module.exports = {
143
194
  startDebugger,
144
195
  stopDebugger,
196
+ acquireDebugger,
197
+ releaseDebugger,
198
+ forceStopDebugger,
145
199
  broadcastEvent,
146
200
  createDebuggerServer,
147
201
  debuggerUrl: null,
@@ -47,6 +47,7 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
47
47
  this.reconnecting = false; // Prevent duplicate reconnection attempts
48
48
  this.pendingTimeouts = new Map(); // Track per-message timeouts
49
49
  this.pendingRetryQueue = []; // Queue of requests to retry after reconnection
50
+ this._lastConnectParams = null; // Connection params for reconnection (per-instance, not shared)
50
51
  }
51
52
 
52
53
  /**
@@ -98,7 +99,7 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
98
99
  // Add sandboxId to every message if we have a connected sandbox
99
100
  // This allows the API to reconnect if the connection was rerouted
100
101
  // Don't inject IP addresses as sandboxId — only valid instance/sandbox IDs
101
- if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
102
+ if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
102
103
  const id = this._lastConnectParams.sandboxId;
103
104
  // Only inject if it looks like a valid ID (not an IP address)
104
105
  if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
@@ -198,6 +199,22 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
198
199
  }
199
200
  }
200
201
 
202
+ /**
203
+ * Set connection params for reconnection logic and sandboxId injection.
204
+ * Use this instead of directly assigning this._lastConnectParams from
205
+ * external code. Keeps the shape consistent and avoids stale state
206
+ * leaking across concurrent test runs.
207
+ * @param {Object|null} params
208
+ * @param {string} [params.type] - 'direct' for IP-based connections
209
+ * @param {string} [params.ip] - IP address for direct connections
210
+ * @param {string} [params.sandboxId] - Sandbox/instance ID
211
+ * @param {boolean} [params.persist] - Whether to persist the sandbox
212
+ * @param {number|null} [params.keepAlive] - Keep-alive TTL in ms
213
+ */
214
+ setConnectionParams(params) {
215
+ this._lastConnectParams = params ? { ...params } : null;
216
+ }
217
+
201
218
  async connect(sandboxId, persist = false, keepAlive = null) {
202
219
  let reply = await this.send({
203
220
  type: "connect",
@@ -209,14 +226,14 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
209
226
  if (reply.success) {
210
227
  // Only store connection params after successful connection
211
228
  // This prevents malformed sandboxId from being attached to subsequent messages
212
- this._lastConnectParams = { sandboxId, persist, keepAlive };
229
+ this.setConnectionParams({ sandboxId, persist, keepAlive });
213
230
  this.instanceSocketConnected = true;
214
231
  emitter.emit(events.sandbox.connected);
215
232
  // Return the full reply (includes url and sandbox)
216
233
  return reply;
217
234
  } else {
218
235
  // Clear any previous connection params on failure
219
- this._lastConnectParams = null;
236
+ this.setConnectionParams(null);
220
237
  // Throw error to trigger fallback to creating new sandbox
221
238
  throw new Error(reply.errorMessage || "Failed to connect to sandbox");
222
239
  }
@@ -461,12 +478,17 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
461
478
 
462
479
  if (!this.ps[message.requestId]) {
463
480
  // This can happen during reconnection (ps was cleared) or after timeout
464
- // (promise was deleted). Only log at debug level since it's expected.
481
+ // (promise was deleted). Expected during polling loops (e.g. Chrome
482
+ // debugger readiness checks) where short-timeout exec calls regularly
483
+ // expire before the sandbox responds. Only log in debug/verbose mode.
465
484
  if (!this.reconnecting) {
466
- console.warn(
467
- "No pending promise found for requestId:",
468
- message.requestId,
469
- );
485
+ const debugMode = process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
486
+ if (debugMode) {
487
+ console.warn(
488
+ "No pending promise found for requestId:",
489
+ message.requestId,
490
+ );
491
+ }
470
492
  }
471
493
  return;
472
494
  }
@@ -528,6 +550,7 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
528
550
  this.instanceSocketConnected = false;
529
551
  this.authenticated = false;
530
552
  this.instance = null;
553
+ this._lastConnectParams = null;
531
554
 
532
555
  // Silently clear pending promises and retry queue without rejecting
533
556
  // (rejecting causes unhandled promise rejections during cleanup)
@@ -25,7 +25,7 @@ const testdriver = new TestDriver(apiKey, options)
25
25
  Configuration options for the client
26
26
 
27
27
  <Expandable title="properties">
28
- <ParamField path="os" type="string" default="windows">
28
+ <ParamField path="os" type="string" default="linux">
29
29
  Operating system for the sandbox: `'windows'` or `'linux'`
30
30
  </ParamField>
31
31
 
@@ -33,7 +33,7 @@ const testdriver = new TestDriver(apiKey, options)
33
33
  Screen resolution for the sandbox (e.g., `'1920x1080'`, `'1366x768'`)
34
34
  </ParamField>
35
35
 
36
- <ParamField path="apiRoot" type="string" default="https://testdriver-api.onrender.com">
36
+ <ParamField path="apiRoot" type="string">
37
37
  API endpoint URL (typically only changed for self-hosted deployments)
38
38
  </ParamField>
39
39
 
@@ -49,6 +49,111 @@ const testdriver = new TestDriver(apiKey, options)
49
49
  Automatically capture screenshots before and after each command. Screenshots are saved to `.testdriver/screenshots/<test>/` with descriptive filenames that include the line number and action name. Format: `<seq>-<action>-<phase>-L<line>-<description>.png`
50
50
  </ParamField>
51
51
 
52
+ <ParamField path="newSandbox" type="boolean" default="true">
53
+ Force creation of a new sandbox instead of reusing an existing one
54
+ </ParamField>
55
+
56
+ <ParamField path="reconnect" type="boolean" default="false">
57
+ Reconnect to the last used sandbox instead of creating a new one. When `true`, provision methods (`chrome`, `vscode`, `installer`, etc.) will be skipped since the application is already running. Throws error if no previous sandbox exists.
58
+ </ParamField>
59
+
60
+ <ParamField path="keepAlive" type="number" default="60000">
61
+ Keep sandbox alive for the specified number of milliseconds after disconnect. Set to `0` to terminate immediately on disconnect. Useful for debugging or reconnecting to the same sandbox.
62
+ </ParamField>
63
+
64
+ <ParamField path="preview" type="string" default="browser">
65
+ Preview mode for live test visualization:
66
+ - `"browser"` — Opens debugger in default browser (default)
67
+ - `"ide"` — Opens preview in IDE panel (VSCode, Cursor - requires TestDriver extension)
68
+ - `"none"` — Headless mode, no visual preview
69
+ </ParamField>
70
+
71
+ <ParamField path="headless" type="boolean" default="false">
72
+ **Deprecated**: Use `preview: "none"` instead. Run in headless mode without opening the debugger.
73
+ </ParamField>
74
+
75
+ <ParamField path="debugOnFailure" type="boolean" default="false">
76
+ Keep the sandbox alive when a test fails so you can reconnect and debug interactively. The sandbox ID is printed to the console.
77
+ </ParamField>
78
+
79
+ <ParamField path="ip" type="string">
80
+ Direct IP address to connect to a running sandbox instance (for self-hosted deployments)
81
+ </ParamField>
82
+
83
+ <ParamField path="sandboxAmi" type="string">
84
+ Custom AMI ID for the sandbox instance (AWS deployments, e.g., `'ami-1234'`)
85
+ </ParamField>
86
+
87
+ <ParamField path="sandboxInstance" type="string">
88
+ EC2 instance type for the sandbox (AWS deployments, e.g., `'i3.metal'`)
89
+ </ParamField>
90
+
91
+ <ParamField path="cache" type="boolean | object" default="true">
92
+ Enable or disable element caching, or provide advanced threshold configuration.
93
+
94
+ <Expandable title="advanced config">
95
+ <ParamField path="enabled" type="boolean" default="true">
96
+ Enable or disable caching
97
+ </ParamField>
98
+
99
+ <ParamField path="thresholds" type="object">
100
+ Fine-tune cache matching
101
+
102
+ <Expandable title="properties">
103
+ <ParamField path="find" type="object">
104
+ Thresholds for `find()` operations
105
+
106
+ <Expandable title="properties">
107
+ <ParamField path="screen" type="number" default="0.05">
108
+ Pixel diff threshold for screen comparison (0-1). `0.05` = 5% diff allowed.
109
+ </ParamField>
110
+
111
+ <ParamField path="element" type="number" default="0.8">
112
+ OpenCV template match threshold for element matching (0-1). `0.8` = 80% correlation.
113
+ </ParamField>
114
+ </Expandable>
115
+ </ParamField>
116
+
117
+ <ParamField path="assert" type="number" default="0.05">
118
+ Pixel diff threshold for `assert()` operations (0-1). `0.05` = 5% diff allowed.
119
+ </ParamField>
120
+ </Expandable>
121
+ </ParamField>
122
+ </Expandable>
123
+ </ParamField>
124
+
125
+ <ParamField path="cacheKey" type="string">
126
+ Cache key for element finding operations. If provided, enables caching tied to this key.
127
+ </ParamField>
128
+
129
+ <ParamField path="dashcam" type="boolean" default="true">
130
+ Enable or disable Dashcam video recording
131
+ </ParamField>
132
+
133
+ <ParamField path="redraw" type="boolean | object" default="true">
134
+ Enable or disable screen-change (redraw) detection, or provide advanced configuration.
135
+
136
+ <Expandable title="advanced config">
137
+ <ParamField path="enabled" type="boolean" default="true">
138
+ Enable or disable redraw detection
139
+ </ParamField>
140
+
141
+ <ParamField path="thresholds" type="object">
142
+ Threshold configuration
143
+
144
+ <Expandable title="properties">
145
+ <ParamField path="screen" type="number | false" default="0.05">
146
+ Pixel diff threshold (0-1). Set to `false` to disable screen redraw detection.
147
+ </ParamField>
148
+
149
+ <ParamField path="network" type="boolean" default="false">
150
+ Enable or disable network activity monitoring
151
+ </ParamField>
152
+ </Expandable>
153
+ </ParamField>
154
+ </Expandable>
155
+ </ParamField>
156
+
52
157
  <ParamField path="environment" type="object">
53
158
  Additional environment variables to pass to the sandbox
54
159
  </ParamField>
@@ -10,7 +10,64 @@ Configure TestDriver behavior with options passed to the `TestDriver()` function
10
10
 
11
11
  ```javascript
12
12
  const testdriver = TestDriver(context, {
13
- reconnect: false,
13
+ // === Sandbox & Connection ===
14
+ newSandbox: true, // Force creation of a new sandbox (default: true)
15
+ reconnect: false, // Reconnect to last sandbox (default: false)
16
+ keepAlive: 60000, // Keep sandbox alive after disconnect in ms (default: 60000)
17
+ os: "linux", // 'linux' | 'windows' (default: 'linux')
18
+ resolution: "1366x768", // Sandbox resolution (e.g., '1920x1080')
19
+ ip: "203.0.113.42", // Direct IP for self-hosted sandbox
20
+ sandboxAmi: "ami-1234", // Custom AMI ID (AWS deployments)
21
+ sandboxInstance: "i3.metal", // EC2 instance type (AWS deployments)
22
+
23
+ // === Preview & Debugging ===
24
+ preview: "browser", // "browser" | "ide" | "none" (default: "browser")
25
+ headless: false, // @deprecated - use preview: "none" instead
26
+ debugOnFailure: false, // Keep sandbox alive on test failure for debugging
27
+
28
+ // === Caching ===
29
+ cache: true, // Enable element caching (default: true)
30
+ // Or use advanced caching config:
31
+ // cache: {
32
+ // enabled: true,
33
+ // thresholds: {
34
+ // find: { screen: 0.05, element: 0.8 },
35
+ // assert: 0.05
36
+ // }
37
+ // },
38
+ cacheKey: "my-test", // Cache key for element finding operations
39
+
40
+ // === Recording & Screenshots ===
41
+ dashcam: true, // Enable/disable Dashcam video recording (default: true)
42
+ autoScreenshots: true, // Capture screenshots before/after each command (default: true)
43
+
44
+ // === AI Configuration ===
45
+ ai: { // Global AI sampling configuration
46
+ temperature: 0, // 0 = deterministic, higher = more creative
47
+ top: {
48
+ p: 0.9, // Top-P nucleus sampling (0-1)
49
+ k: 40, // Top-K sampling (1 = most likely, 0 = disabled)
50
+ },
51
+ },
52
+
53
+ // === Screen Change Detection ===
54
+ redraw: true, // Enable redraw detection (default: true)
55
+ // Or use advanced redraw config:
56
+ // redraw: {
57
+ // enabled: true,
58
+ // thresholds: {
59
+ // screen: 0.05, // Pixel diff threshold (0-1), false to disable
60
+ // network: false, // Monitor network activity (default: false)
61
+ // }
62
+ // },
63
+
64
+ // === Logging & Analytics ===
65
+ logging: true, // Enable console logging output (default: true)
66
+ analytics: true, // Enable analytics tracking (default: true)
67
+
68
+ // === Advanced ===
69
+ apiRoot: "https://...", // API endpoint URL (for self-hosted deployments)
70
+ environment: {}, // Additional environment variables for the sandbox
14
71
  });
15
72
  ```
16
73
 
@@ -54,6 +111,16 @@ const testdriver = TestDriver(context, {
54
111
  The legacy `headless: true` option still works for backward compatibility and maps to `preview: "none"`.
55
112
  </Note>
56
113
 
114
+ ### Debug on Failure
115
+
116
+ Keep the sandbox alive when a test fails so you can reconnect and debug interactively. The sandbox ID is printed to the console along with instructions for reconnecting via MCP.
117
+
118
+ ```javascript
119
+ const testdriver = TestDriver(context, {
120
+ debugOnFailure: true,
121
+ });
122
+ ```
123
+
57
124
  ### IP Target
58
125
 
59
126
  If self-hosting TestDriver, use `ip` to specify the device IP. See [Self-Hosting TestDriver](../self-hosting.md) for details.
@@ -105,11 +172,102 @@ steps:
105
172
  - run: TD_OS=${{ matrix.os }} vitest run
106
173
  ```
107
174
 
108
- ## Keepalive
175
+ ### Dashcam Recording
176
+
177
+ Dashcam video recording is enabled by default. Disable it to skip recording:
178
+
179
+ ```javascript
180
+ const testdriver = TestDriver(context, {
181
+ dashcam: false,
182
+ });
183
+ ```
184
+
185
+ ### Automatic Screenshots
186
+
187
+ Screenshots are automatically captured before and after every command (click, type, find, assert, etc.) by default. Each screenshot filename includes the line number from your test file.
188
+
189
+ Disable automatic screenshots:
190
+
191
+ ```javascript
192
+ const testdriver = TestDriver(context, {
193
+ autoScreenshots: false,
194
+ });
195
+ ```
196
+
197
+ ### Caching
198
+
199
+ Element caching speeds up repeated `find()` and `assert()` calls. Enabled by default.
200
+
201
+ ```javascript
202
+ // Disable caching
203
+ const testdriver = TestDriver(context, {
204
+ cache: false,
205
+ });
206
+
207
+ // Advanced: custom thresholds
208
+ const testdriver = TestDriver(context, {
209
+ cache: {
210
+ enabled: true,
211
+ thresholds: {
212
+ find: { screen: 0.05, element: 0.8 },
213
+ assert: 0.05,
214
+ },
215
+ },
216
+ cacheKey: "my-test",
217
+ });
218
+ ```
109
219
 
110
- By default, sandboxes terminate immediately when the test finishes. Set this value to keep the sandbox alive for reconnection.
220
+ ### Redraw Detection
111
221
 
112
- The `keepAlive` param enables you to keep the sandbox running after the test completes for debugging or reconnection. This will allow you to use the debugger to inspect the state of the device after the test has finished.
222
+ Redraw detection waits for the screen to stabilize before taking actions. Enabled by default.
223
+
224
+ ```javascript
225
+ // Disable redraw detection
226
+ const testdriver = TestDriver(context, {
227
+ redraw: false,
228
+ });
229
+
230
+ // Advanced: custom thresholds with network monitoring
231
+ const testdriver = TestDriver(context, {
232
+ redraw: {
233
+ enabled: true,
234
+ thresholds: {
235
+ screen: 0.05,
236
+ network: true,
237
+ },
238
+ },
239
+ });
240
+ ```
241
+
242
+ ### AI Configuration
243
+
244
+ Control how the AI model generates responses for `find()` verification and `assert()` calls:
245
+
246
+ ```javascript
247
+ const testdriver = TestDriver(context, {
248
+ ai: {
249
+ temperature: 0, // 0 = deterministic
250
+ top: { p: 0.9, k: 40 },
251
+ },
252
+ });
253
+ ```
254
+
255
+ ### Environment Variables
256
+
257
+ Pass additional environment variables to the sandbox:
258
+
259
+ ```javascript
260
+ const testdriver = TestDriver(context, {
261
+ environment: {
262
+ MY_VAR: "value",
263
+ DEBUG: "true",
264
+ },
265
+ });
266
+ ```
267
+
268
+ ## Keepalive
269
+
270
+ By default, sandboxes stay alive for 60 seconds after disconnect. Customize this with `keepAlive`:
113
271
 
114
272
  ```javascript
115
273
  const testdriver = TestDriver(context, {
@@ -117,6 +275,14 @@ const testdriver = TestDriver(context, {
117
275
  });
118
276
  ```
119
277
 
278
+ Set to `0` to terminate immediately:
279
+
280
+ ```javascript
281
+ const testdriver = TestDriver(context, {
282
+ keepAlive: 0, // Terminate sandbox immediately on disconnect
283
+ });
284
+ ```
285
+
120
286
  ### Reconnecting to Existing Sandbox
121
287
 
122
288
  Speed up test development by reconnecting to an existing sandbox instead of starting fresh each time. This lets you iterate quickly on failing steps without re-running the entire test from the beginning.
@@ -65,31 +65,31 @@ describe("Chrome Extension Test", () => {
65
65
  expect(popupResult).toBeTruthy();
66
66
  });
67
67
 
68
- it("should load Loom from Chrome Web Store by extensionId", async (context) => {
69
- const testdriver = TestDriver(context, { ...getDefaults(context) });
70
-
71
- // Launch Chrome with Loom loaded by its Chrome Web Store ID
72
- // Loom ID: liecbddmkiiihnedobmlmillhodjkdmb
73
- await testdriver.provision.chromeExtension({
74
- extensionId: 'liecbddmkiiihnedobmlmillhodjkdmb'
75
- });
76
-
77
- // Navigate to testdriver.ai (extensions don't load on New Tab)
78
- const addressBar = await testdriver.find("Chrome address bar");
79
- await addressBar.click();
80
- await testdriver.type("testdriver.ai");
81
- await testdriver.pressKeys(["enter"]);
82
-
83
- // Wait for page to load
84
- const pageResult = await testdriver.assert("I can see testdriver.ai");
85
- expect(pageResult).toBeTruthy();
86
-
87
- // Click on the extensions button (puzzle piece icon) in Chrome toolbar
88
- const extensionsButton = await testdriver.find("The puzzle-shaped icon in the Chrome toolbar.", {zoom: true});
89
- await extensionsButton.click();
90
-
91
- // Look for Loom in the extensions menu
92
- const loomExtension = await testdriver.find("Loom extension in the extensions dropdown");
93
- expect(loomExtension.found()).toBeTruthy();
94
- });
68
+ // it("should load Loom from Chrome Web Store by extensionId", async (context) => {
69
+ // const testdriver = TestDriver(context, { ...getDefaults(context) });
70
+
71
+ // // Launch Chrome with Loom loaded by its Chrome Web Store ID
72
+ // // Loom ID: liecbddmkiiihnedobmlmillhodjkdmb
73
+ // await testdriver.provision.chromeExtension({
74
+ // extensionId: 'liecbddmkiiihnedobmlmillhodjkdmb'
75
+ // });
76
+
77
+ // // Navigate to testdriver.ai (extensions don't load on New Tab)
78
+ // const addressBar = await testdriver.find("Chrome address bar");
79
+ // await addressBar.click();
80
+ // await testdriver.type("testdriver.ai");
81
+ // await testdriver.pressKeys(["enter"]);
82
+
83
+ // // Wait for page to load
84
+ // const pageResult = await testdriver.assert("I can see testdriver.ai");
85
+ // expect(pageResult).toBeTruthy();
86
+
87
+ // // Click on the extensions button (puzzle piece icon) in Chrome toolbar
88
+ // const extensionsButton = await testdriver.find("The puzzle-shaped icon in the Chrome toolbar.", {zoom: true});
89
+ // await extensionsButton.click();
90
+
91
+ // // Look for Loom in the extensions menu
92
+ // const loomExtension = await testdriver.find("Loom extension in the extensions dropdown");
93
+ // expect(loomExtension.found()).toBeTruthy();
94
+ // });
95
95
  });
@@ -1142,7 +1142,10 @@ class TestDriverReporter {
1142
1142
 
1143
1143
  const suiteName = test.suite?.name;
1144
1144
  const startTime = Date.now() - duration; // Calculate start time from duration
1145
- const retryCount = result.retryCount || 0;
1145
+ // In Vitest v4, retryCount is on diagnostic(), not result()
1146
+ // result() only returns { state, errors }, while diagnostic() has retryCount, duration, etc.
1147
+ const diagnostic = test.diagnostic?.();
1148
+ const retryCount = diagnostic?.retryCount || 0;
1146
1149
  const testRunDbId = process.env.TD_TEST_RUN_DB_ID;
1147
1150
  const consoleUrl = getConsoleUrl(pluginState.apiRoot);
1148
1151
  const hasRetries = retryCount > 0 && dashcamUrls.length > 1;
package/lib/sentry.js CHANGED
@@ -16,7 +16,11 @@ const os = require("os");
16
16
  const { version } = require("../package.json");
17
17
  const logger = require("../agent/lib/logger");
18
18
 
19
- // Store the current session's trace context
19
+ // Store trace contexts per session so concurrent tests don't overwrite each other.
20
+ // Keys are sessionIds, values are { traceId, sessionId }.
21
+ const _traceContexts = new Map();
22
+
23
+ // For backward compatibility, track the most recently set session
20
24
  let currentTraceId = null;
21
25
  let currentSessionId = null;
22
26
 
@@ -177,7 +181,13 @@ function setSessionTraceContext(sessionId) {
177
181
  if (!isEnabled() || !sessionId) return;
178
182
 
179
183
  // Derive trace ID from session ID (same algorithm as API)
180
- currentTraceId = crypto.createHash("md5").update(sessionId).digest("hex");
184
+ const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
185
+
186
+ // Store per-session trace context for concurrent safety
187
+ _traceContexts.set(sessionId, { traceId, sessionId });
188
+
189
+ // Also update the module-level "latest" for backward compatibility
190
+ currentTraceId = traceId;
181
191
  currentSessionId = sessionId;
182
192
 
183
193
  // Set as global tag so all events include it
@@ -203,9 +213,26 @@ function setSessionTraceContext(sessionId) {
203
213
  /**
204
214
  * Clear the session trace context
205
215
  */
206
- function clearSessionTraceContext() {
207
- currentTraceId = null;
208
- currentSessionId = null;
216
+ function clearSessionTraceContext(sessionId) {
217
+ if (sessionId) {
218
+ _traceContexts.delete(sessionId);
219
+ // If the cleared session was the "latest", pick another or null
220
+ if (currentSessionId === sessionId) {
221
+ if (_traceContexts.size > 0) {
222
+ const last = Array.from(_traceContexts.values()).pop();
223
+ currentTraceId = last.traceId;
224
+ currentSessionId = last.sessionId;
225
+ } else {
226
+ currentTraceId = null;
227
+ currentSessionId = null;
228
+ }
229
+ }
230
+ } else {
231
+ // Clear all (backward compatibility)
232
+ _traceContexts.clear();
233
+ currentTraceId = null;
234
+ currentSessionId = null;
235
+ }
209
236
  }
210
237
 
211
238
  /**
@@ -131,7 +131,10 @@ function forwardToAllSandboxes(args) {
131
131
  * reporter output).
132
132
  */
133
133
  function installConsoleSpy() {
134
- if (_consoleSpy.installed) return;
134
+ // Check both installed flag AND that spies are still valid.
135
+ // Guards against a race where cleanupConsoleSpy restores mocks (setting
136
+ // installed=false) while a new test is starting up concurrently.
137
+ if (_consoleSpy.installed && _consoleSpy.spies) return;
135
138
  _consoleSpy.installed = true;
136
139
 
137
140
  // Capture originals once — these are whatever console methods look like
@@ -140,6 +140,14 @@ beforeEach(async (context) => {
140
140
  return;
141
141
  }
142
142
 
143
+ // If ip is provided via plugin options, skip spawning
144
+ const pluginIp = globalThis.__testdriverPlugin?.state?.testDriverOptions?.ip;
145
+ if (pluginIp) {
146
+ console.log(`[TestDriver] Using ip from plugin options: ${pluginIp}`);
147
+ context.ip = pluginIp;
148
+ return;
149
+ }
150
+
143
151
  if (!process.env.AWS_LAUNCH_TEMPLATE_ID || !process.env.AMI_ID) {
144
152
  throw new Error(
145
153
  "[TestDriver] TD_OS=windows requires AWS_LAUNCH_TEMPLATE_ID and AMI_ID environment variables",
@@ -81,6 +81,21 @@ beforeEach(async (context) => {
81
81
  return;
82
82
  }
83
83
 
84
+ // If TD_IP is already set, use it and skip spawning
85
+ if (process.env.TD_IP) {
86
+ console.log(`[TestDriver] Using existing instance at ${process.env.TD_IP}`);
87
+ context.ip = process.env.TD_IP;
88
+ return;
89
+ }
90
+
91
+ // If ip is provided via plugin options, skip spawning
92
+ const pluginIp = globalThis.__testdriverPlugin?.state?.testDriverOptions?.ip;
93
+ if (pluginIp) {
94
+ console.log(`[TestDriver] Using ip from plugin options: ${pluginIp}`);
95
+ context.ip = pluginIp;
96
+ return;
97
+ }
98
+
84
99
  // Verify AWS credentials are available
85
100
  if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_LAUNCH_TEMPLATE_ID || !process.env.AMI_ID) {
86
101
  throw new Error('[TestDriver] TD_OS=windows requires AWS credentials (AWS_ACCESS_KEY_ID, AWS_LAUNCH_TEMPLATE_ID, AMI_ID)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.3.33",
3
+ "version": "7.3.35",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",
@@ -23,7 +23,6 @@
23
23
  },
24
24
  "./vitest/setup": "./lib/vitest/setup.mjs",
25
25
  "./vitest/setup-aws": "./lib/vitest/setup-aws.mjs",
26
- "./vitest/setup-disable-defender": "./lib/vitest/setup-disable-defender.mjs",
27
26
  "./vitest/hooks": {
28
27
  "types": "./lib/vitest/hooks.d.ts",
29
28
  "default": "./lib/vitest/hooks.mjs"
package/sdk.js CHANGED
@@ -1399,6 +1399,10 @@ const TestDriverAgent = require("./agent/index.js");
1399
1399
  const { events } = require("./agent/events.js");
1400
1400
  const { createMarkdownLogger } = require("./interfaces/logger.js");
1401
1401
 
1402
+ // Track screenshot directories already cleaned in this process to avoid
1403
+ // concurrent tests in the same file from nuking each other's screenshots.
1404
+ const _cleanedScreenshotDirs = new Set();
1405
+
1402
1406
  class TestDriverSDK {
1403
1407
  constructor(apiKey, options = {}) {
1404
1408
  // Support calling with just options: new TestDriver({ os: 'windows' })
@@ -1432,6 +1436,12 @@ class TestDriverSDK {
1432
1436
  ...options.environment,
1433
1437
  };
1434
1438
 
1439
+ // Auto-detect CI environment (GitHub Actions, etc.) and pass through
1440
+ // This ensures the API creates fresh sandboxes instead of reusing hot-pool instances
1441
+ if (!environment.CI && process.env.CI) {
1442
+ environment.CI = process.env.CI;
1443
+ }
1444
+
1435
1445
  // Create the underlying agent with minimal CLI args
1436
1446
  this.agent = new TestDriverAgent(environment, {
1437
1447
  command: "sdk",
@@ -1710,22 +1720,29 @@ class TestDriverSDK {
1710
1720
  * @param {number} [timeoutMs=60000] - Maximum time to wait in ms
1711
1721
  * @returns {Promise<void>}
1712
1722
  */
1713
- async _waitForChromeDebuggerReady(timeoutMs = 300000) {
1723
+ async _waitForChromeDebuggerReady(timeoutMs = 60000) {
1714
1724
  const shell = this.os === "windows" ? "pwsh" : "sh";
1715
1725
  const portCheckCmd = this.os === "windows"
1716
1726
  ? `$tcp = New-Object System.Net.Sockets.TcpClient; $tcp.Connect('127.0.0.1', 9222); $tcp.Close(); echo 'open'`
1717
1727
  : `curl -s -o /dev/null --connect-timeout 2 http://localhost:9222 2>/dev/null && echo 'open' || echo 'closed'`;
1718
- const pageCheckCmd = this.os === "windows"
1719
- ? `(Invoke-RestMethod -Uri 'http://localhost:9222/json' -TimeoutSec 2) | Where-Object { $_.type -eq 'page' } | Select-Object -First 1 | ConvertTo-Json`
1720
- : `curl -s http://localhost:9222/json 2>/dev/null | grep '"type": "page"'`;
1721
1728
 
1722
1729
  const deadline = Date.now() + timeoutMs;
1723
1730
 
1731
+ // Use commands.exec directly to bypass auto-screenshots wrapper.
1732
+ // The polling loop fires many rapid exec calls with short timeouts;
1733
+ // going through the wrapper adds 2-3 extra sandbox messages
1734
+ // (screenshot before/after/error) per iteration, overwhelming the
1735
+ // WebSocket and generating cascading "No pending promise" warnings
1736
+ // when timed-out responses arrive after the promise has been cleaned up.
1737
+ const execDirect = this.commands?.exec
1738
+ ? (...args) => this.commands.exec(...args)
1739
+ : (...args) => this.exec(...args); // fallback if commands not ready
1740
+
1724
1741
  // Wait for port 9222 to be listening
1725
1742
  let portReady = false;
1726
1743
  while (Date.now() < deadline) {
1727
1744
  try {
1728
- const result = await this.exec(shell, portCheckCmd, 5000, true);
1745
+ const result = await execDirect(shell, portCheckCmd, 10000, true);
1729
1746
  if (result && result.includes("open")) {
1730
1747
  portReady = true;
1731
1748
  break;
@@ -1733,7 +1750,7 @@ class TestDriverSDK {
1733
1750
  } catch (_) {
1734
1751
  // Port not ready yet
1735
1752
  }
1736
- await new Promise((r) => setTimeout(r, 200));
1753
+ await new Promise((r) => setTimeout(r, 2000));
1737
1754
  }
1738
1755
  if (!portReady) {
1739
1756
  throw new Error(
@@ -1741,25 +1758,6 @@ class TestDriverSDK {
1741
1758
  );
1742
1759
  }
1743
1760
 
1744
- // Wait for a page target to appear via CDP
1745
- let pageReady = false;
1746
- while (Date.now() < deadline) {
1747
- try {
1748
- const result = await this.exec(shell, pageCheckCmd, 5000, true);
1749
- if (result && result.trim().length > 0) {
1750
- pageReady = true;
1751
- break;
1752
- }
1753
- } catch (_) {
1754
- // No page target yet
1755
- }
1756
- await new Promise((r) => setTimeout(r, 500));
1757
- }
1758
- if (!pageReady) {
1759
- throw new Error(
1760
- `Chrome page target did not become available within ${timeoutMs}ms`,
1761
- );
1762
- }
1763
1761
  }
1764
1762
 
1765
1763
  _createProvisionAPI() {
@@ -2706,7 +2704,9 @@ CAPTCHA_SOLVER_EOF`,
2706
2704
  );
2707
2705
  }
2708
2706
 
2709
- // Clean up screenshots folder for this test file before running
2707
+ // Clean up screenshots folder for this test file before running.
2708
+ // Only clean once per directory per process to avoid concurrent tests
2709
+ // in the same file (--sequence.concurrent) from nuking each other's screenshots.
2710
2710
  if (this.testFile) {
2711
2711
  const testFileName = path.basename(
2712
2712
  this.testFile,
@@ -2718,8 +2718,11 @@ CAPTCHA_SOLVER_EOF`,
2718
2718
  "screenshots",
2719
2719
  testFileName,
2720
2720
  );
2721
- if (fs.existsSync(screenshotsDir)) {
2722
- fs.rmSync(screenshotsDir, { recursive: true, force: true });
2721
+ if (!_cleanedScreenshotDirs.has(screenshotsDir)) {
2722
+ _cleanedScreenshotDirs.add(screenshotsDir);
2723
+ if (fs.existsSync(screenshotsDir)) {
2724
+ fs.rmSync(screenshotsDir, { recursive: true, force: true });
2725
+ }
2723
2726
  }
2724
2727
  }
2725
2728
 
@@ -2746,33 +2749,6 @@ CAPTCHA_SOLVER_EOF`,
2746
2749
  : this.newSandbox,
2747
2750
  };
2748
2751
 
2749
- // Handle reconnect option - use last sandbox file
2750
- // Check both connectOptions and constructor options
2751
- const shouldReconnect =
2752
- connectOptions.reconnect !== undefined
2753
- ? connectOptions.reconnect
2754
- : this.reconnect;
2755
-
2756
- // Skip reconnect if IP is supplied - directly connect to the provided IP
2757
- const hasIp = Boolean(connectOptions.ip || this.ip);
2758
-
2759
- if (shouldReconnect && !hasIp) {
2760
- const lastSandbox = this.agent.getLastSandboxId();
2761
- if (!lastSandbox || !lastSandbox.sandboxId) {
2762
- throw new Error(
2763
- "Cannot reconnect: No previous sandbox found. Run a test first to create a sandbox, or remove the reconnect option.",
2764
- );
2765
- }
2766
- this.agent.sandboxId = lastSandbox.sandboxId;
2767
- buildEnvOptions.new = false;
2768
-
2769
- // Use OS from last sandbox if not explicitly specified
2770
- if (!connectOptions.os && lastSandbox.os) {
2771
- this.agent.sandboxOs = lastSandbox.os;
2772
- this.os = lastSandbox.os;
2773
- }
2774
- }
2775
-
2776
2752
  // Set agent properties for buildEnv to use
2777
2753
  if (connectOptions.sandboxId) {
2778
2754
  this.agent.sandboxId = connectOptions.sandboxId;
@@ -2883,10 +2859,11 @@ CAPTCHA_SOLVER_EOF`,
2883
2859
  }
2884
2860
  }
2885
2861
 
2886
- // Stop the debugger server (HTTP + WebSocket server) to release the port
2862
+ // Release our reference to the shared debugger server.
2863
+ // The server only actually stops when the last concurrent test disconnects.
2887
2864
  try {
2888
- const { stopDebugger } = require("./agent/lib/debugger-server.js");
2889
- stopDebugger();
2865
+ const { releaseDebugger } = require("./agent/lib/debugger-server.js");
2866
+ releaseDebugger();
2890
2867
  } catch (err) {
2891
2868
  // Ignore if debugger wasn't started
2892
2869
  }
@@ -2916,14 +2893,6 @@ CAPTCHA_SOLVER_EOF`,
2916
2893
  return this.session?.get() || null;
2917
2894
  }
2918
2895
 
2919
- /**
2920
- * Get the last sandbox info from the stored file
2921
- * @returns {Object|null} Last sandbox info including sandboxId, os, ami, instanceType, timestamp, or null if not found
2922
- */
2923
- getLastSandboxId() {
2924
- return this.agent.getLastSandboxId();
2925
- }
2926
-
2927
2896
  // ====================================
2928
2897
  // Element Finding API
2929
2898
  // ====================================
@@ -3924,16 +3893,13 @@ CAPTCHA_SOLVER_EOF`,
3924
3893
  * @private
3925
3894
  */
3926
3895
  async _initializeDebugger() {
3927
- // Import createDebuggerProcess at the module level if not already done
3928
- const { createDebuggerProcess } = require("./agent/lib/debugger.js");
3896
+ // Use reference-counted debugger server so concurrent tests share one
3897
+ // server and it only shuts down when the last test disconnects.
3898
+ const { acquireDebugger } = require("./agent/lib/debugger-server.js");
3929
3899
 
3930
- // Only initialize once
3931
3900
  if (!this.agent.debuggerUrl) {
3932
- const debuggerProcess = await createDebuggerProcess(
3933
- this.config,
3934
- this.emitter,
3935
- );
3936
- this.agent.debuggerUrl = debuggerProcess.url || null;
3901
+ const result = await acquireDebugger(this.config, this.emitter);
3902
+ this.agent.debuggerUrl = result.url || null;
3937
3903
  }
3938
3904
  }
3939
3905
 
package/vitest.config.mjs CHANGED
@@ -5,17 +5,17 @@ import { defineConfig } from "vitest/config";
5
5
  // Note: dotenv is loaded automatically by the TestDriver SDK
6
6
  const setupFiles = [
7
7
  "testdriverai/vitest/setup",
8
- "testdriverai/vitest/setup-aws",
9
- 'testdriverai/vitest/setup-disable-defender'
8
+ "testdriverai/vitest/setup-aws"
10
9
  ];
11
10
 
12
11
  export default defineConfig({
13
12
  test: {
14
- retry: 1,
13
+ retry: 0,
15
14
  testTimeout: 900000,
16
15
  hookTimeout: 900000,
17
16
  disableConsoleIntercept: true,
18
17
  maxConcurrency: 100,
18
+ maxWorkers: 16,
19
19
  reporters: [
20
20
  "default",
21
21
  TestDriver(),
@@ -1,52 +0,0 @@
1
- /**
2
- * Post-spawn hook to disable Windows Defender
3
- *
4
- * Usage in vitest.config.mjs:
5
- * ```js
6
- * setupFiles: [
7
- * 'testdriverai/vitest/setup',
8
- * 'testdriverai/vitest/setup-aws',
9
- * 'testdriverai/vitest/setup-disable-defender'
10
- * ]
11
- * ```
12
- */
13
-
14
- import { execSync } from 'child_process';
15
- import { dirname, join } from 'path';
16
- import { fileURLToPath } from 'url';
17
- import { beforeEach } from 'vitest';
18
-
19
- const __filename = fileURLToPath(import.meta.url);
20
- const __dirname = dirname(__filename);
21
-
22
- beforeEach(async (context) => {
23
- // Only run if we have an instance IP (self-hosted mode)
24
- if (!context.ip) return;
25
-
26
- // Get instance ID from global state set by setup-aws
27
- const instanceInfo = globalThis.__testdriverAWS?.instances?.get(context.task.id);
28
- if (!instanceInfo?.instanceId) {
29
- console.warn('[TestDriver] No instance ID found, skipping Defender disable');
30
- return;
31
- }
32
-
33
- const { instanceId, awsRegion } = instanceInfo;
34
- const scriptPath = join(__dirname, '../../setup/aws/disable-defender.sh');
35
-
36
- console.log(`[TestDriver] Disabling Windows Defender on ${instanceId}...`);
37
-
38
- try {
39
- execSync(`bash ${scriptPath}`, {
40
- encoding: 'utf-8',
41
- env: {
42
- ...process.env,
43
- AWS_REGION: awsRegion,
44
- INSTANCE_ID: instanceId,
45
- },
46
- stdio: 'inherit',
47
- });
48
- } catch (error) {
49
- console.warn('[TestDriver] Failed to disable Defender:', error.message);
50
- // Don't throw - this is optional optimization
51
- }
52
- });