testdriverai 7.3.12 → 7.3.13

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 (133) hide show
  1. package/.github/skills/testdriver:ai/SKILL.md +204 -0
  2. package/.github/skills/testdriver:assert/SKILL.md +284 -0
  3. package/.github/skills/testdriver:aws-setup/SKILL.md +515 -0
  4. package/.github/skills/testdriver:caching/SKILL.md +124 -0
  5. package/.github/skills/testdriver:captcha/SKILL.md +159 -0
  6. package/.github/skills/testdriver:ci-cd/SKILL.md +602 -0
  7. package/.github/skills/testdriver:click/SKILL.md +286 -0
  8. package/.github/skills/testdriver:client/SKILL.md +339 -0
  9. package/.github/skills/testdriver:cloud/SKILL.md +119 -0
  10. package/.github/skills/testdriver:customizing-devices/SKILL.md +153 -0
  11. package/.github/skills/testdriver:dashcam/SKILL.md +418 -0
  12. package/.github/skills/testdriver:debugging-with-screenshots/SKILL.md +271 -0
  13. package/.github/skills/testdriver:device-config/SKILL.md +317 -0
  14. package/.github/skills/testdriver:double-click/SKILL.md +102 -0
  15. package/.github/skills/testdriver:elements/SKILL.md +605 -0
  16. package/.github/skills/testdriver:enterprise/SKILL.md +114 -0
  17. package/.github/skills/testdriver:examples/SKILL.md +7 -0
  18. package/.github/skills/testdriver:exec/SKILL.md +345 -0
  19. package/.github/skills/testdriver:find/SKILL.md +721 -0
  20. package/.github/skills/testdriver:focus-application/SKILL.md +293 -0
  21. package/.github/skills/testdriver:generating-tests/SKILL.md +36 -0
  22. package/.github/skills/testdriver:hover/SKILL.md +278 -0
  23. package/.github/skills/testdriver:locating-elements/SKILL.md +71 -0
  24. package/.github/skills/testdriver:making-assertions/SKILL.md +32 -0
  25. package/.github/skills/testdriver:mcp-workflow/SKILL.md +410 -0
  26. package/.github/skills/testdriver:mouse-down/SKILL.md +161 -0
  27. package/.github/skills/testdriver:mouse-up/SKILL.md +164 -0
  28. package/.github/skills/testdriver:performing-actions/SKILL.md +51 -0
  29. package/.github/skills/testdriver:press-keys/SKILL.md +348 -0
  30. package/.github/skills/testdriver:quickstart/SKILL.md +161 -0
  31. package/.github/skills/testdriver:reusable-code/SKILL.md +240 -0
  32. package/.github/skills/testdriver:right-click/SKILL.md +123 -0
  33. package/.github/skills/testdriver:running-tests/SKILL.md +181 -0
  34. package/.github/skills/testdriver:screenshot/SKILL.md +167 -0
  35. package/.github/skills/testdriver:scroll/SKILL.md +299 -0
  36. package/.github/skills/testdriver:secrets/SKILL.md +115 -0
  37. package/.github/skills/testdriver:self-hosted/SKILL.md +65 -0
  38. package/.github/skills/testdriver:test-writer/SKILL.md +451 -0
  39. package/.github/skills/testdriver:testdriver/SKILL.md +523 -0
  40. package/.github/skills/testdriver:testdriver-mechanic/SKILL.md +165 -0
  41. package/.github/skills/testdriver:type/SKILL.md +357 -0
  42. package/.github/skills/testdriver:variables/SKILL.md +111 -0
  43. package/.github/skills/testdriver:waiting-for-elements/SKILL.md +66 -0
  44. package/.github/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
  45. package/.github/workflows/acceptance-windows-scheduled.yaml +6 -1
  46. package/.github/workflows/acceptance.yaml +0 -36
  47. package/.github/workflows/update-examples.yaml +53 -0
  48. package/CHANGELOG.md +4 -0
  49. package/agent/events.js +1 -0
  50. package/agent/index.js +8 -0
  51. package/agent/lib/commands.js +48 -29
  52. package/agent/lib/redraw.js +3 -1
  53. package/agent/lib/sandbox.js +166 -14
  54. package/agent/lib/sdk.js +142 -3
  55. package/agent/lib/system.js +4 -6
  56. package/ai/skills/testdriver:ai/SKILL.md +204 -0
  57. package/ai/skills/testdriver:assert/SKILL.md +315 -0
  58. package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
  59. package/ai/skills/testdriver:caching/SKILL.md +124 -0
  60. package/ai/skills/testdriver:captcha/SKILL.md +159 -0
  61. package/ai/skills/testdriver:ci-cd/SKILL.md +602 -0
  62. package/ai/skills/testdriver:click/SKILL.md +286 -0
  63. package/ai/skills/testdriver:client/SKILL.md +372 -0
  64. package/ai/skills/testdriver:cloud/SKILL.md +119 -0
  65. package/ai/skills/testdriver:customizing-devices/SKILL.md +153 -0
  66. package/ai/skills/testdriver:dashcam/SKILL.md +418 -0
  67. package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +401 -0
  68. package/ai/skills/testdriver:device-config/SKILL.md +317 -0
  69. package/ai/skills/testdriver:double-click/SKILL.md +102 -0
  70. package/ai/skills/testdriver:elements/SKILL.md +605 -0
  71. package/ai/skills/testdriver:enterprise/SKILL.md +114 -0
  72. package/ai/skills/testdriver:examples/SKILL.md +7 -0
  73. package/ai/skills/testdriver:exec/SKILL.md +345 -0
  74. package/ai/skills/testdriver:find/SKILL.md +745 -0
  75. package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
  76. package/ai/skills/testdriver:generating-tests/SKILL.md +36 -0
  77. package/ai/skills/testdriver:hover/SKILL.md +278 -0
  78. package/ai/skills/testdriver:locating-elements/SKILL.md +71 -0
  79. package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
  80. package/ai/skills/testdriver:mcp-workflow/SKILL.md +410 -0
  81. package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
  82. package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
  83. package/ai/skills/testdriver:ocr/SKILL.md +235 -0
  84. package/ai/skills/testdriver:performing-actions/SKILL.md +51 -0
  85. package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
  86. package/ai/skills/testdriver:quickstart/SKILL.md +146 -0
  87. package/ai/skills/testdriver:reusable-code/SKILL.md +240 -0
  88. package/ai/skills/testdriver:right-click/SKILL.md +123 -0
  89. package/ai/skills/testdriver:running-tests/SKILL.md +185 -0
  90. package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
  91. package/ai/skills/testdriver:scroll/SKILL.md +335 -0
  92. package/ai/skills/testdriver:secrets/SKILL.md +115 -0
  93. package/ai/skills/testdriver:self-hosted/SKILL.md +65 -0
  94. package/ai/skills/testdriver:test-writer/SKILL.md +451 -0
  95. package/ai/skills/testdriver:testdriver/SKILL.md +631 -0
  96. package/ai/skills/testdriver:testdriver-mechanic/SKILL.md +165 -0
  97. package/ai/skills/testdriver:type/SKILL.md +357 -0
  98. package/ai/skills/testdriver:variables/SKILL.md +111 -0
  99. package/ai/skills/testdriver:waiting-for-elements/SKILL.md +66 -0
  100. package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
  101. package/debugger/index.html +12 -2
  102. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  103. package/docs/v7/find.mdx +1 -0
  104. package/examples/config.mjs +1 -1
  105. package/examples/findall-coffee-icons.test.mjs +42 -0
  106. package/examples/flake-diffthreshold-001.test.mjs +9 -0
  107. package/examples/flake-diffthreshold-01.test.mjs +9 -0
  108. package/examples/flake-diffthreshold-05.test.mjs +9 -0
  109. package/examples/{z_flake-noredraw-cache.test.mjs → flake-noredraw-cache.test.mjs} +2 -2
  110. package/examples/{z_flake-noredraw-nocache.test.mjs → flake-noredraw-nocache.test.mjs} +2 -2
  111. package/examples/{z_flake-redraw-cache.test.mjs → flake-redraw-cache.test.mjs} +2 -2
  112. package/examples/{z_flake-redraw-nocache.test.mjs → flake-redraw-nocache.test.mjs} +2 -2
  113. package/examples/flake-rocket-match.test.mjs +30 -0
  114. package/examples/{z_flake-shared.mjs → flake-shared.mjs} +2 -2
  115. package/examples/parse.test.mjs +19 -0
  116. package/examples/scroll-keyboard.test.mjs +1 -1
  117. package/interfaces/cli/lib/base.js +6 -0
  118. package/interfaces/logger.js +51 -13
  119. package/interfaces/vitest-plugin.mjs +137 -0
  120. package/lib/core/index.d.ts +22 -0
  121. package/lib/init-project.js +105 -6
  122. package/lib/vitest/hooks.mjs +2 -5
  123. package/lib/vitest/setup-disable-defender.mjs +52 -0
  124. package/package.json +2 -1
  125. package/sdk-log-formatter.js +90 -0
  126. package/sdk.d.ts +88 -51
  127. package/sdk.js +126 -18
  128. package/setup/aws/disable-defender.sh +42 -0
  129. package/vitest.config.mjs +1 -3
  130. package/examples/z_flake-diffthreshold-001.test.mjs +0 -9
  131. package/examples/z_flake-diffthreshold-01.test.mjs +0 -9
  132. package/examples/z_flake-diffthreshold-05.test.mjs +0 -9
  133. /package/{examples → manual}/captcha-api.test.mjs +0 -0
