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.
- package/.github/workflows/publish.yaml +15 -7
- package/.github/workflows/testdriver.yml +36 -0
- package/agent/index.js +28 -109
- package/bin/testdriverai.js +8 -0
- package/debugger/index.html +37 -0
- package/docs/docs.json +2 -11
- package/docs/v7/_drafts/architecture.mdx +1 -26
- package/docs/v7/_drafts/provision.mdx +251 -188
- package/docs/v7/_drafts/quick-start-test-recording.mdx +0 -1
- package/docs/v7/_drafts/test-recording.mdx +0 -6
- package/docs/v7/api/act.mdx +1 -0
- package/docs/v7/getting-started/quickstart.mdx +9 -16
- package/interfaces/cli/commands/init.js +33 -19
- package/interfaces/cli/lib/base.js +24 -0
- package/interfaces/cli.js +8 -1
- package/interfaces/logger.js +8 -3
- package/interfaces/vitest-plugin.mjs +16 -71
- package/lib/sentry.js +343 -0
- package/lib/vitest/hooks.mjs +23 -31
- package/package.json +4 -3
- package/sdk-log-formatter.js +41 -0
- package/sdk.js +335 -94
- package/test/testdriver/act.test.mjs +30 -0
- package/test/testdriver/assert.test.mjs +1 -1
- package/test/testdriver/hover-text.test.mjs +1 -1
- package/test/testdriver/installer.test.mjs +47 -0
- package/test/testdriver/launch-vscode-linux.test.mjs +55 -0
- package/test/testdriver/setup/testHelpers.mjs +8 -118
- package/tests/example.test.js +33 -0
- package/tests/login.js +28 -0
- package/vitest.config.js +18 -0
- package/vitest.config.mjs +1 -0
- package/agent/lib/cache.js +0 -142
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
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
|
});
|
package/interfaces/logger.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
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
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
+
};
|