testdriverai 7.2.2 → 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.
@@ -24,6 +24,7 @@ class InitCommand extends BaseCommand {
24
24
 
25
25
  console.log(chalk.green("\n✅ Project initialized successfully!\n"));
26
26
  this.printNextSteps();
27
+ process.exit(0);
27
28
  }
28
29
 
29
30
  /**
@@ -79,28 +80,41 @@ class InitCommand extends BaseCommand {
79
80
  */
80
81
  async promptHidden(question) {
81
82
  return new Promise((resolve) => {
82
- const rl = readline.createInterface({
83
- input: process.stdin,
84
- output: process.stdout,
85
- });
86
-
87
- // Mute output to hide the input
83
+ process.stdout.write(question);
84
+
88
85
  const stdin = process.stdin;
89
- const muted = {
90
- write: () => {},
86
+ const wasRaw = stdin.isRaw;
87
+ stdin.setRawMode(true);
88
+ stdin.resume();
89
+ stdin.setEncoding("utf8");
90
+
91
+ let input = "";
92
+
93
+ const onData = (char) => {
94
+ // Handle Ctrl+C
95
+ if (char === "\u0003") {
96
+ stdin.setRawMode(wasRaw);
97
+ process.exit();
98
+ }
99
+ // Handle Enter
100
+ if (char === "\r" || char === "\n") {
101
+ stdin.setRawMode(wasRaw);
102
+ stdin.removeListener("data", onData);
103
+ stdin.pause();
104
+ console.log(""); // New line after hidden input
105
+ resolve(input);
106
+ return;
107
+ }
108
+ // Handle Backspace
109
+ if (char === "\u007F" || char === "\b") {
110
+ input = input.slice(0, -1);
111
+ return;
112
+ }
113
+ // Add character to input (but don't echo it)
114
+ input += char;
91
115
  };
92
116
 
93
- rl.question(question, (answer) => {
94
- rl.close();
95
- stdin.removeListener("data", muted.write);
96
- console.log(""); // New line after hidden input
97
- resolve(answer);
98
- });
99
-
100
- // Mute stdin to hide input
101
- stdin.on("data", (char) => {
102
- // Don't write to output (hides the input)
103
- });
117
+ stdin.on("data", onData);
104
118
  });
105
119
  }
106
120
 
@@ -23,6 +23,7 @@ async function openBrowser(url) {
23
23
  await open(url, {
24
24
  // Wait for the app to open
25
25
  wait: false,
26
+ background: true
26
27
  });
27
28
  } catch (error) {
28
29
  console.error("Failed to open browser automatically:", error);
@@ -131,9 +132,32 @@ class BaseCommand extends Command {
131
132
  }
132
133
 
133
134
  this.agent.emitter.on("exit", (exitCode) => {
135
+ // Ensure sandbox is closed before exiting
136
+ if (this.agent?.sandbox) {
137
+ try {
138
+ this.agent.sandbox.close();
139
+ } catch (err) {
140
+ // Ignore close errors
141
+ }
142
+ }
134
143
  process.exit(exitCode);
135
144
  });
136
145
 
146
+ // Handle process signals to ensure clean disconnection
147
+ const cleanupAndExit = () => {
148
+ if (this.agent?.sandbox) {
149
+ try {
150
+ this.agent.sandbox.close();
151
+ } catch (err) {
152
+ // Ignore close errors
153
+ }
154
+ }
155
+ process.exit(1);
156
+ };
157
+
158
+ process.on('SIGINT', cleanupAndExit);
159
+ process.on('SIGTERM', cleanupAndExit);
160
+
137
161
  // Handle unhandled promise rejections to prevent them from interfering with the exit flow
138
162
  // This is particularly important when JavaScript execution in VM contexts leaves dangling promises
139
163
  process.on("unhandledRejection", (reason) => {
package/interfaces/cli.js CHANGED
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { run } = require("@oclif/core");
4
+ const sentry = require("../lib/sentry");
4
5
 
5
6
  // Run oclif (with default command handling built-in)
6
7
  run()
7
8
  .then(() => {
8
9
  // Success
9
10
  })
10
- .catch((error) => {
11
+ .catch(async (error) => {
12
+ // Capture error in Sentry
13
+ sentry.captureException(error, {
14
+ tags: { component: "cli-init" },
15
+ });
16
+ await sentry.flush();
17
+
11
18
  console.error("Failed to start TestDriver.ai agent:", error);
12
19
  process.exit(1);
13
20
  });
@@ -300,6 +300,9 @@ marked.use(
300
300
  );
301
301
 
302
302
  const createMarkdownLogger = (emitter) => {
303
+ // Indent prefix for streaming AI thoughts - makes it visually distinct and scoped
304
+ const streamIndent = "";
305
+
303
306
  const markedParsePartial = (markdown, start = 0, end = 0) => {
304
307
  let result = markdown.trimEnd().split("\n").slice(start, end);
305
308
  if (end <= 0) {
@@ -307,7 +310,8 @@ const createMarkdownLogger = (emitter) => {
307
310
  }
308
311
  result = result.join("\n");
309
312
 
310
- return marked.parse(result).replace(/^/gm, spaceChar).trimEnd();
313
+ // Use streamIndent for streaming output to make it visually scoped
314
+ return marked.parse(result).replace(/^/gm, streamIndent).trimEnd();
311
315
  };
312
316
 
313
317
  // Event-based markdown streaming with buffering
@@ -360,7 +364,8 @@ const createMarkdownLogger = (emitter) => {
360
364
  diff = censorSensitiveDataDeep(diff);
361
365
  process.stdout.write(diff);
362
366
  }
363
- process.stdout.write("\n\n");
367
+ // Use console.log for the final newlines so it gets captured by vitest
368
+ console.log("");
364
369
 
365
370
  // Clean up the stream
366
371
  activeStreams.delete(streamId);
@@ -384,7 +389,7 @@ const createMarkdownLogger = (emitter) => {
384
389
  });
385
390
  };
386
391
 
387
- const spaceChar = " ";
392
+ const spaceChar = " ";
388
393
 
389
394
  module.exports = {
390
395
  logger,
@@ -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
+ };