@@ -0,0 +1,53 @@
1
+ name: Update Example Docs
2
+ permissions:
3
+ contents: write
4
+ on:
5
+ schedule:
6
+ - cron: "0 0 * * *" # Daily at midnight UTC
7
+ workflow_dispatch: # Allow manual trigger
8
+
9
+ jobs:
10
+ update-examples:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ token: ${{ secrets.GITHUB_TOKEN }}
18
+
19
+ - name: Setup Node.js
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: "20"
23
+
24
+ - name: Configure Git
25
+ run: |
26
+ git config user.name "github-actions[bot]"
27
+ git config user.email "github-actions[bot]@users.noreply.github.com"
28
+
29
+ - name: Install dependencies
30
+ run: npm ci
31
+
32
+ - name: Run example tests
33
+ run: set -o pipefail && npx vitest run examples/*.test.mjs 2>&1 | tee test-output.log
34
+ env:
35
+ TD_API_KEY: ${{ secrets.TD_API_KEY }}
36
+ TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }}
37
+ TD_OS: linux
38
+
39
+ - name: Extract example URLs
40
+ if: success()
41
+ run: node docs/_scripts/extract-example-urls.js --file=test-output.log
42
+
43
+ - name: Generate example docs
44
+ if: success()
45
+ run: node docs/_scripts/generate-examples.js --skip-ai
46
+ env:
47
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
48
+
49
+ - name: Commit and push updated docs
50
+ if: success()
51
+ run: |
52
+ git add docs/
53
+ git diff --staged --quiet || (git commit -m "docs: update example documentation [skip ci]" && git push origin main)
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## [7.3.13](https://github.com/testdriverai/testdriverai/compare/v7.3.12...v7.3.13) (2026-02-17)
2
+
3
+
4
+
1
5
  ## [7.3.12](https://github.com/testdriverai/testdriverai/compare/v7.3.11...v7.3.12) (2026-02-17)
2
6
 
3
7
 
package/agent/events.js CHANGED
@@ -91,6 +91,7 @@ const events = {
91
91
  request: "sdk:request",
92
92
  response: "sdk:response",
93
93
  progress: "sdk:progress",
94
+ retry: "sdk:retry",
94
95
  },
95
96
  sandbox: {
96
97
  connected: "sandbox:connected",
package/agent/index.js CHANGED
@@ -1791,6 +1791,14 @@ ${regression}
1791
1791
  ip: this.ip,
1792
1792
  });
1793
1793
 
1794
+ // Store sandboxId (for self-hosted, use the IP as identifier) so messages include it
1795
+ // This enables the API to reconnect if the websocket connection is rerouted
1796
+ this.sandbox._lastConnectParams = {
1797
+ sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || this.ip,
1798
+ persist: true,
1799
+ keepAlive: this.keepAlive,
1800
+ };
1801
+
1794
1802
  // Mark instance socket as connected so console logs are forwarded
1795
1803
  this.sandbox.instanceSocketConnected = true;
1796
1804
  this.emitter.emit(events.sandbox.connected);
@@ -51,24 +51,43 @@ class CommandError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Normalize redraw options from new thresholds format or legacy format.
56
+ * New: { enabled: true, thresholds: { screen: 0.05, network: true } }
57
+ * Legacy: { enabled: true, diffThreshold: 0.1, screenRedraw: true, networkMonitor: true }
58
+ * @param {Object} opts - Raw redraw options object
59
+ * @returns {Object} Normalised { enabled, screenRedraw, networkMonitor }
60
+ */
61
+ const normalizeRedrawOpts = (opts) => {
62
+ if (!opts || typeof opts !== 'object') return { enabled: !!opts };
63
+ const result = { enabled: opts.enabled !== false };
64
+ if (opts.thresholds && typeof opts.thresholds === 'object') {
65
+ result.screenRedraw = opts.thresholds.screen !== false;
66
+ result.networkMonitor = !!opts.thresholds.network;
67
+ } else {
68
+ result.screenRedraw = opts.screenRedraw !== undefined ? opts.screenRedraw : true;
69
+ result.networkMonitor = opts.networkMonitor !== undefined ? opts.networkMonitor : false;
70
+ }
71
+ return result;
72
+ };
73
+
54
74
  /**
55
75
  * Extract redraw options from command options
56
76
  * @param {Object} options - Command options that may contain redraw settings
57
77
  * @returns {Object} Redraw options object
58
78
  */
