testdriverai 7.2.3 → 7.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.github/workflows/publish.yaml +15 -7
  2. package/.github/workflows/testdriver.yml +163 -0
  3. package/.testdriver/last-sandbox +7 -0
  4. package/agent/events.js +1 -0
  5. package/agent/index.js +99 -163
  6. package/agent/lib/sandbox.js +11 -1
  7. package/agents.md +393 -0
  8. package/bin/testdriverai.js +8 -0
  9. package/debug/01-table-initial.png +0 -0
  10. package/debug/02-after-ai-explore.png +0 -0
  11. package/debug/02-after-scroll.png +0 -0
  12. package/debugger/index.html +37 -0
  13. package/docs/docs.json +93 -125
  14. package/docs/v7/_drafts/architecture.mdx +1 -26
  15. package/docs/v7/_drafts/caching.mdx +2 -2
  16. package/docs/v7/{getting-started → _drafts}/installation.mdx +0 -66
  17. package/docs/v7/{features/coverage.mdx → _drafts/powerful.mdx} +1 -90
  18. package/docs/v7/_drafts/quick-start-test-recording.mdx +0 -1
  19. package/docs/v7/{features → _drafts}/scalable.mdx +126 -4
  20. package/docs/v7/_drafts/screenshot.mdx +155 -0
  21. package/docs/v7/_drafts/test-recording.mdx +0 -6
  22. package/docs/v7/_drafts/writing-tests.mdx +25 -0
  23. package/docs/v7/{api/act.mdx → ai.mdx} +28 -27
  24. package/docs/v7/{api/assert.mdx → assert.mdx} +3 -3
  25. package/docs/v7/aws-setup.mdx +338 -0
  26. package/docs/v7/caching.mdx +128 -0
  27. package/docs/v7/ci-cd.mdx +605 -0
  28. package/docs/v7/{api/click.mdx → click.mdx} +4 -4
  29. package/docs/v7/cloud.mdx +120 -0
  30. package/docs/v7/customizing-devices.mdx +129 -0
  31. package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
  32. package/docs/v7/enterprise.mdx +135 -0
  33. package/docs/v7/examples.mdx +5 -0
  34. package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
  35. package/docs/v7/{api/find.mdx → find.mdx} +17 -21
  36. package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
  37. package/docs/v7/generating-tests.mdx +32 -0
  38. package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
  39. package/docs/v7/locating-elements.mdx +71 -0
  40. package/docs/v7/making-assertions.mdx +32 -0
  41. package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
  42. package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
  43. package/docs/v7/performing-actions.mdx +51 -0
  44. package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
  45. package/docs/v7/quickstart.mdx +162 -0
  46. package/docs/v7/reusable-code.mdx +240 -0
  47. package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
  48. package/docs/v7/running-tests.mdx +181 -0
  49. package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
  50. package/docs/v7/secrets.mdx +115 -0
  51. package/docs/v7/self-hosted.mdx +66 -0
  52. package/docs/v7/{api/type.mdx → type.mdx} +3 -3
  53. package/docs/v7/variables.mdx +111 -0
  54. package/docs/v7/waiting-for-elements.mdx +66 -0
  55. package/docs/v7/what-is-testdriver.mdx +54 -0
  56. package/interfaces/cli/commands/init.js +33 -19
  57. package/interfaces/cli/lib/base.js +24 -0
  58. package/interfaces/cli.js +8 -1
  59. package/interfaces/logger.js +8 -3
  60. package/interfaces/vitest-plugin.mjs +16 -71
  61. package/lib/sentry.js +343 -0
  62. package/lib/vitest/hooks.mjs +81 -81
  63. package/package.json +4 -3
  64. package/sdk-log-formatter.js +41 -0
  65. package/sdk.d.ts +22 -9
  66. package/sdk.js +344 -100
  67. package/test/manual/reconnect-provision.test.mjs +49 -0
  68. package/test/manual/reconnect-signin.test.mjs +41 -0
  69. package/test/testdriver/act.test.mjs +30 -0
  70. package/test/testdriver/ai.test.mjs +30 -0
  71. package/test/testdriver/assert.test.mjs +1 -1
  72. package/test/testdriver/hover-text.test.mjs +1 -1
  73. package/test/testdriver/setup/testHelpers.mjs +8 -119
  74. package/test/testdriver/windows-installer.test.mjs +61 -0
  75. package/tests/example.test.js +33 -0
  76. package/tests/login.js +28 -0
  77. package/tests/table-sort-enrollments.test.mjs +72 -0
  78. package/tests/table-sort-experiment.test.mjs +42 -0
  79. package/tests/table-sort-setup.test.mjs +59 -0
  80. package/vitest.config.mjs +3 -1
  81. package/agent/lib/cache.js +0 -142
  82. package/docs/v7/api/assertions.mdx +0 -403
  83. package/docs/v7/features/ai-native.mdx +0 -413
  84. package/docs/v7/features/application-logs.mdx +0 -353
  85. package/docs/v7/features/browser-logs.mdx +0 -414
  86. package/docs/v7/features/cache-management.mdx +0 -402
  87. package/docs/v7/features/continuous-testing.mdx +0 -346
  88. package/docs/v7/features/data-driven-testing.mdx +0 -441
  89. package/docs/v7/features/easy-to-write.mdx +0 -280
  90. package/docs/v7/features/enterprise.mdx +0 -656
  91. package/docs/v7/features/fast.mdx +0 -406
  92. package/docs/v7/features/managed-sandboxes.mdx +0 -384
  93. package/docs/v7/features/network-monitoring.mdx +0 -568
  94. package/docs/v7/features/parallel-execution.mdx +0 -381
  95. package/docs/v7/features/powerful.mdx +0 -531
  96. package/docs/v7/features/sandbox-customization.mdx +0 -229
  97. package/docs/v7/features/stable.mdx +0 -473
  98. package/docs/v7/features/system-performance.mdx +0 -616
  99. package/docs/v7/features/test-analytics.mdx +0 -373
  100. package/docs/v7/features/test-cases.mdx +0 -393
  101. package/docs/v7/features/test-replays.mdx +0 -408
  102. package/docs/v7/features/test-reports.mdx +0 -308
  103. package/docs/v7/getting-started/debugging-tests.mdx +0 -382
  104. package/docs/v7/getting-started/quickstart.mdx +0 -90
  105. package/docs/v7/getting-started/running-tests.mdx +0 -173
  106. package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
  107. package/docs/v7/getting-started/writing-tests.mdx +0 -534
  108. package/docs/v7/overview/what-is-testdriver.mdx +0 -386
  109. package/docs/v7/presets/chrome-extension.mdx +0 -248
  110. package/docs/v7/presets/chrome.mdx +0 -300
  111. package/docs/v7/presets/electron.mdx +0 -460
  112. package/docs/v7/presets/vscode.mdx +0 -417
  113. package/docs/v7/presets/webapp.mdx +0 -393
  114. /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
  115. /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
  116. /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
  117. /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
  118. /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
  119. /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
  120. /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
  121. /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
  122. /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
  123. /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
  124. /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
  125. /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
  126. /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
  127. /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
  128. /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
  129. /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
  130. /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
  131. /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
  132. /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
  133. /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
  134. /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
  135. /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
  136. /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
  137. /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
  138. /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
  139. /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
  140. /package/docs/v7/{api/dashcam.mdx → dashcam.mdx} +0 -0
  141. /package/docs/v7/{api/elements.mdx → elements.mdx} +0 -0
  142. /package/docs/v7/{api/sandbox.mdx → sandbox.mdx} +0 -0
