testdriverai 7.8.0 → 7.9.0-test.1
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/agent/index.js +12 -0
- package/agent/lib/http.js +21 -3
- package/agent/lib/logger.js +15 -0
- package/agent/lib/provision-commands.js +176 -0
- package/agent/lib/sandbox.js +667 -118
- package/agent/lib/sdk.js +1 -20
- package/ai/skills/testdriver-find/SKILL.md +14 -20
- package/docs/_data/examples-manifest.json +46 -46
- package/docs/_scripts/extract-example-urls.js +67 -72
- package/docs/docs.json +2 -1
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/captcha-api.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +1 -1
- package/docs/v7/examples/drag-and-drop.mdx +1 -1
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/exec-output.mdx +1 -1
- package/docs/v7/examples/exec-pwsh.mdx +1 -1
- package/docs/v7/examples/focus-window.mdx +1 -1
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll-until-image.mdx +1 -1
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/examples/windows-installer.mdx +1 -1
- package/docs/v7/find.mdx +14 -20
- package/docs/v7/test-results-json.mdx +258 -0
- package/examples/scroll-keyboard.test.mjs +1 -1
- package/examples/scroll.test.mjs +1 -12
- package/interfaces/vitest-plugin.mjs +167 -51
- package/lib/core/Dashcam.js +16 -22
- package/lib/environments.json +8 -4
- package/lib/github-comment.mjs +58 -40
- package/lib/init-project.js +5 -67
- package/lib/resolve-channel.js +39 -10
- package/lib/sentry.js +47 -23
- package/lib/vitest/hooks.mjs +117 -20
- package/manual/exec-stream-logs.test.mjs +25 -0
- package/mcp-server/dist/server.mjs +28 -8
- package/mcp-server/src/server.ts +31 -8
- package/package.json +2 -1
- package/sdk.d.ts +4 -0
- package/sdk.js +42 -12
- package/setup/aws/install-dev-runner.sh +79 -0
- package/setup/aws/spawn-runner.sh +165 -0
- package/test-sentry-span.js +35 -0
- package/vitest.config.mjs +7 -3
- package/vitest.runner.config.mjs +33 -0
- package/docs/v7/_drafts/core.mdx +0 -458
package/lib/github-comment.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function generateTestResultsTable(testCases, testRunUrl) {
|
|
|
49
49
|
|
|
50
50
|
// Filter out skipped tests
|
|
51
51
|
const nonSkippedTests = testCases.filter(test => test.status !== 'skipped');
|
|
52
|
-
|
|
52
|
+
|
|
53
53
|
if (nonSkippedTests.length === 0) {
|
|
54
54
|
return '_No test cases to display (all tests were skipped)_';
|
|
55
55
|
}
|
|
@@ -62,12 +62,12 @@ function generateTestResultsTable(testCases, testRunUrl) {
|
|
|
62
62
|
const name = test.testName || 'Unknown';
|
|
63
63
|
const file = test.testFile || 'unknown';
|
|
64
64
|
const duration = formatDuration(test.duration || 0);
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
// Use test run context URL instead of direct replay URL
|
|
67
67
|
let replay = '-';
|
|
68
68
|
if (test.replayUrl) {
|
|
69
69
|
const linkUrl = (test.id && testRunUrl) ? `${testRunUrl}/${test.id}` : test.replayUrl;
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
// Extract replay ID and generate GIF URL
|
|
72
72
|
const replayId = extractReplayId(test.replayUrl);
|
|
73
73
|
if (replayId) {
|
|
@@ -97,28 +97,28 @@ function generateTestResultsTable(testCases, testRunUrl) {
|
|
|
97
97
|
*/
|
|
98
98
|
function generateExceptionsSection(testCases, testRunUrl) {
|
|
99
99
|
const failedTests = testCases.filter(t => t.status === 'failed' && t.errorMessage);
|
|
100
|
-
|
|
100
|
+
|
|
101
101
|
if (failedTests.length === 0) {
|
|
102
102
|
return '';
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
let section = '\n## 🔴 Failures\n\n';
|
|
106
|
-
|
|
106
|
+
|
|
107
107
|
for (const test of failedTests) {
|
|
108
108
|
section += `### ${test.testName}\n\n`;
|
|
109
109
|
section += `**File:** \`${test.testFile}\`\n\n`;
|
|
110
|
-
|
|
110
|
+
|
|
111
111
|
// Use test run context URL instead of direct replay URL
|
|
112
112
|
if (test.id && testRunUrl) {
|
|
113
113
|
section += `**📹 [Watch Replay](${testRunUrl}/${test.id})**\n\n`;
|
|
114
114
|
} else if (test.replayUrl) {
|
|
115
115
|
section += `**📹 [Watch Replay](${test.replayUrl})**\n\n`;
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
|
|
118
118
|
section += '```\n';
|
|
119
119
|
section += test.errorMessage || 'Unknown error';
|
|
120
120
|
section += '\n```\n\n';
|
|
121
|
-
|
|
121
|
+
|
|
122
122
|
if (test.errorStack) {
|
|
123
123
|
section += '<details>\n';
|
|
124
124
|
section += '<summary>Stack Trace</summary>\n\n';
|
|
@@ -140,22 +140,22 @@ function generateExceptionsSection(testCases, testRunUrl) {
|
|
|
140
140
|
*/
|
|
141
141
|
function generateReplaySection(testCases, testRunUrl) {
|
|
142
142
|
const testsWithReplays = testCases.filter(t => t.replayUrl);
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
if (testsWithReplays.length === 0) {
|
|
145
145
|
return '';
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
let section = '\n## 🎥 Dashcam Replays\n\n';
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
for (const test of testsWithReplays) {
|
|
151
151
|
section += `### ${test.testName}\n\n`;
|
|
152
|
-
|
|
152
|
+
|
|
153
153
|
// Determine the link URL - prefer test run context
|
|
154
154
|
let linkUrl = test.replayUrl;
|
|
155
155
|
if (test.id && testRunUrl) {
|
|
156
156
|
linkUrl = `${testRunUrl}/${test.id}`;
|
|
157
157
|
}
|
|
158
|
-
|
|
158
|
+
|
|
159
159
|
// Extract replay ID from URL for GIF embed
|
|
160
160
|
const replayId = extractReplayId(test.replayUrl);
|
|
161
161
|
if (replayId) {
|
|
@@ -177,7 +177,7 @@ function generateReplaySection(testCases, testRunUrl) {
|
|
|
177
177
|
*/
|
|
178
178
|
function extractReplayId(url) {
|
|
179
179
|
if (!url) return null;
|
|
180
|
-
|
|
180
|
+
|
|
181
181
|
// Match pattern: /replay/{id} or /replay/{id}?params
|
|
182
182
|
const match = url.match(/\/replay\/([^?/#]+)/);
|
|
183
183
|
return match ? match[1] : null;
|
|
@@ -190,7 +190,7 @@ function extractReplayId(url) {
|
|
|
190
190
|
*/
|
|
191
191
|
function extractShareKey(url) {
|
|
192
192
|
if (!url) return null;
|
|
193
|
-
|
|
193
|
+
|
|
194
194
|
// Match pattern: ?share=KEY or &share=KEY
|
|
195
195
|
const match = url.match(/[?&]share=([^&#]+)/);
|
|
196
196
|
return match ? match[1] : null;
|
|
@@ -204,32 +204,50 @@ function extractShareKey(url) {
|
|
|
204
204
|
*/
|
|
205
205
|
function getReplayGifUrl(replayUrl, replayId) {
|
|
206
206
|
// Determine the API base URL based on the replay URL
|
|
207
|
+
// Replay URLs use console domains; GIF endpoints live on the corresponding API domain
|
|
207
208
|
let apiBaseUrl;
|
|
208
|
-
|
|
209
|
+
|
|
209
210
|
if (replayUrl.includes('app.dashcam.io')) {
|
|
210
211
|
// Production dashcam uses Heroku API
|
|
211
212
|
apiBaseUrl = 'https://testdriverai-v6-c96fc597be11.herokuapp.com';
|
|
212
|
-
} else if (replayUrl.includes('console.testdriver.ai')) {
|
|
213
|
-
// TestDriver console
|
|
214
|
-
apiBaseUrl = 'https://api.testdriver.ai';
|
|
215
213
|
} else if (replayUrl.includes('localhost')) {
|
|
216
214
|
// Local development
|
|
217
215
|
apiBaseUrl = 'http://localhost:1337';
|
|
218
216
|
} else {
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
// Map console URLs → API URLs for all environments
|
|
218
|
+
// console-test.testdriver.ai → api-test.testdriver.ai
|
|
219
|
+
// console-canary.testdriver.ai → api-canary.testdriver.ai
|
|
220
|
+
// console.testdriver.ai → api.testdriver.ai
|
|
221
|
+
const consoleEnvMatch = replayUrl.match(/https:\/\/console-(test|canary)\.testdriver\.ai/);
|
|
222
|
+
if (consoleEnvMatch) {
|
|
223
|
+
apiBaseUrl = `https://api-${consoleEnvMatch[1]}.testdriver.ai`;
|
|
224
|
+
} else if (replayUrl.includes('console.testdriver.ai')) {
|
|
225
|
+
apiBaseUrl = 'https://api.testdriver.ai';
|
|
226
|
+
}
|
|
227
|
+
// Fly.io: map web app → API app
|
|
228
|
+
// pr-123-web.fly.dev → pr-123-api.fly.dev
|
|
229
|
+
// td-test-web.fly.dev → td-test-api.fly.dev
|
|
230
|
+
else {
|
|
231
|
+
const flyWebMatch = replayUrl.match(/https:\/\/([\w-]+)-web\.fly\.dev/);
|
|
232
|
+
if (flyWebMatch) {
|
|
233
|
+
apiBaseUrl = `https://${flyWebMatch[1]}-api.fly.dev`;
|
|
234
|
+
} else {
|
|
235
|
+
// Fallback: extract base URL from replay URL as-is
|
|
236
|
+
const urlObj = new URL(replayUrl);
|
|
237
|
+
apiBaseUrl = `${urlObj.protocol}//${urlObj.host}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
222
240
|
}
|
|
223
|
-
|
|
241
|
+
|
|
224
242
|
// Extract share key if present
|
|
225
243
|
const shareKey = extractShareKey(replayUrl);
|
|
226
|
-
|
|
244
|
+
|
|
227
245
|
// Build GIF URL with shareKey parameter
|
|
228
246
|
let gifUrl = `${apiBaseUrl}/replay/${replayId}/gif`;
|
|
229
247
|
if (shareKey) {
|
|
230
248
|
gifUrl += `?shareKey=${shareKey}`;
|
|
231
249
|
}
|
|
232
|
-
|
|
250
|
+
|
|
233
251
|
return gifUrl;
|
|
234
252
|
}
|
|
235
253
|
|
|
@@ -257,9 +275,9 @@ export function generateGitHubComment(testRunData, testCases = []) {
|
|
|
257
275
|
// Header with overall status
|
|
258
276
|
const statusEmoji = getStatusEmoji(status);
|
|
259
277
|
const statusColor = status === 'passed' ? '🟢' : status === 'failed' ? '🔴' : '🟡';
|
|
260
|
-
|
|
278
|
+
|
|
261
279
|
let comment = `# ${statusColor} TestDriver Test Results\n\n`;
|
|
262
|
-
|
|
280
|
+
|
|
263
281
|
// Compact summary line
|
|
264
282
|
comment += `**Status:** ${statusEmoji} ${status.toUpperCase()}`;
|
|
265
283
|
comment += ` • **Duration:** ${formatDuration(duration)}`;
|
|
@@ -270,23 +288,23 @@ export function generateGitHubComment(testRunData, testCases = []) {
|
|
|
270
288
|
comment += `, ${skippedTests} skipped`;
|
|
271
289
|
}
|
|
272
290
|
comment += `\n\n`;
|
|
273
|
-
|
|
291
|
+
|
|
274
292
|
// Exceptions section (only if there are failures) - show first
|
|
275
293
|
comment += generateExceptionsSection(testCases, testRunUrl);
|
|
276
|
-
|
|
294
|
+
|
|
277
295
|
// Test results table (now includes embedded GIFs)
|
|
278
296
|
comment += '## 📝 Test Results\n\n';
|
|
279
297
|
comment += generateTestResultsTable(testCases, testRunUrl);
|
|
280
|
-
|
|
298
|
+
|
|
281
299
|
// Link to full test run (below table)
|
|
282
300
|
if (testRunUrl) {
|
|
283
301
|
comment += `\n[📋 View Full Test Run](${testRunUrl})\n`;
|
|
284
302
|
}
|
|
285
|
-
|
|
303
|
+
|
|
286
304
|
// Footer
|
|
287
305
|
comment += '\n---\n';
|
|
288
306
|
comment += `<sub>Generated by [TestDriver](https://testdriver.ai) • Run ID: \`${runId}\`</sub>\n`;
|
|
289
|
-
|
|
307
|
+
|
|
290
308
|
return comment;
|
|
291
309
|
}
|
|
292
310
|
|
|
@@ -303,15 +321,15 @@ export function generateGitHubComment(testRunData, testCases = []) {
|
|
|
303
321
|
*/
|
|
304
322
|
export async function postGitHubComment(options) {
|
|
305
323
|
const { token, owner, repo, prNumber, commitSha, body } = options;
|
|
306
|
-
|
|
324
|
+
|
|
307
325
|
if (!token) {
|
|
308
326
|
throw new Error('GitHub token is required');
|
|
309
327
|
}
|
|
310
|
-
|
|
328
|
+
|
|
311
329
|
if (!owner || !repo) {
|
|
312
330
|
throw new Error('Repository owner and name are required');
|
|
313
331
|
}
|
|
314
|
-
|
|
332
|
+
|
|
315
333
|
if (!prNumber && !commitSha) {
|
|
316
334
|
throw new Error('Either prNumber or commitSha must be provided');
|
|
317
335
|
}
|
|
@@ -351,7 +369,7 @@ export async function postGitHubComment(options) {
|
|
|
351
369
|
*/
|
|
352
370
|
export async function updateGitHubComment(options) {
|
|
353
371
|
const { token, owner, repo, commentId, body } = options;
|
|
354
|
-
|
|
372
|
+
|
|
355
373
|
if (!token || !owner || !repo || !commentId) {
|
|
356
374
|
throw new Error('Token, owner, repo, and commentId are required');
|
|
357
375
|
}
|
|
@@ -364,7 +382,7 @@ export async function updateGitHubComment(options) {
|
|
|
364
382
|
comment_id: commentId,
|
|
365
383
|
body,
|
|
366
384
|
});
|
|
367
|
-
|
|
385
|
+
|
|
368
386
|
return response.data;
|
|
369
387
|
}
|
|
370
388
|
|
|
@@ -379,7 +397,7 @@ export async function updateGitHubComment(options) {
|
|
|
379
397
|
*/
|
|
380
398
|
export async function findExistingComment(options) {
|
|
381
399
|
const { token, owner, repo, prNumber } = options;
|
|
382
|
-
|
|
400
|
+
|
|
383
401
|
if (!token || !owner || !repo || !prNumber) {
|
|
384
402
|
return null;
|
|
385
403
|
}
|
|
@@ -410,11 +428,11 @@ export async function findExistingComment(options) {
|
|
|
410
428
|
*/
|
|
411
429
|
export async function postOrUpdateTestResults(testRunData, testCases, githubOptions) {
|
|
412
430
|
const commentBody = generateGitHubComment(testRunData, testCases);
|
|
413
|
-
|
|
431
|
+
|
|
414
432
|
// Try to find and delete existing comment to keep it at the end
|
|
415
433
|
if (githubOptions.prNumber) {
|
|
416
434
|
const existingComment = await findExistingComment(githubOptions);
|
|
417
|
-
|
|
435
|
+
|
|
418
436
|
if (existingComment) {
|
|
419
437
|
// Delete the old comment
|
|
420
438
|
const octokit = new Octokit({ auth: githubOptions.token });
|
|
@@ -425,7 +443,7 @@ export async function postOrUpdateTestResults(testRunData, testCases, githubOpti
|
|
|
425
443
|
});
|
|
426
444
|
}
|
|
427
445
|
}
|
|
428
|
-
|
|
446
|
+
|
|
429
447
|
// Always create a new comment (will be at the end of the thread)
|
|
430
448
|
return await postGitHubComment({
|
|
431
449
|
...githubOptions,
|
package/lib/init-project.js
CHANGED
|
@@ -14,66 +14,11 @@ function runInstall(cmd, args, cwd, label) {
|
|
|
14
14
|
return new Promise((resolve, reject) => {
|
|
15
15
|
const child = spawn(cmd, args, {
|
|
16
16
|
cwd,
|
|
17
|
-
stdio: ["ignore", "
|
|
17
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
18
18
|
shell: process.platform === "win32",
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
22
|
-
const barWidth = 20;
|
|
23
|
-
let frame = 0;
|
|
24
|
-
let status = "resolving";
|
|
25
|
-
let filled = 0;
|
|
26
|
-
|
|
27
|
-
// Parse npm stderr for progress hints
|
|
28
|
-
const handleData = (data) => {
|
|
29
|
-
const text = data.toString();
|
|
30
|
-
if (text.includes("idealTree")) {
|
|
31
|
-
status = "resolving packages";
|
|
32
|
-
filled = Math.max(filled, 3);
|
|
33
|
-
} else if (text.includes("reify:")) {
|
|
34
|
-
status = "installing";
|
|
35
|
-
filled = Math.max(filled, 8);
|
|
36
|
-
// Try to extract package name from reify output
|
|
37
|
-
const match = text.match(/reify:([^\s:]+)/);
|
|
38
|
-
if (match) {
|
|
39
|
-
status = `installing ${match[1]}`;
|
|
40
|
-
}
|
|
41
|
-
} else if (text.includes("timing")) {
|
|
42
|
-
filled = Math.max(filled, 14);
|
|
43
|
-
status = "finalizing";
|
|
44
|
-
} else if (text.includes("added")) {
|
|
45
|
-
filled = barWidth;
|
|
46
|
-
status = "done";
|
|
47
|
-
}
|
|
48
|
-
// Slowly increment to show activity
|
|
49
|
-
if (filled < barWidth - 2) {
|
|
50
|
-
filled = Math.min(filled + 1, barWidth - 2);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
child.stdout.on("data", handleData);
|
|
55
|
-
child.stderr.on("data", handleData);
|
|
56
|
-
|
|
57
|
-
const isTTY = process.stderr.isTTY;
|
|
58
|
-
|
|
59
|
-
const interval = setInterval(() => {
|
|
60
|
-
frame = (frame + 1) % spinnerFrames.length;
|
|
61
|
-
const spinner = spinnerFrames[frame];
|
|
62
|
-
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
|
63
|
-
const line = ` ${spinner} ${label} [${bar}] ${status}`;
|
|
64
|
-
if (isTTY) {
|
|
65
|
-
process.stderr.clearLine(0);
|
|
66
|
-
process.stderr.cursorTo(0);
|
|
67
|
-
process.stderr.write(line);
|
|
68
|
-
}
|
|
69
|
-
}, 80);
|
|
70
|
-
|
|
71
21
|
child.on("close", (code) => {
|
|
72
|
-
clearInterval(interval);
|
|
73
|
-
if (isTTY) {
|
|
74
|
-
process.stderr.clearLine(0);
|
|
75
|
-
process.stderr.cursorTo(0);
|
|
76
|
-
}
|
|
77
22
|
if (code === 0) {
|
|
78
23
|
resolve();
|
|
79
24
|
} else {
|
|
@@ -82,11 +27,6 @@ function runInstall(cmd, args, cwd, label) {
|
|
|
82
27
|
});
|
|
83
28
|
|
|
84
29
|
child.on("error", (err) => {
|
|
85
|
-
clearInterval(interval);
|
|
86
|
-
if (isTTY) {
|
|
87
|
-
process.stderr.clearLine(0);
|
|
88
|
-
process.stderr.cursorTo(0);
|
|
89
|
-
}
|
|
90
30
|
reject(err);
|
|
91
31
|
});
|
|
92
32
|
});
|
|
@@ -384,11 +324,9 @@ jobs:
|
|
|
384
324
|
[
|
|
385
325
|
"--yes",
|
|
386
326
|
"add-mcp",
|
|
327
|
+
"testdriverai",
|
|
328
|
+
"-n",
|
|
387
329
|
"testdriver",
|
|
388
|
-
"--command",
|
|
389
|
-
"npx -p testdriverai testdriverai-mcp",
|
|
390
|
-
"--env",
|
|
391
|
-
"TD_API_KEY",
|
|
392
330
|
],
|
|
393
331
|
{
|
|
394
332
|
cwd: targetDir,
|
|
@@ -400,10 +338,10 @@ jobs:
|
|
|
400
338
|
if (addMcpResult.status === 0) {
|
|
401
339
|
progress("✓ MCP configured via add-mcp");
|
|
402
340
|
} else if (addMcpResult.status !== null) {
|
|
403
|
-
progress("⚠ MCP setup skipped or failed - you can run 'npx add-mcp
|
|
341
|
+
progress("⚠ MCP setup skipped or failed - you can run 'npx add-mcp testdriverai' later");
|
|
404
342
|
}
|
|
405
343
|
} catch (err) {
|
|
406
|
-
progress("⚠ Could not run add-mcp - you can run 'npx add-mcp
|
|
344
|
+
progress("⚠ Could not run add-mcp - you can run 'npx add-mcp testdriverai' later");
|
|
407
345
|
}
|
|
408
346
|
}
|
|
409
347
|
|
package/lib/resolve-channel.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolves the active release channel and API URLs.
|
|
3
3
|
*
|
|
4
|
+
* TD_CHANNEL: dev | test | canary | stable (which release channel)
|
|
5
|
+
* TD_ENV: dev | staging | production (which infrastructure tier)
|
|
6
|
+
*
|
|
4
7
|
* Channel is derived from (in priority order):
|
|
5
8
|
* 1. TD_CHANNEL env var (explicit override)
|
|
6
|
-
* 2. TD_ENV env var
|
|
9
|
+
* 2. TD_ENV env var — if it holds a legacy channel name (dev/test/canary/stable)
|
|
7
10
|
* 3. SDK package.json version prerelease tag (e.g. "7.6.0-test.5" → "test")
|
|
8
|
-
* 4. "
|
|
11
|
+
* 4. "stable" for clean semver versions
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
const semver = require("semver");
|
|
@@ -15,20 +18,20 @@ const CHANNELS = {
|
|
|
15
18
|
dev: "http://localhost:1337",
|
|
16
19
|
test: environments.test.apiRoot,
|
|
17
20
|
canary: environments.canary.apiRoot,
|
|
18
|
-
|
|
21
|
+
stable: environments.stable.apiRoot,
|
|
19
22
|
};
|
|
20
23
|
|
|
24
|
+
const VALID_ENVS = new Set(["dev", "staging", "production"]);
|
|
25
|
+
|
|
21
26
|
function resolveActiveChannel() {
|
|
22
27
|
// 1. Explicit channel override
|
|
23
28
|
if (process.env.TD_CHANNEL && CHANNELS[process.env.TD_CHANNEL]) {
|
|
24
29
|
return process.env.TD_CHANNEL;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
// 2.
|
|
28
|
-
if (process.env.TD_ENV) {
|
|
29
|
-
|
|
30
|
-
if (CHANNELS[envName]) return envName;
|
|
31
|
-
if (envName === "stable") return "latest";
|
|
32
|
+
// 2. TD_ENV — if it holds a legacy channel name, use it as channel
|
|
33
|
+
if (process.env.TD_ENV && CHANNELS[process.env.TD_ENV]) {
|
|
34
|
+
return process.env.TD_ENV;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
// 3. Fallback: derive from package.json prerelease tag
|
|
@@ -38,9 +41,35 @@ function resolveActiveChannel() {
|
|
|
38
41
|
return pre[0];
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
return "
|
|
44
|
+
return "stable";
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const active = resolveActiveChannel();
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Returns the infrastructure environment (dev | staging | production).
|
|
51
|
+
* Reads from environments.json tdEnv mapping.
|
|
52
|
+
*/
|
|
53
|
+
function resolveTdEnv() {
|
|
54
|
+
// If TD_ENV is already new-format, use it directly
|
|
55
|
+
if (process.env.TD_ENV && VALID_ENVS.has(process.env.TD_ENV)) {
|
|
56
|
+
return process.env.TD_ENV;
|
|
57
|
+
}
|
|
58
|
+
// Derive from channel
|
|
59
|
+
const entry = environments[active];
|
|
60
|
+
return entry ? entry.tdEnv : "production";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tdEnv = resolveTdEnv();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolves the Sentry environment name.
|
|
67
|
+
* Uses the infrastructure tier (dev | staging | production).
|
|
68
|
+
*/
|
|
69
|
+
function resolveSentryEnvironment() {
|
|
70
|
+
return tdEnv;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sentryEnvironment = resolveSentryEnvironment();
|
|
74
|
+
|
|
75
|
+
module.exports = { active, channels: CHANNELS, sentryEnvironment, tdEnv };
|
package/lib/sentry.js
CHANGED
|
@@ -14,6 +14,7 @@ const Sentry = require("@sentry/node");
|
|
|
14
14
|
const crypto = require("crypto");
|
|
15
15
|
const os = require("os");
|
|
16
16
|
const { version } = require("../package.json");
|
|
17
|
+
const { sentryEnvironment } = require("./resolve-channel");
|
|
17
18
|
const logger = require("../agent/lib/logger");
|
|
18
19
|
|
|
19
20
|
// Store trace contexts per session so concurrent tests don't overwrite each other.
|
|
@@ -41,7 +42,7 @@ if (isEnabled()) {
|
|
|
41
42
|
dsn:
|
|
42
43
|
process.env.SENTRY_DSN ||
|
|
43
44
|
"https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
|
|
44
|
-
environment:
|
|
45
|
+
environment: sentryEnvironment,
|
|
45
46
|
release: version,
|
|
46
47
|
sampleRate: 1.0,
|
|
47
48
|
tracesSampleRate: 1.0, // Sample 20% of transactions for performance
|
|
@@ -53,6 +54,7 @@ if (isEnabled()) {
|
|
|
53
54
|
platform: os.platform(),
|
|
54
55
|
arch: os.arch(),
|
|
55
56
|
nodeVersion: process.version,
|
|
57
|
+
runner: "sdk",
|
|
56
58
|
},
|
|
57
59
|
},
|
|
58
60
|
// Filter out common non-errors and expected test failures
|
|
@@ -85,6 +87,11 @@ if (isEnabled()) {
|
|
|
85
87
|
return null;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// Filter out ElementNotFoundError - expected test outcome, not a crash
|
|
91
|
+
if (error && error.name === "ElementNotFoundError") {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
return event;
|
|
89
96
|
},
|
|
90
97
|
});
|
|
@@ -173,18 +180,41 @@ function captureMessage(message, level = "info") {
|
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/**
|
|
176
|
-
* Set the session trace context for distributed tracing
|
|
177
|
-
*
|
|
183
|
+
* Set the session trace context for distributed tracing.
|
|
184
|
+
* Creates a root span (sdk.session) that appears in the Sentry trace waterfall
|
|
185
|
+
* and sets propagation context so all outbound headers (HTTP and Ably) reference
|
|
186
|
+
* the same deterministic traceId = MD5(sessionId).
|
|
187
|
+
*
|
|
178
188
|
* @param {string} sessionId - The session ID
|
|
179
189
|
*/
|
|
180
190
|
function setSessionTraceContext(sessionId) {
|
|
181
191
|
if (!isEnabled() || !sessionId) return;
|
|
182
192
|
|
|
183
|
-
// Derive trace ID from session ID (same algorithm as API)
|
|
193
|
+
// Derive trace ID from session ID (same algorithm as API and Runner)
|
|
184
194
|
const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
|
|
185
195
|
|
|
186
|
-
//
|
|
187
|
-
|
|
196
|
+
// Build a synthetic sentry-trace header to seed the trace with our deterministic ID
|
|
197
|
+
const spanId = crypto.randomBytes(8).toString("hex");
|
|
198
|
+
const sentryTraceHeader = `${traceId}-${spanId}-1`;
|
|
199
|
+
const baggageHeader = `sentry-trace_id=${traceId},sentry-sampled=true`;
|
|
200
|
+
|
|
201
|
+
// continueTrace sets the scope's propagation context so all subsequent
|
|
202
|
+
// getTraceData() calls return our traceId. startInactiveSpan creates a
|
|
203
|
+
// root transaction that will be visible in the Sentry trace waterfall.
|
|
204
|
+
let rootSpan = null;
|
|
205
|
+
Sentry.continueTrace(
|
|
206
|
+
{ sentryTrace: sentryTraceHeader, baggage: baggageHeader },
|
|
207
|
+
() => {
|
|
208
|
+
rootSpan = Sentry.startInactiveSpan({
|
|
209
|
+
name: "sdk.session",
|
|
210
|
+
op: "session",
|
|
211
|
+
forceTransaction: true,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Store per-session trace context (including root span for cleanup)
|
|
217
|
+
_traceContexts.set(sessionId, { traceId, sessionId, rootSpan });
|
|
188
218
|
|
|
189
219
|
// Also update the module-level "latest" for backward compatibility
|
|
190
220
|
currentTraceId = traceId;
|
|
@@ -193,28 +223,17 @@ function setSessionTraceContext(sessionId) {
|
|
|
193
223
|
// Set as global tag so all events include it
|
|
194
224
|
Sentry.setTag("session", sessionId);
|
|
195
225
|
Sentry.setTag("trace_id", currentTraceId);
|
|
196
|
-
|
|
197
|
-
// Try to set propagation context for trace linking (may not be available in all versions)
|
|
198
|
-
try {
|
|
199
|
-
const scope = Sentry.getCurrentScope();
|
|
200
|
-
if (scope && typeof scope.setPropagationContext === "function") {
|
|
201
|
-
scope.setPropagationContext({
|
|
202
|
-
traceId: currentTraceId,
|
|
203
|
-
spanId: currentTraceId.substring(0, 16),
|
|
204
|
-
sampled: true,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
} catch (e) {
|
|
208
|
-
// Ignore errors - propagation context may not be supported
|
|
209
|
-
logger.log("Could not set propagation context:", e.message);
|
|
210
|
-
}
|
|
211
226
|
}
|
|
212
227
|
|
|
213
228
|
/**
|
|
214
|
-
* Clear the session trace context
|
|
229
|
+
* Clear the session trace context and end the root session span.
|
|
215
230
|
*/
|
|
216
231
|
function clearSessionTraceContext(sessionId) {
|
|
217
232
|
if (sessionId) {
|
|
233
|
+
const ctx = _traceContexts.get(sessionId);
|
|
234
|
+
if (ctx && ctx.rootSpan) {
|
|
235
|
+
try { ctx.rootSpan.end(); } catch (e) { /* ignore */ }
|
|
236
|
+
}
|
|
218
237
|
_traceContexts.delete(sessionId);
|
|
219
238
|
// If the cleared session was the "latest", pick another or null
|
|
220
239
|
if (currentSessionId === sessionId) {
|
|
@@ -228,7 +247,12 @@ function clearSessionTraceContext(sessionId) {
|
|
|
228
247
|
}
|
|
229
248
|
}
|
|
230
249
|
} else {
|
|
231
|
-
// Clear all (backward compatibility)
|
|
250
|
+
// Clear all (backward compatibility) — end all root spans
|
|
251
|
+
for (const ctx of _traceContexts.values()) {
|
|
252
|
+
if (ctx.rootSpan) {
|
|
253
|
+
try { ctx.rootSpan.end(); } catch (e) { /* ignore */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
232
256
|
_traceContexts.clear();
|
|
233
257
|
currentTraceId = null;
|
|
234
258
|
currentSessionId = null;
|