59
79
  const extractRedrawOptions = (options = {}) => {
60
- const redrawOpts = {};
61
-
62
- // Support nested redraw object: { redraw: { enabled: false, diffThreshold: 0.5 } }
80
+ // Support nested redraw object (new or legacy format)
63
81
  if (options.redraw && typeof options.redraw === 'object') {
64
- return options.redraw;
82
+ return normalizeRedrawOpts(options.redraw);
65
83
  }
66
84
 
67
- // Support flat options for convenience
85
+ // Support flat options for convenience (legacy)
86
+ const redrawOpts = {};
68
87
  if ('redrawEnabled' in options) redrawOpts.enabled = options.redrawEnabled;
69
88
  if ('redrawScreenRedraw' in options) redrawOpts.screenRedraw = options.redrawScreenRedraw;
70
89
  if ('redrawNetworkMonitor' in options) redrawOpts.networkMonitor = options.redrawNetworkMonitor;
71
- if ('redrawDiffThreshold' in options) redrawOpts.diffThreshold = options.redrawDiffThreshold;
90
+ if ('redrawDiffThreshold' in options) redrawOpts.screenRedraw = true;
72
91
 
73
92
  return redrawOpts;
74
93
  };
@@ -226,8 +245,8 @@ const createCommands = (
226
245
  const assertTimestamp = Date.now();
227
246
  const assertStartTime = assertTimestamp;
228
247
 
229
- // Extract cache and AI options
230
- const { threshold = -1, cacheKey, os, resolution, ai } = options;
248
+ // Extract cache options
249
+ const { threshold = 0.05, cacheKey, os, resolution, ai } = options;
231
250
 
232
251
  // Debug log cache settings
233
252
  emitter.emit(
@@ -318,9 +337,9 @@ const createCommands = (
318
337
  * @param {number} [options.amount=300] - Amount to scroll in pixels
319
338
  * @param {Object} [options.redraw] - Redraw detection options
320
339
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
321
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
322
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
323
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
340
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
341
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
342
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
324
343
  */
325
344
  const scroll = async (direction = 'down', options = {}) => {
326
345
  // Capture absolute timestamp at the very start of the command
@@ -461,9 +480,9 @@ const createCommands = (
461
480
  * @param {boolean} [options.selectorUsed] - Whether selector was used
462
481
  * @param {Object} [options.redraw] - Redraw detection options
463
482
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
464
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
465
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
466
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
483
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
484
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
485
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
467
486
  */
468
487
  const click = async (...args) => {
469
488
  // Capture absolute timestamp at the very start of the command
@@ -904,9 +923,9 @@ const createCommands = (
904
923
  * @param {boolean} [options.secret=false] - If true, text is treated as sensitive (not logged or stored)
905
924
  * @param {Object} [options.redraw] - Redraw detection options
906
925
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
907
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
908
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
909
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
926
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
927
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
928
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
910
929
  */
911
930
  "type": async (text, options = {}) => {
912
931
  const { formatter } = require("../../sdk-log-formatter.js");
@@ -976,9 +995,9 @@ const createCommands = (
976
995
  * @param {Object} [options] - Additional options
977
996
  * @param {Object} [options.redraw] - Redraw detection options
978
997
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
979
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
980
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
981
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
998
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
999
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
1000
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
982
1001
  */
983
1002
  "press-keys": async (keys, options = {}) => {
984
1003
  const { formatter } = require("../../sdk-log-formatter.js");
@@ -1183,9 +1202,9 @@ const createCommands = (
1183
1202
  * @param {number} [options.timeout=5000] - Timeout in milliseconds
1184
1203
  * @param {Object} [options.redraw] - Redraw detection options
1185
1204
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
1186
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
1187
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
1188
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
1205
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
1206
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
1207
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
1189
1208
  */
1190
1209
  "wait-for-text": async (...args) => {
1191
1210
  // Capture absolute timestamp at the very start of the command
@@ -1294,9 +1313,9 @@ const createCommands = (
1294
1313
  * @param {boolean} [options.invert=false] - Invert the match
1295
1314
  * @param {Object} [options.redraw] - Redraw detection options
1296
1315
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
1297
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
1298
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
1299
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
1316
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
1317
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
1318
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
1300
1319
  */
1301
1320
  "scroll-until-text": async (...args) => {
1302
1321
  let text, direction, maxDistance, invert, redrawOptions;
@@ -1448,9 +1467,9 @@ const createCommands = (
1448
1467
  * @param {Object} [options] - Additional options
1449
1468
  * @param {Object} [options.redraw] - Redraw detection options
1450
1469
  * @param {boolean} [options.redraw.enabled=true] - Enable/disable redraw detection
1451
- * @param {boolean} [options.redraw.screenRedraw=true] - Enable/disable screen redraw detection
1452
- * @param {boolean} [options.redraw.networkMonitor=true] - Enable/disable network monitoring
1453
- * @param {number} [options.redraw.diffThreshold=0.1] - Screen diff threshold percentage
1470
+ * @param {Object} [options.redraw.thresholds] - Threshold configuration
1471
+ * @param {number|boolean} [options.redraw.thresholds.screen=0.05] - Screen diff threshold (false to disable)
1472
+ * @param {boolean} [options.redraw.thresholds.network=false] - Enable/disable network monitoring
1454
1473
  */
1455
1474
  "focus-application": async (name, options = {}) => {
1456
1475
  const redrawOptions = extractRedrawOptions(options);
@@ -348,9 +348,11 @@ const createRedraw = (
348
348
  });
349
349
  resolve("true");
350
350
  } else {
351
+ // Poll at 500ms intervals to reduce websocket traffic
352
+ // (previously 250ms = up to 20 polls per 5s timeout, now max 10)
351
353
  setTimeout(() => {
352
354
  checkCondition(resolve, startTime, timeoutMs, options);
353
- }, 250);
355
+ }, 500);
354
356
  }
355
357
  }
356
358
 
@@ -39,10 +39,14 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
39
39
  this.sessionInstance = sessionInstance; // Store session instance to include in messages
40
40
  this.traceId = null; // Sentry trace ID for debugging
41
41
  this.reconnectAttempts = 0;
42
- this.maxReconnectAttempts = 5;
42
+ this.maxReconnectAttempts = 10;
43
43
  this.intentionalDisconnect = false;
44
44
  this.apiRoot = null;
45
45
  this.apiKey = null;
46
+ this.reconnectTimer = null; // Track reconnect setTimeout
47
+ this.reconnecting = false; // Prevent duplicate reconnection attempts
48
+ this.pendingTimeouts = new Map(); // Track per-message timeouts
49
+ this.pendingRetryQueue = []; // Queue of requests to retry after reconnection
46
50
  }
47
51
 
48
52
  /**
@@ -89,6 +93,12 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
89
93
  }
90
94
  }
91
95
 
96
+ // Add sandboxId to every message if we have a connected sandbox
97
+ // This allows the API to reconnect if the connection was rerouted
98
+ if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
99
+ message.sandboxId = this._lastConnectParams.sandboxId;
100
+ }
101
+
92
102
  let p = new Promise((resolve, reject) => {
93
103
  this.socket.send(JSON.stringify(message));
94
104
  emitter.emit(events.sandbox.sent, message);
@@ -100,6 +110,7 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
100
110
 
101
111
  // Set up timeout to prevent hanging requests
102
112
  const timeoutId = setTimeout(() => {
113
+ this.pendingTimeouts.delete(requestId);
103
114
  if (this.ps[requestId]) {
104
115
  delete this.ps[requestId];
105
116
  rejectPromise(
@@ -110,20 +121,32 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
110
121
  }
111
122
  }, timeout);
112
123
 
124
+ // Track timeout so close() can clear it
125
+ this.pendingTimeouts.set(requestId, timeoutId);
126
+
113
127
  this.ps[requestId] = {
114
128
  promise: p,
115
129
  resolve: (result) => {
116
130
  clearTimeout(timeoutId);
131
+ this.pendingTimeouts.delete(requestId);
117
132
  resolvePromise(result);
118
133
  },
119
134
  reject: (error) => {
120
135
  clearTimeout(timeoutId);
136
+ this.pendingTimeouts.delete(requestId);
121
137
  rejectPromise(error);
122
138
  },
123
139
  message,
124
140
  startTime: Date.now(),
125
141
  };
126
142
 
143
+ // Fire-and-forget message types: attach .catch() to prevent
144
+ // unhandled promise rejections if nobody awaits the result
145
+ const fireAndForgetTypes = ["output", "trackInteraction"];
146
+ if (fireAndForgetTypes.includes(message.type)) {
147
+ p.catch(() => {});
148
+ }
149
+
127
150
  return p;
128
151
  }
129
152
 
@@ -156,6 +179,9 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
156
179
  }
157
180
 
158
181
  async connect(sandboxId, persist = false, keepAlive = null) {
182
+ // Store connection params so we can re-establish after reconnection
183
+ this._lastConnectParams = { sandboxId, persist, keepAlive };
184
+
159
185
  let reply = await this.send({
160
186
  type: "connect",
161
187
  persist,
@@ -177,34 +203,129 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
177
203
  async handleConnectionLoss() {
178
204
  if (this.intentionalDisconnect) return;
179
205
 
206
+ // Prevent duplicate reconnection attempts (both 'error' and 'close' fire)
207
+ if (this.reconnecting) return;
208
+ this.reconnecting = true;
209
+
210
+ // Remove listeners from the old socket to prevent "No pending promise found" warnings
211
+ // when late responses arrive on the dying connection
212
+ if (this.socket) {
213
+ try {
214
+ this.socket.removeAllListeners("message");
215
+ } catch (e) {
216
+ // Ignore errors removing listeners from closed socket
217
+ }
218
+ }
219
+
220
+ // Queue pending requests for retry after reconnection
221
+ // (they were sent on the old socket and will never receive responses)
222
+ const pendingRequestIds = Object.keys(this.ps);
223
+ if (pendingRequestIds.length > 0) {
224
+ console.log(`[Sandbox] Queuing ${pendingRequestIds.length} pending request(s) for retry after reconnection`);
225
+ for (const requestId of pendingRequestIds) {
226
+ const pending = this.ps[requestId];
227
+ if (pending) {
228
+ // Clear the timeout - we'll set a new one when we retry
229
+ const timeoutId = this.pendingTimeouts.get(requestId);
230
+ if (timeoutId) {
231
+ clearTimeout(timeoutId);
232
+ this.pendingTimeouts.delete(requestId);
233
+ }
234
+ // Queue for retry (store message and promise handlers)
235
+ this.pendingRetryQueue.push({
236
+ message: pending.message,
237
+ resolve: pending.resolve,
238
+ reject: pending.reject,
239
+ });
240
+ }
241
+ }
242
+ this.ps = {};
243
+ }
244
+
245
+ // Cancel any existing reconnect timer
246
+ if (this.reconnectTimer) {
247
+ clearTimeout(this.reconnectTimer);
248
+ this.reconnectTimer = null;
249
+ }
250
+
180
251
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
181
252
  const errorMsg =
182
253
  "Unable to reconnect to TestDriver sandbox after multiple attempts. Please check your internet connection.";
183
254
  emitter.emit(events.error.sandbox, errorMsg);
184
255
  console.error(errorMsg);
256
+
257
+ // Reject all queued requests since reconnection failed
258
+ if (this.pendingRetryQueue.length > 0) {
259
+ console.log(`[Sandbox] Rejecting ${this.pendingRetryQueue.length} queued request(s) - reconnection failed`);
260
+ for (const queued of this.pendingRetryQueue) {
261
+ queued.reject(new Error("Sandbox reconnection failed after multiple attempts"));
262
+ }
263
+ this.pendingRetryQueue = [];
264
+ }
265
+
266
+ this.reconnecting = false;
185
267
  return;
186
268
  }
187
269
 
188
270
  this.reconnectAttempts++;
189
- const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 30000);
271
+ const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 60000);
190
272
 
191
273
  console.log(
192
274
  `[Sandbox] Connection lost. Reconnecting in ${delay}ms... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
193
275
  );
194
276
 
195
- setTimeout(async () => {
277
+ this.reconnectTimer = setTimeout(async () => {
278
+ this.reconnectTimer = null;
196
279
  try {
197
280
  await this.boot(this.apiRoot);
198
281
  if (this.apiKey) {
199
282
  await this.auth(this.apiKey);
200
283
  }
284
+ // Re-establish sandbox connection on the new API instance
285
+ // Without this, the new API instance has no connection.desktop
286
+ // and all Linux operations will fail with "sandbox not initialized"
287
+ if (this._lastConnectParams) {
288
+ const { sandboxId, persist, keepAlive } = this._lastConnectParams;
289
+ console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
290
+ await this.connect(sandboxId, persist, keepAlive);
291
+ }
201
292
  console.log("[Sandbox] Reconnected successfully.");
293
+
294
+ // Retry queued requests
295
+ await this._retryQueuedRequests();
202
296
  } catch (e) {
203
- // Ignore error here as the boot's error handler will trigger handleConnectionLoss again
297
+ // boot's close handler will trigger handleConnectionLoss again
298
+ } finally {
299
+ this.reconnecting = false;
204
300
  }
205
301
  }, delay);
206
302
  }
207
303
 
304
+ /**
305
+ * Retry queued requests after successful reconnection
306
+ * @private
307
+ */
308
+ async _retryQueuedRequests() {
309
+ if (this.pendingRetryQueue.length === 0) return;
310
+
311
+ console.log(`[Sandbox] Retrying ${this.pendingRetryQueue.length} queued request(s)...`);
312
+
313
+ // Take all queued requests and clear the queue
314
+ const toRetry = this.pendingRetryQueue.splice(0);
315
+
316
+ for (const queued of toRetry) {
317
+ try {
318
+ // Re-send the message and resolve/reject the original promise
319
+ const result = await this.send(queued.message);
320
+ queued.resolve(result);
321
+ } catch (err) {
322
+ queued.reject(err);
323
+ }
324
+ }
325
+
326
+ console.log(`[Sandbox] Finished retrying queued requests.`);
327
+ }
328
+
208
329
  async boot(apiRoot) {
209
330
  if (apiRoot) this.apiRoot = apiRoot;
210
331
  return new Promise((resolve, reject) => {
@@ -233,10 +354,11 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
233
354
  // handle errors
234
355
  this.socket.on("close", () => {
235
356
  clearInterval(this.heartbeat);
236
- // Emit a clear error event for API key issues
237
- reject();
238
357
  this.apiSocketConnected = false;
358
+ // Reset reconnecting flag so handleConnectionLoss can run for this new disconnection
359
+ this.reconnecting = false;
239
360
  this.handleConnectionLoss();
361
+ reject();
240
362
  });
241
363
 
242
364
  this.socket.on("error", (err) => {
@@ -245,13 +367,14 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
245
367
  clearInterval(this.heartbeat);
246
368
  emitter.emit(events.error.sandbox, err);
247
369
  this.apiSocketConnected = false;
248
- this.handleConnectionLoss();
249
- // We don't throw here to avoid crashing the process, let reconnection handle it
370
+ // Don't call handleConnectionLoss here - the 'close' event always fires
371
+ // after 'error', so let 'close' handle reconnection to avoid duplicate attempts
250
372
  reject(err);
251
373
  });
252
374
 
253
375
  this.socket.on("open", async () => {
254
376
  this.reconnectAttempts = 0;
377
+ this.reconnecting = false;
255
378
  this.apiSocketConnected = true;
256
379
 
257
380
  this.heartbeat = setInterval(() => {
@@ -259,6 +382,10 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
259
382
  this.socket.ping();
260
383
  }
261
384
  }, 5000);
385
+ // Don't let the heartbeat interval prevent Node process from exiting
386
+ if (this.heartbeat.unref) {
387
+ this.heartbeat.unref();
388
+ }
262
389
 
263
390
  resolve(this);
264
391
  });
@@ -276,15 +403,24 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
276
403
  }
277
404
 
278
405
  if (!this.ps[message.requestId]) {
279
- console.warn(
280
- "No pending promise found for requestId:",
281
- message.requestId,
282
- );
406
+ // This can happen during reconnection (ps was cleared) or after timeout
407
+ // (promise was deleted). Only log at debug level since it's expected.
408
+ if (!this.reconnecting) {
409
+ console.warn(
410
+ "No pending promise found for requestId:",
411
+ message.requestId,
412
+ );
413
+ }
283
414
  return;
284
415
  }
285
416
 
286
417
  if (message.error) {
287
- emitter.emit(events.error.sandbox, message.errorMessage);
418
+ // Don't emit error:sandbox for output (log forwarding) messages
419
+ // to prevent infinite loops: error → log → sendToSandbox → error → ...
420
+ const pendingMessage = this.ps[message.requestId]?.message;
421
+ if (pendingMessage?.type !== "output") {
422
+ emitter.emit(events.error.sandbox, message.errorMessage);
423
+ }
288
424
  const error = new Error(message.errorMessage || "Sandbox error");
289
425
  error.responseData = message;
290
426
  this.ps[message.requestId].reject(error);
@@ -302,12 +438,27 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
302
438
  */
303
439
  close() {
304
440
  this.intentionalDisconnect = true;
441
+ this.reconnecting = false;
442
+ // Cancel any pending reconnect timer
443
+ if (this.reconnectTimer) {
444
+ clearTimeout(this.reconnectTimer);
445
+ this.reconnectTimer = null;
446
+ }
447
+
305
448
  if (this.heartbeat) {
306
449
  clearInterval(this.heartbeat);
307
450
  this.heartbeat = null;
308
451
  }
309
452
 
453
+ // Clear all pending message timeouts to prevent timers keeping the process alive
454
+ for (const timeoutId of this.pendingTimeouts.values()) {
455
+ clearTimeout(timeoutId);
456
+ }
457
+ this.pendingTimeouts.clear();
458
+
310
459
  if (this.socket) {
460
+ // Remove all listeners before closing to prevent reconnect attempts
461
+ this.socket.removeAllListeners();
311
462
  try {
312
463
  this.socket.close();
313
464
  } catch (err) {
@@ -321,9 +472,10 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
321
472
  this.authenticated = false;
322
473
  this.instance = null;
323
474
 
324
- // Silently clear pending promises without rejecting
475
+ // Silently clear pending promises and retry queue without rejecting
325
476
  // (rejecting causes unhandled promise rejections during cleanup)
326
477
  this.ps = {};
478
+ this.pendingRetryQueue = [];
327
479
  }
328
480
  }
329
481