@@ -1,8 +1,6 @@
1
1
  import { execSync } from "child_process";
2
2
  import crypto from "crypto";
3
- import fs from "fs";
4
3
  import { createRequire } from "module";
5
- import os from "os";
6
4
  import path from "path";
7
5
  import { setTestRunInfo } from "./shared-test-state.mjs";
8
6
 
@@ -662,76 +660,19 @@ class TestDriverReporter {
662
660
 
663
661
  logger.debug(`Calculated duration: ${duration}ms (startTime: ${testCase?.startTime}, now: ${Date.now()})`);
664
662
 
665
- // Read test metadata from file (cross-process communication)
666
- let dashcamUrl = null;
667
- let sessionId = null;
668
- let testFile = "unknown";
669
- let testOrder = 0;
663
+ // Read test metadata from Vitest's task.meta (set in test hooks)
664
+ const meta = test.meta();
665
+ logger.debug(`Test meta for ${test.id}:`, meta);
670
666
 
671
- const testResultFile = path.join(
672
- os.tmpdir(),
673
- "testdriver-results",
674
- `${test.id}.json`,
675
- );
676
-
677
- logger.debug(`Looking for test result file with test.id: ${test.id}`);
678
- logger.debug(`Test result file path: ${testResultFile}`);
679
-
680
- try {
681
- if (fs.existsSync(testResultFile)) {
682
- const testResult = JSON.parse(fs.readFileSync(testResultFile, "utf-8"));
683
- dashcamUrl = testResult.dashcamUrl || null;
684
- const platform = testResult.platform || null;
685
- sessionId = testResult.sessionId || null;
686
- const absolutePath =
687
- testResult.testFile ||
688
- test.file?.filepath ||
689
- test.file?.name ||
690
- "unknown";
691
- // Make path relative to project root
692
- testFile = pluginState.projectRoot && absolutePath !== "unknown"
693
- ? path.relative(pluginState.projectRoot, absolutePath)
694
- : absolutePath;
695
- testOrder =
696
- testResult.testOrder !== undefined ? testResult.testOrder : 0;
697
- // Don't override duration from file - use Vitest's result.duration
698
- // duration is already set above from result.duration
699
-
700
- // Update test run platform from first test that reports it
701
- if (platform && !pluginState.detectedPlatform) {
702
- pluginState.detectedPlatform = platform;
703
- }
667
+ const dashcamUrl = meta.dashcamUrl || null;
668
+ const sessionId = meta.sessionId || null;
669
+ const platform = meta.platform || null;
670
+ const sandboxId = meta.sandboxId || null;
671
+ let testFile = meta.testFile || "unknown";
672
+ const testOrder = meta.testOrder !== undefined ? meta.testOrder : 0;
704
673
 
705
- // Clean up the file after reading
706
- try {
707
- fs.unlinkSync(testResultFile);
708
- } catch {
709
- // Ignore cleanup errors
710
- }
711
- } else {
712
- logger.debug(`No result file found for test: ${test.id}`);
713
- // Fallback to test object properties - try multiple sources
714
- // In Vitest, the file path is on test.module.task.filepath
715
- const absolutePath =
716
- test.module?.task?.filepath ||
717
- test.module?.file?.filepath ||
718
- test.module?.file?.name ||
719
- test.file?.filepath ||
720
- test.file?.name ||
721
- test.suite?.file?.filepath ||
722
- test.suite?.file?.name ||
723
- test.location?.file ||
724
- "unknown";
725
- // Make path relative to project root
726
- testFile = pluginState.projectRoot && absolutePath !== "unknown"
727
- ? path.relative(pluginState.projectRoot, absolutePath)
728
- : absolutePath;
729
- logger.debug(`Resolved testFile: ${testFile}`);
730
- }
731
- } catch (error) {
732
- logger.error("Failed to read test result file:", error.message);
733
- // Fallback to test object properties - try multiple sources
734
- // In Vitest, the file path is on test.module.task.filepath
674
+ // If testFile not in meta, fallback to test object properties
675
+ if (testFile === "unknown") {
735
676
  const absolutePath =
736
677
  test.module?.task?.filepath ||
737
678
  test.module?.file?.filepath ||
@@ -742,13 +683,17 @@ class TestDriverReporter {
742
683
  test.suite?.file?.name ||
743
684
  test.location?.file ||
744
685
  "unknown";
745
- // Make path relative to project root
746
686
  testFile = pluginState.projectRoot && absolutePath !== "unknown"
747
687
  ? path.relative(pluginState.projectRoot, absolutePath)
748
688
  : absolutePath;
749
689
  logger.debug(`Resolved testFile from fallback: ${testFile}`);
750
690
  }
751
691
 
692
+ // Update test run platform from first test that reports it
693
+ if (platform && !pluginState.detectedPlatform) {
694
+ pluginState.detectedPlatform = platform;
695
+ }
696
+
752
697
  // Get test run info from environment variables
753
698
  const testRunId = process.env.TD_TEST_RUN_ID;
754
699
  const token = process.env.TD_TEST_RUN_TOKEN;
package/lib/sentry.js ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Sentry initialization for TestDriver CLI
3
+ *
4
+ * This module initializes Sentry for error tracking and performance monitoring.
5
+ * It should be required at the very beginning of the CLI entry point.
6
+ *
7
+ * Distributed Tracing:
8
+ * The CLI uses session-based trace IDs (MD5 hash of session ID) to link
9
+ * CLI traces with API traces. Call setSessionTraceContext() after establishing
10
+ * a session to ensure all CLI errors/logs are linked to the same trace.
11
+ */
12
+
13
+ const Sentry = require("@sentry/node");
14
+ const crypto = require("crypto");
15
+ const os = require("os");
16
+ const { version } = require("../package.json");
17
+
18
+ // Store the current session's trace context
19
+ let currentTraceId = null;
20
+ let currentSessionId = null;
21
+
22
+ // Track if we've attached listeners to avoid duplicates
23
+ let emitterAttached = false;
24
+
25
+ const isEnabled = () => {
26
+
27
+ // Disable if explicitly disabled
28
+ if (process.env.TD_TELEMETRY === "false") {
29
+ return false;
30
+ }
31
+ return true;
32
+ };
33
+
34
+ if (isEnabled()) {
35
+ Sentry.init({
36
+ dsn:
37
+ process.env.SENTRY_DSN ||
38
+ "https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
39
+ environment: process.env.NODE_ENV || "development",
40
+ release: `testdriverai@${version}`,
41
+ sampleRate: 1.0,
42
+ tracesSampleRate: 1.0, // Sample 20% of transactions for performance
43
+ enableLogs: true,
44
+ integrations: [
45
+ Sentry.httpIntegration(),
46
+ Sentry.nodeContextIntegration(),
47
+ ],
48
+ // Set initial context
49
+ initialScope: {
50
+ tags: {
51
+ platform: os.platform(),
52
+ arch: os.arch(),
53
+ nodeVersion: process.version,
54
+ },
55
+ },
56
+ // Filter out common non-errors
57
+ beforeSend(event, hint) {
58
+
59
+ console.log('sending sentry event', event);
60
+
61
+ const error = hint.originalException;
62
+
63
+ // Don't send user-initiated exits
64
+ if (error && error.message && error.message.includes("User cancelled")) {
65
+ return null;
66
+ }
67
+
68
+ return event;
69
+ },
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Set user context for Sentry
75
+ * @param {Object} user - User object with id, email, etc.
76
+ */
77
+ function setUser(user) {
78
+ if (!isEnabled()) return;
79
+ Sentry.setUser(user);
80
+ }
81
+
82
+ /**
83
+ * Set additional context
84
+ * @param {string} name - Context name
85
+ * @param {Object} context - Context data
86
+ */
87
+ function setContext(name, context) {
88
+ if (!isEnabled()) return;
89
+ Sentry.setContext(name, context);
90
+ }
91
+
92
+ /**
93
+ * Set a tag
94
+ * @param {string} key - Tag key
95
+ * @param {string} value - Tag value
96
+ */
97
+ function setTag(key, value) {
98
+ if (!isEnabled()) return;
99
+ Sentry.setTag(key, value);
100
+ }
101
+
102
+ /**
103
+ * Capture an exception
104
+ * @param {Error} error - The error to capture
105
+ * @param {Object} context - Additional context
106
+ */
107
+ function captureException(error, context = {}) {
108
+ if (!isEnabled()) return;
109
+
110
+ Sentry.withScope((scope) => {
111
+ // Link to session trace if available
112
+ if (currentTraceId && currentSessionId) {
113
+ scope.setTag("session", currentSessionId);
114
+ scope.setContext("trace", {
115
+ trace_id: currentTraceId,
116
+ session_id: currentSessionId,
117
+ });
118
+ }
119
+
120
+ if (context.tags) {
121
+ Object.entries(context.tags).forEach(([key, value]) => {
122
+ scope.setTag(key, value);
123
+ });
124
+ }
125
+ if (context.extra) {
126
+ Object.entries(context.extra).forEach(([key, value]) => {
127
+ scope.setExtra(key, value);
128
+ });
129
+ }
130
+ Sentry.captureException(error);
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Capture a message
136
+ * @param {string} message - The message to capture
137
+ * @param {string} level - Severity level (info, warning, error)
138
+ */
139
+ function captureMessage(message, level = "info") {
140
+ if (!isEnabled()) return;
141
+
142
+ Sentry.withScope((scope) => {
143
+ // Link to session trace if available
144
+ if (currentTraceId && currentSessionId) {
145
+ scope.setTag("session", currentSessionId);
146
+ scope.setContext("trace", {
147
+ trace_id: currentTraceId,
148
+ session_id: currentSessionId,
149
+ });
150
+ }
151
+ Sentry.captureMessage(message, level);
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Set the session trace context for distributed tracing
157
+ * This links CLI errors/logs to the same trace as API calls
158
+ * @param {string} sessionId - The session ID
159
+ */
160
+ function setSessionTraceContext(sessionId) {
161
+ if (!isEnabled() || !sessionId) return;
162
+
163
+ // Derive trace ID from session ID (same algorithm as API)
164
+ currentTraceId = crypto.createHash("md5").update(sessionId).digest("hex");
165
+ currentSessionId = sessionId;
166
+
167
+ // Set as global tag so all events include it
168
+ Sentry.setTag("session", sessionId);
169
+ Sentry.setTag("trace_id", currentTraceId);
170
+
171
+ // Try to set propagation context for trace linking (may not be available in all versions)
172
+ try {
173
+ const scope = Sentry.getCurrentScope();
174
+ if (scope && typeof scope.setPropagationContext === 'function') {
175
+ scope.setPropagationContext({
176
+ traceId: currentTraceId,
177
+ spanId: currentTraceId.substring(0, 16),
178
+ sampled: true,
179
+ });
180
+ }
181
+ } catch (e) {
182
+ // Ignore errors - propagation context may not be supported
183
+ console.log('Could not set propagation context:', e.message);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Clear the session trace context
189
+ */
190
+ function clearSessionTraceContext() {
191
+ currentTraceId = null;
192
+ currentSessionId = null;
193
+ }
194
+
195
+ /**
196
+ * Get the current trace ID (for debugging)
197
+ * @returns {string|null} Current trace ID or null
198
+ */
199
+ function getTraceId() {
200
+ return currentTraceId;
201
+ }
202
+
203
+ /**
204
+ * Attach log listeners to an emitter to capture CLI logs as Sentry breadcrumbs
205
+ * @param {EventEmitter} emitter - The event emitter to listen to
206
+ */
207
+ function attachLogListeners(emitter) {
208
+
209
+ if (!isEnabled() || !emitter || emitterAttached) return;
210
+
211
+ // Check if Sentry.logger is available
212
+ if (!Sentry.logger) {
213
+ console.log('Sentry.logger not available, skipping log listeners');
214
+ return;
215
+ }
216
+
217
+ emitterAttached = true;
218
+
219
+ // Helper to strip ANSI codes for cleaner logs
220
+ const stripAnsi = (str) => {
221
+ if (typeof str !== 'string') return String(str);
222
+ return str.replace(/\x1B[[(?);]{0,2}(;?\d)*./g, '');
223
+ };
224
+
225
+ // Helper to get current log attributes with trace context
226
+ const getLogAttributes = (extra = {}) => {
227
+ const attrs = { ...extra };
228
+ if (currentSessionId) {
229
+ attrs['session.id'] = currentSessionId;
230
+ }
231
+ if (currentTraceId) {
232
+ attrs['sentry.trace.trace_id'] = currentTraceId;
233
+ }
234
+ // Get current user from Sentry scope
235
+ try {
236
+ const user = Sentry.getCurrentScope().getUser();
237
+ if (user) {
238
+ if (user.id) attrs['user.id'] = user.id;
239
+ if (user.email) attrs['user.email'] = user.email;
240
+ if (user.username) attrs['user.name'] = user.username;
241
+ }
242
+ } catch (e) {
243
+ // Ignore errors getting user
244
+ }
245
+ return attrs;
246
+ };
247
+
248
+ // Capture log:log as info logs
249
+ emitter.on('log:log', (message) => {
250
+ Sentry.logger.info(stripAnsi(message), getLogAttributes({ category: 'cli.log' }));
251
+ });
252
+
253
+ // Capture log:warn as warning logs
254
+ emitter.on('log:warn', (message) => {
255
+ Sentry.logger.warn(stripAnsi(message), getLogAttributes({ category: 'cli.warn' }));
256
+ });
257
+
258
+ // Capture log:debug as debug logs (only in verbose mode)
259
+ if (process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG) {
260
+ emitter.on('log:debug', (message) => {
261
+ Sentry.logger.debug(stripAnsi(message), getLogAttributes({ category: 'cli.debug' }));
262
+ });
263
+ }
264
+
265
+ // Capture command events
266
+ emitter.on('command:start', (data) => {
267
+ Sentry.logger.info(`Command started: ${data?.command || data?.name || 'unknown'}`, getLogAttributes({
268
+ category: 'cli.command',
269
+ ...data,
270
+ }));
271
+ });
272
+
273
+ emitter.on('command:error', (data) => {
274
+ Sentry.logger.error(`Command error: ${data?.message || data?.error || 'unknown'}`, getLogAttributes({
275
+ category: 'cli.command',
276
+ ...data,
277
+ }));
278
+ });
279
+
280
+ // Capture step events
281
+ emitter.on('step:start', (data) => {
282
+ Sentry.logger.info(`Step started: ${data?.step || data?.name || 'unknown'}`, getLogAttributes({
283
+ category: 'cli.step',
284
+ }));
285
+ });
286
+
287
+ emitter.on('step:error', (data) => {
288
+ Sentry.logger.error(`Step error: ${data?.message || data?.error || 'unknown'}`, getLogAttributes({
289
+ category: 'cli.step',
290
+ ...data,
291
+ }));
292
+ });
293
+
294
+ // Capture test events
295
+ emitter.on('test:start', (data) => {
296
+ Sentry.logger.info(`Test started: ${data?.name || 'unknown'}`, getLogAttributes({
297
+ category: 'cli.test',
298
+ }));
299
+ });
300
+
301
+ emitter.on('test:error', (data) => {
302
+ Sentry.logger.error(`Test error: ${data?.message || data?.error || 'unknown'}`, getLogAttributes({
303
+ category: 'cli.test',
304
+ ...data,
305
+ }));
306
+ });
307
+ }
308
+
309
+ /**
310
+ * Start a new transaction for performance monitoring
311
+ * @param {string} name - Transaction name
312
+ * @param {string} op - Operation type
313
+ * @returns {Object} Transaction object
314
+ */
315
+ function startTransaction(name, op = "cli") {
316
+ if (!isEnabled()) return null;
317
+ return Sentry.startSpan({ name, op });
318
+ }
319
+
320
+ /**
321
+ * Flush pending events before process exit
322
+ * @param {number} timeout - Timeout in milliseconds
323
+ */
324
+ async function flush(timeout = 2000) {
325
+ if (!isEnabled()) return;
326
+ await Sentry.flush(timeout);
327
+ }
328
+
329
+ module.exports = {
330
+ Sentry,
331
+ isEnabled,
332
+ setUser,
333
+ setContext,
334
+ setTag,
335
+ captureException,
336
+ captureMessage,
337
+ setSessionTraceContext,
338
+ clearSessionTraceContext,
339
+ getTraceId,
340
+ attachLogListeners,
341
+ startTransaction,
342
+ flush,
343
+ };
@@ -16,8 +16,6 @@
16
16
  */
17
17
 
18
18
  import chalk from 'chalk';
19
- import fs from 'fs';
20
- import os from 'os';
21
19
  import path from 'path';
22
20
  import { vi } from 'vitest';
23
21
  import TestDriverSDK from '../../sdk.js';
@@ -137,6 +135,7 @@ const lifecycleHandlers = new WeakMap();
137
135
  * @param {string} [options.apiKey] - TestDriver API key (defaults to process.env.TD_API_KEY)
138
136
  * @param {boolean} [options.headless] - Run sandbox in headless mode
139
137
  * @param {boolean} [options.newSandbox] - Create new sandbox
138
+ * @param {number} [options.timeout=0] - Sandbox timeout (TTL) in milliseconds. 0 = use provider default (5 min for E2B Linux)
140
139
  * @param {boolean} [options.autoConnect=true] - Automatically connect to sandbox
141
140
  * @returns {TestDriver} TestDriver client instance
142
141
  *
@@ -155,9 +154,16 @@ export function TestDriver(context, options = {}) {
155
154
  throw new Error('TestDriver() requires Vitest context. Pass the context parameter from your test function: test("name", async (context) => { ... })');
156
155
  }
157
156
 
158
- // 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
159
159
  if (testDriverInstances.has(context.task)) {
160
- 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);
161
167
  }
162
168
 
163
169
  // Get global plugin options if available
@@ -195,7 +201,6 @@ export function TestDriver(context, options = {}) {
195
201
 
196
202
  if (autoConnect) {
197
203
  testdriver.__connectionPromise = (async () => {
198
- console.log('[testdriver] Connecting to sandbox...');
199
204
  if (debugConsoleSpy) {
200
205
  console.log('[DEBUG] Before auth - sandbox.instanceSocketConnected:', testdriver.sandbox?.instanceSocketConnected);
201
206
  }
@@ -232,85 +237,80 @@ export function TestDriver(context, options = {}) {
232
237
  }
233
238
 
234
239
  // Register cleanup handler with dashcam.stop()
235
- if (!lifecycleHandlers.has(context.task)) {
236
- const cleanup = async () => {
237
- try {
238
- // Stop dashcam if it was started - with timeout to prevent hanging
239
- if (testdriver._dashcam && testdriver._dashcam.recording) {
240
- try {
241
- const dashcamUrl = await testdriver.dashcam.stop();
242
- console.log('');
243
- console.log('🎥' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
244
- console.log('');
245
- // Write test result to file for the reporter (cross-process communication)
246
- // This should happen regardless of whether dashcam succeeded, to ensure platform info is available
247
- const testId = context.task.id;
248
- const platform = testdriver.os || 'linux';
249
- const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
250
- const projectRoot = process.cwd();
251
- const testFile = absolutePath !== 'unknown'
252
- ? path.relative(projectRoot, absolutePath)
253
- : absolutePath;
254
-
255
- // Create results directory if it doesn't exist
256
- const resultsDir = path.join(os.tmpdir(), 'testdriver-results');
257
- if (!fs.existsSync(resultsDir)) {
258
- fs.mkdirSync(resultsDir, { recursive: true });
259
- }
260
-
261
- // Write test result file
262
- const testResultFile = path.join(resultsDir, `${testId}.json`);
263
- const testResult = {
264
- dashcamUrl: dashcamUrl || null,
265
- platform,
266
- testFile,
267
- testOrder: 0,
268
- sessionId: testdriver.getSessionId(),
269
- };
270
-
271
- fs.writeFileSync(testResultFile, JSON.stringify(testResult, null, 2));
272
-
273
- // Also register in memory if plugin is available
274
- if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
275
- globalThis.__testdriverPlugin.registerDashcamUrl(testId, dashcamUrl, platform);
276
- }
277
- } catch (error) {
278
- // Log more detailed error information for debugging
279
- console.error(' Failed to stop dashcam:', error.name || error.constructor?.name || 'Error');
280
- if (error.message) console.error(' Message:', error.message);
281
- // NotFoundError during cleanup is expected if sandbox already terminated
282
- if (error.name === 'NotFoundError' || error.responseData?.error === 'NotFoundError') {
283
- console.log(' ℹ️ Sandbox session already terminated - dashcam stop skipped');
284
- }
285
- // Mark as not recording to prevent retries
286
- if (testdriver._dashcam) {
287
- testdriver._dashcam.recording = false;
288
- }
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;
289
289
  }
290
290
  }
291
-
292
- // Clean up console spies
293
- cleanupConsoleSpy(testdriver);
294
-
295
- // Wait for connection to finish if it was initiated
296
- if (testdriver.__connectionPromise) {
297
- await testdriver.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
298
- }
299
-
300
- // Disconnect with timeout
301
- await Promise.race([
302
- testdriver.disconnect(),
303
- new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
304
- ]);
305
- } catch (error) {
306
- console.error('Error disconnecting client:', error);
307
291
  }
308
- };
309
- lifecycleHandlers.set(context.task, cleanup);
310
-
311
- // Vitest will call this automatically after the test
312
- context.onTestFinished?.(cleanup);
313
- }
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);
314
314
 
315
315
  return testdriver;
316
316
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.2.3",
3
+ "version": "7.2.10",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "exports": {
@@ -52,6 +52,7 @@
52
52
  "@oclif/plugin-help": "^6.2.30",
53
53
  "@oclif/plugin-not-found": "^3.2.59",
54
54
  "@oclif/plugin-warn-if-update-available": "^3.1.43",
55
+ "@sentry/node": "^9.47.1",
55
56
  "@stoplight/yaml-ast-parser": "^0.0.50",
56
57
  "ajv": "^8.17.1",
57
58
  "arktype": "^2.1.19",
@@ -97,8 +98,8 @@
97
98
  "mocha": "^10.8.2",
98
99
  "node-addon-api": "^8.0.0",
99
100
  "prettier": "3.3.3",
100
- "testdriverai": "^6.1.11",
101
- "vitest": "^4.0.15"
101
+ "testdriverai": "^7.2.3",
102
+ "vitest": "^4.0.16"
102
103
  },
103
104
  "optionalDependencies": {
104
105
  "@esbuild/linux-x64": "^0.21.5"