testdriverai 7.2.3 → 7.2.9

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.
@@ -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
  *
@@ -242,9 +241,9 @@ export function TestDriver(context, options = {}) {
242
241
  console.log('');
243
242
  console.log('🎥' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
244
243
  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;
244
+
245
+ // Set test metadata directly on the Vitest task context
246
+ // This is the proper way to pass data from test to reporter
248
247
  const platform = testdriver.os || 'linux';
249
248
  const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
250
249
  const projectRoot = process.cwd();
@@ -252,27 +251,16 @@ export function TestDriver(context, options = {}) {
252
251
  ? path.relative(projectRoot, absolutePath)
253
252
  : absolutePath;
254
253
 
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));
254
+ // Set metadata on the task for the reporter to read
255
+ context.task.meta.dashcamUrl = dashcamUrl || null;
256
+ context.task.meta.platform = platform;
257
+ context.task.meta.testFile = testFile;
258
+ context.task.meta.testOrder = 0;
259
+ context.task.meta.sessionId = testdriver.getSessionId();
272
260
 
273
- // Also register in memory if plugin is available
261
+ // Also register in memory if plugin is available (for cross-process scenarios)
274
262
  if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
275
- globalThis.__testdriverPlugin.registerDashcamUrl(testId, dashcamUrl, platform);
263
+ globalThis.__testdriverPlugin.registerDashcamUrl(context.task.id, dashcamUrl, platform);
276
264
  }
277
265
  } catch (error) {
278
266
  // Log more detailed error information for debugging
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.2.3",
3
+ "version": "7.2.9",
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"
@@ -878,6 +878,47 @@ class SDKLogFormatter {
878
878
 
879
879
  return `\n${parts.join(" ")}\n`;
880
880
  }
881
+
882
+ /**
883
+ * Format act() start message - provides visual scope boundary
884
+ * @param {string} task - The task being executed
885
+ * @returns {string} Formatted act start message
886
+ */
887
+ formatActStart(task) {
888
+ const parts = [];
889
+ this.addTimestamp(parts);
890
+ parts.push(this.getPrefix("action"));
891
+ parts.push(chalk.bold.cyan("Act"));
892
+ parts.push(chalk.cyan(`"${task}"`));
893
+ return parts.join(" ");
894
+ }
895
+
896
+ /**
897
+ * Format act() completion message - provides visual scope boundary
898
+ * @param {number} durationMs - Duration in milliseconds
899
+ * @param {boolean} success - Whether the act completed successfully
900
+ * @param {string} [error] - Error message if failed
901
+ * @returns {string} Formatted act complete message
902
+ */
903
+ formatActComplete(durationMs, success, error = null) {
904
+ const parts = [];
905
+ this.addTimestamp(parts);
906
+ parts.push(this.getResultPrefix());
907
+
908
+ if (success) {
909
+ parts.push(chalk.green("complete"));
910
+ } else {
911
+ parts.push(chalk.red("failed"));
912
+ if (error) {
913
+ parts.push(chalk.dim("·"));
914
+ parts.push(chalk.red(error));
915
+ }
916
+ }
917
+
918
+ parts.push(this.formatDurationColored(durationMs, "default"));
919
+
920
+ return parts.join(" ");
921
+ }
881
922
  }
882
923
 
883
924
  // Export singleton instance