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.
Files changed (55) hide show
  1. package/agent/index.js +12 -0
  2. package/agent/lib/http.js +21 -3
  3. package/agent/lib/logger.js +15 -0
  4. package/agent/lib/provision-commands.js +176 -0
  5. package/agent/lib/sandbox.js +667 -118
  6. package/agent/lib/sdk.js +1 -20
  7. package/ai/skills/testdriver-find/SKILL.md +14 -20
  8. package/docs/_data/examples-manifest.json +46 -46
  9. package/docs/_scripts/extract-example-urls.js +67 -72
  10. package/docs/docs.json +2 -1
  11. package/docs/v7/examples/ai.mdx +1 -1
  12. package/docs/v7/examples/assert.mdx +1 -1
  13. package/docs/v7/examples/captcha-api.mdx +1 -1
  14. package/docs/v7/examples/chrome-extension.mdx +1 -1
  15. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  16. package/docs/v7/examples/element-not-found.mdx +1 -1
  17. package/docs/v7/examples/exec-output.mdx +1 -1
  18. package/docs/v7/examples/exec-pwsh.mdx +1 -1
  19. package/docs/v7/examples/focus-window.mdx +1 -1
  20. package/docs/v7/examples/hover-image.mdx +1 -1
  21. package/docs/v7/examples/hover-text.mdx +1 -1
  22. package/docs/v7/examples/installer.mdx +1 -1
  23. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  24. package/docs/v7/examples/match-image.mdx +1 -1
  25. package/docs/v7/examples/press-keys.mdx +1 -1
  26. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  27. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  28. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  29. package/docs/v7/examples/scroll.mdx +1 -1
  30. package/docs/v7/examples/type.mdx +1 -1
  31. package/docs/v7/examples/windows-installer.mdx +1 -1
  32. package/docs/v7/find.mdx +14 -20
  33. package/docs/v7/test-results-json.mdx +258 -0
  34. package/examples/scroll-keyboard.test.mjs +1 -1
  35. package/examples/scroll.test.mjs +1 -12
  36. package/interfaces/vitest-plugin.mjs +167 -51
  37. package/lib/core/Dashcam.js +16 -22
  38. package/lib/environments.json +8 -4
  39. package/lib/github-comment.mjs +58 -40
  40. package/lib/init-project.js +5 -67
  41. package/lib/resolve-channel.js +39 -10
  42. package/lib/sentry.js +47 -23
  43. package/lib/vitest/hooks.mjs +117 -20
  44. package/manual/exec-stream-logs.test.mjs +25 -0
  45. package/mcp-server/dist/server.mjs +28 -8
  46. package/mcp-server/src/server.ts +31 -8
  47. package/package.json +2 -1
  48. package/sdk.d.ts +4 -0
  49. package/sdk.js +42 -12
  50. package/setup/aws/install-dev-runner.sh +79 -0
  51. package/setup/aws/spawn-runner.sh +165 -0
  52. package/test-sentry-span.js +35 -0
  53. package/vitest.config.mjs +7 -3
  54. package/vitest.runner.config.mjs +33 -0
  55. package/docs/v7/_drafts/core.mdx +0 -458
@@ -13,6 +13,7 @@ const channelConfig = require("../lib/resolve-channel.js");
13
13
 
14
14
  // Import Sentry for error reporting
15
15
  const Sentry = require("@sentry/node");
16
+ const chalk = require("chalk");
16
17
 
17
18
  // Track if Sentry has been initialized
18
19
  let sentryInitialized = false;
@@ -23,7 +24,7 @@ let sentryInitialized = false;
23
24
  */
24
25
  function initializeSentry() {
25
26
  if (sentryInitialized) return;
26
-
27
+
27
28
  // Respect telemetry opt-out
28
29
  if (process.env.TD_TELEMETRY === "false") {
29
30
  return;
@@ -31,12 +32,12 @@ function initializeSentry() {
31
32
 
32
33
  try {
33
34
  const version = resolveTestDriverVersion() || "unknown";
34
-
35
+
35
36
  Sentry.init({
36
37
  dsn:
37
38
  process.env.SENTRY_DSN ||
38
39
  "https://452bd5a00dbd83a38ee8813e11c57694@o4510262629236736.ingest.us.sentry.io/4510480443637760",
39
- environment: "vitest",
40
+ environment: channelConfig.sentryEnvironment,
40
41
  release: version,
41
42
  sampleRate: 1.0,
42
43
  tracesSampleRate: 1.0,
@@ -53,12 +54,12 @@ function initializeSentry() {
53
54
  // Filter out events that should not be reported to Sentry
54
55
  beforeSend(event, hint) {
55
56
  const error = hint.originalException;
56
-
57
+
57
58
  // Don't send user-cancelled errors
58
59
  if (error && error.message && error.message.includes("User cancelled")) {
59
60
  return null;
60
61
  }
61
-
62
+
62
63
  // Don't send test failures - these are expected behavior, not bugs in the SDK
63
64
  // Test failures indicate the test found a problem, which is the intended use case
64
65
  if (event.exception?.values) {
@@ -67,28 +68,33 @@ function initializeSentry() {
67
68
  if (exception.type === "TestFailure") {
68
69
  return null;
69
70
  }
70
-
71
+
71
72
  // Filter out common user code errors (ReferenceError, TypeError from user tests)
72
73
  // Only report if the error originates from TestDriver SDK code, not user test code
73
74
  const isUserCodeError = exception.stacktrace?.frames?.some(frame => {
74
75
  const filename = frame.filename || frame.abs_path || "";
75
76
  // Check if error is from user test files (not from SDK internals)
76
- return filename.includes("/tests/") ||
77
- filename.includes("/test/") ||
78
- filename.includes(".test.") ||
79
- filename.includes(".spec.");
77
+ return filename.includes("/tests/") ||
78
+ filename.includes("/test/") ||
79
+ filename.includes(".test.") ||
80
+ filename.includes(".spec.");
80
81
  });
81
-
82
+
82
83
  if (isUserCodeError && (exception.type === "ReferenceError" || exception.type === "TypeError")) {
83
84
  return null;
84
85
  }
86
+
87
+ // Filter out ElementNotFoundError - expected test outcome, not a crash
88
+ if (exception.type === "ElementNotFoundError") {
89
+ return null;
90
+ }
85
91
  }
86
92
  }
87
-
93
+
88
94
  return event;
89
95
  },
90
96
  });
91
-
97
+
92
98
  sentryInitialized = true;
93
99
  logger.debug("Sentry initialized for vitest");
94
100
  } catch (err) {
@@ -118,17 +124,17 @@ async function flushSentry(timeout = 2000) {
118
124
  function resolveTestDriverVersion() {
119
125
  try {
120
126
  return require("../package.json").version;
121
- } catch {}
127
+ } catch { }
122
128
 
123
129
  try {
124
130
  const cwdRequire = createRequire(path.join(process.cwd(), "package.json"));
125
131
  return cwdRequire("testdriverai/package.json").version;
126
- } catch {}
132
+ } catch { }
127
133
 
128
134
  try {
129
135
  const pkgPath = path.join(process.cwd(), "node_modules", "testdriverai", "package.json");
130
136
  return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
131
- } catch {}
137
+ } catch { }
132
138
 
133
139
  return null;
134
140
  }
@@ -149,19 +155,19 @@ function resolveVitestVersion() {
149
155
  // Strategy 1: createRequire from import.meta.url (standard CJS interop)
150
156
  try {
151
157
  return require("vitest/package.json").version;
152
- } catch {}
158
+ } catch { }
153
159
 
154
160
  // Strategy 2: createRequire from process.cwd() (works when import.meta.url is rewritten)
155
161
  try {
156
162
  const cwdRequire = createRequire(path.join(process.cwd(), "package.json"));
157
163
  return cwdRequire("vitest/package.json").version;
158
- } catch {}
164
+ } catch { }
159
165
 
160
166
  // Strategy 3: read directly from node_modules on disk
161
167
  try {
162
168
  const vitestPkgPath = path.join(process.cwd(), "node_modules", "vitest", "package.json");
163
169
  return JSON.parse(fs.readFileSync(vitestPkgPath, "utf8")).version;
164
- } catch {}
170
+ } catch { }
165
171
 
166
172
  return null;
167
173
  }
@@ -176,7 +182,7 @@ function checkVitestVersion() {
176
182
  if (!version) {
177
183
  throw new Error(
178
184
  "TestDriver requires Vitest to be installed. " +
179
- "Please install it: npm install vitest@latest",
185
+ "Please install it: npm install vitest@latest",
180
186
  );
181
187
  }
182
188
 
@@ -184,7 +190,7 @@ function checkVitestVersion() {
184
190
  if (major < MINIMUM_VITEST_VERSION) {
185
191
  throw new Error(
186
192
  `TestDriver requires Vitest >= ${MINIMUM_VITEST_VERSION}.0.0, but found ${version}. ` +
187
- `Please upgrade Vitest: npm install vitest@latest`,
193
+ `Please upgrade Vitest: npm install vitest@latest`,
188
194
  );
189
195
  }
190
196
  }
@@ -374,7 +380,7 @@ export async function authenticateWithApiKey(apiKey, apiRoot) {
374
380
  // Network-level error (fetch failed entirely)
375
381
  const networkError = new Error(
376
382
  `Unable to reach TestDriver API at ${apiRoot}. ` +
377
- "Check your internet connection and try again.",
383
+ "Check your internet connection and try again.",
378
384
  );
379
385
  networkError.code = "NETWORK_ERROR";
380
386
  networkError.isNetworkError = true;
@@ -394,8 +400,8 @@ export async function authenticateWithApiKey(apiKey, apiRoot) {
394
400
  if (response.status === 401) {
395
401
  const authError = new Error(
396
402
  data.message ||
397
- "Invalid API key. Please check your TD_API_KEY and try again. " +
398
- "Get your API key at https://console.testdriver.ai/team",
403
+ "Invalid API key. Please check your TD_API_KEY and try again. " +
404
+ "Get your API key at https://console.testdriver.ai/team",
399
405
  );
400
406
  authError.code = data.error || "INVALID_API_KEY";
401
407
  authError.isAuthError = true;
@@ -406,7 +412,7 @@ export async function authenticateWithApiKey(apiKey, apiRoot) {
406
412
  if (response.status >= 500) {
407
413
  const serverError = new Error(
408
414
  data.message ||
409
- `TestDriver API is currently unavailable (HTTP ${response.status}). Please try again later.`,
415
+ `TestDriver API is currently unavailable (HTTP ${response.status}). Please try again later.`,
410
416
  );
411
417
  serverError.code = data.error || "API_UNAVAILABLE";
412
418
  serverError.isServerError = true;
@@ -426,7 +432,7 @@ export async function authenticateWithApiKey(apiKey, apiRoot) {
426
432
  // Other HTTP errors
427
433
  throw new Error(
428
434
  `Authentication failed: ${response.status} ${response.statusText}` +
429
- (data.message ? ` - ${data.message}` : ""),
435
+ (data.message ? ` - ${data.message}` : ""),
430
436
  );
431
437
  }
432
438
 
@@ -1138,7 +1144,7 @@ class TestDriverReporter {
1138
1144
  const error = result.errors[0];
1139
1145
  errorMessage = error.message;
1140
1146
  errorStack = error.stack;
1141
-
1147
+
1142
1148
  // Note: We do NOT report test failures to Sentry.
1143
1149
  // Test failures are expected behavior (they indicate a test found a bug).
1144
1150
  // We only want actual SDK crashes and exceptions reported to Sentry.
@@ -1208,8 +1214,125 @@ class TestDriverReporter {
1208
1214
 
1209
1215
  console.log("");
1210
1216
  console.log(
1211
- `🔗 Test Report: ${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`,
1217
+ chalk.cyan(`🔗 Test Report: ${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`),
1212
1218
  );
1219
+ console.log("");
1220
+
1221
+ // Write per-test-case JSON result file
1222
+ {
1223
+ const testResult = meta.testResult || {};
1224
+
1225
+ // Parse replay URL to extract replayId and shareKey for embed links
1226
+ let replayUrl = dashcamUrl || null;
1227
+ let replayGifUrl = null;
1228
+ let replayEmbedUrl = null;
1229
+ let replayMarkdown = null;
1230
+ const replayMatch = dashcamUrl && dashcamUrl.match(/\/replay\/([a-f0-9]+)\?share=([^&\s]+)/);
1231
+ if (replayMatch) {
1232
+ const [, replayId, shareKey] = replayMatch;
1233
+ const apiRoot = pluginState.apiRoot;
1234
+ replayGifUrl = `${apiRoot}/replay/${replayId}/gif?shareKey=${shareKey}`;
1235
+ replayEmbedUrl = `${consoleUrl}/replay/${replayId}?share=${shareKey}&embed=true`;
1236
+ replayMarkdown = `[![Test Recording](${replayGifUrl})](${replayUrl})`;
1237
+ }
1238
+
1239
+ const resultData = {
1240
+ // Versions
1241
+ versions: {
1242
+ sdk: testResult.sdkVersion || null,
1243
+ vitest: resolveVitestVersion() || null,
1244
+ api: testResult.apiVersion || null,
1245
+ runnerBefore: testResult.runnerVersionBefore || null,
1246
+ runnerAfter: testResult.runnerVersionAfter || null,
1247
+ runnerWasUpdated: testResult.wasUpdated || false,
1248
+ },
1249
+
1250
+ // Test info
1251
+ test: {
1252
+ file: testResult.testFile || null,
1253
+ name: testResult.testName || null,
1254
+ suite: testResult.suiteName || null,
1255
+ passed: status === "passed",
1256
+ caseId: testCaseDbId || null,
1257
+ runId: testRunDbId || null,
1258
+ error: errorMessage || testResult.error || null,
1259
+ errorStack: errorStack || testResult.errorStack || null,
1260
+ },
1261
+
1262
+ // URLs
1263
+ urls: {
1264
+ api: testResult.apiUrl || null,
1265
+ console: consoleUrl || null,
1266
+ vnc: testResult.vncUrl || null,
1267
+ testRun: testCaseDbId ? `${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}` : null,
1268
+ },
1269
+
1270
+ // Recording replay
1271
+ replay: {
1272
+ url: replayUrl,
1273
+ gifUrl: replayGifUrl,
1274
+ embedUrl: replayEmbedUrl,
1275
+ markdown: replayMarkdown,
1276
+ },
1277
+
1278
+ // Timing
1279
+ date: testResult.date || new Date().toISOString(),
1280
+
1281
+ // Team & session
1282
+ team: {
1283
+ id: testResult.teamId || null,
1284
+ sessionId: testResult.sessionId || null,
1285
+ },
1286
+
1287
+ // Infrastructure
1288
+ infrastructure: {
1289
+ sandboxId: testResult.sandboxId || null,
1290
+ instanceId: testResult.instanceId || null,
1291
+ os: testResult.os || null,
1292
+ amiId: testResult.amiId || null,
1293
+ e2bTemplateId: testResult.e2bTemplateId || null,
1294
+ imageVersion: testResult.imageVersion || null,
1295
+ },
1296
+
1297
+ // Realtime
1298
+ realtime: {
1299
+ channel: testResult.realtimeChannel || null,
1300
+ messageCount: testResult.realtimeMessageCount || 0,
1301
+ },
1302
+
1303
+ // Interactions
1304
+ interactions: testResult.interactions || { total: 0, cached: 0, byType: {} },
1305
+ };
1306
+
1307
+ // Sanitize testName for filesystem use
1308
+ const safeName = (test.name || "unknown").replace(/[^a-zA-Z0-9_.-]/g, "_").substring(0, 200);
1309
+ const resultDir = path.join(process.cwd(), ".testdriver", "results", testFile);
1310
+ fs.mkdirSync(resultDir, { recursive: true });
1311
+
1312
+ // Include a stable unique suffix in the filename to avoid collisions
1313
+ // when multiple tests in the same file share the same name.
1314
+ const hashSourceParts = [];
1315
+ if (test.id) {
1316
+ hashSourceParts.push(String(test.id));
1317
+ }
1318
+ if (Array.isArray(test.suitePath)) {
1319
+ hashSourceParts.push(test.suitePath.join(" > "));
1320
+ }
1321
+ if (test.file && (test.file.name || test.file.path)) {
1322
+ hashSourceParts.push(test.file.name || test.file.path);
1323
+ }
1324
+ // Fallback to the test name if no other identifiers are available.
1325
+ if (hashSourceParts.length === 0) {
1326
+ hashSourceParts.push(test.name || "unknown");
1327
+ }
1328
+ const hashSource = hashSourceParts.join(" | ");
1329
+ const uniqueHash = crypto.createHash("sha256").update(hashSource).digest("hex").slice(0, 8);
1330
+
1331
+ fs.writeFileSync(
1332
+ path.join(resultDir, `${safeName}-${uniqueHash}.json`),
1333
+ JSON.stringify(resultData, null, 2),
1334
+ );
1335
+ }
1213
1336
 
1214
1337
  // If there were retries, list all per-attempt dashcam URLs for debugging
1215
1338
  if (hasRetries) {
@@ -1221,14 +1344,7 @@ class TestDriverReporter {
1221
1344
  }
1222
1345
  }
1223
1346
  }
1224
-
1225
- // Output parseable format for docs generation (examples only)
1226
- if (testFile.startsWith("examples/")) {
1227
- const testFileName = path.basename(testFile);
1228
- console.log(
1229
- `TESTDRIVER_EXAMPLE_URL::${testFileName}::${consoleUrl}/runs/${testRunDbId}/${testCaseDbId}`,
1230
- );
1231
- }
1347
+
1232
1348
  } catch (error) {
1233
1349
  logger.error("Failed to report test case:", error.message);
1234
1350
  }
@@ -1247,12 +1363,20 @@ class TestDriverReporter {
1247
1363
  * @returns {string} The corresponding web console URL
1248
1364
  */
1249
1365
  function getConsoleUrl(apiRoot) {
1250
- // Allow explicit override via env (e.g. VITE_DOMAIN from .env)
1251
- if (process.env.VITE_DOMAIN) return process.env.VITE_DOMAIN;
1366
+ // Explicit override use TD_CONSOLE_URL when deliberately set
1367
+ if (process.env.TD_CONSOLE_URL) return process.env.TD_CONSOLE_URL;
1252
1368
 
1253
1369
  if (!apiRoot) return "https://console.testdriver.ai";
1254
1370
 
1255
- // Map known channel API URLs to their console equivalents
1371
+ // Fly.io: swap "-api" for "-web" in the hostname
1372
+ // e.g. preview-138-api.fly.dev -> preview-138-web.fly.dev
1373
+ // td-test-api.fly.dev -> td-test-web.fly.dev
1374
+ const flyMatch = apiRoot.match(/https:\/\/([\w-]+)-api\.fly\.dev/);
1375
+ if (flyMatch) {
1376
+ return `https://${flyMatch[1]}-web.fly.dev`;
1377
+ }
1378
+
1379
+ // Known channel API URLs -> console equivalents
1256
1380
  // e.g. https://api-canary.testdriver.ai -> https://console-canary.testdriver.ai
1257
1381
  for (const url of Object.values(channelConfig.channels)) {
1258
1382
  if (url === apiRoot) {
@@ -1260,27 +1384,19 @@ function getConsoleUrl(apiRoot) {
1260
1384
  }
1261
1385
  }
1262
1386
 
1263
- // Local development: ngrok/cloudflare tunnels -> localhost:3001
1387
+ // Local development
1264
1388
  if (apiRoot.includes("ngrok.io") || apiRoot.includes("trycloudflare.com") || apiRoot.includes("localhost")) {
1265
- return `http://localhost:3001`;
1389
+ return "http://localhost:3001";
1266
1390
  }
1267
1391
 
1268
- // Render PR previews: map API service to Web service
1269
- // canary-api-pr-123.onrender.com -> canary-web-pr-123.onrender.com
1270
- // testdriver-api-i4m4-pr-123.onrender.com -> web-i4m4-pr-123.onrender.com
1392
+ // Render PR previews (legacy)
1271
1393
  const renderPrMatch = apiRoot.match(/https:\/\/([\w-]+)-api(-[\w]+)?(-pr-\d+)?\.onrender\.com/);
1272
1394
  if (renderPrMatch) {
1273
1395
  const [, prefix, suffix, prSuffix] = renderPrMatch;
1274
- let webPrefix;
1275
- if (prefix === 'testdriver' && suffix) {
1276
- webPrefix = 'web' + suffix;
1277
- } else {
1278
- webPrefix = prefix + '-web';
1279
- }
1396
+ const webPrefix = (prefix === 'testdriver' && suffix) ? 'web' + suffix : prefix + '-web';
1280
1397
  return `https://${webPrefix}${prSuffix || ''}.onrender.com`;
1281
1398
  }
1282
1399
 
1283
- // Other tunnels or unknown hosts: return as-is
1284
1400
  return apiRoot;
1285
1401
  }
1286
1402
 
@@ -31,8 +31,7 @@ class Dashcam {
31
31
  this.apiKey =
32
32
  options.apiKey ||
33
33
  client.apiKey ||
34
- client.config?.TD_API_KEY ||
35
- "4e93d8bf-3886-4d26-a144-116c4063522d";
34
+ client.config?.TD_API_KEY;
36
35
  this.autoStart = options.autoStart ?? false;
37
36
  this.logs = options.logs || [];
38
37
  this.recording = false;
@@ -104,7 +103,7 @@ class Dashcam {
104
103
  return `https://console-${envMatch[1]}.testdriver.ai`;
105
104
  }
106
105
 
107
- // Production: API on render.com or v6 -> Console on testdriver.ai
106
+ // Production: API on custom domain or v6 -> Console on testdriver.ai
108
107
  if (
109
108
  apiRoot.includes("api.testdriver.ai") ||
110
109
  apiRoot.includes("v6.testdriver.ai")
@@ -117,24 +116,19 @@ class Dashcam {
117
116
  return "http://localhost:3001";
118
117
  }
119
118
 
120
- // Render PR previews: map API service to Web service
121
- // canary-api-pr-123.onrender.com -> canary-web-pr-123.onrender.com
122
- // testdriver-api-i4m4-pr-123.onrender.com -> web-i4m4-pr-123.onrender.com
123
- const renderPrMatch = apiRoot.match(/https:\/\/([\w-]+)-api(-[\w]+)?(-pr-\d+)?\.onrender\.com/);
124
- if (renderPrMatch) {
125
- const [, prefix, suffix, prSuffix] = renderPrMatch;
126
- // Map API naming to Web naming:
127
- // canary-api -> canary-web
128
- // testdriver-api-i4m4 -> web-i4m4
129
- let webPrefix;
130
- if (prefix === 'testdriver' && suffix) {
131
- // testdriver-api-i4m4 -> web-i4m4
132
- webPrefix = 'web' + suffix;
133
- } else {
134
- // canary-api -> canary-web
135
- webPrefix = prefix + '-web';
136
- }
137
- return `https://${webPrefix}${prSuffix || ''}.onrender.com`;
119
+ // Fly.io PR previews: map API app to Web app
120
+ // pr-123-api.fly.dev -> pr-123-web.fly.dev
121
+ const flyPrMatch = apiRoot.match(/https:\/\/(pr-\d+)-api\.fly\.dev/);
122
+ if (flyPrMatch) {
123
+ const [, prPrefix] = flyPrMatch;
124
+ return `https://${prPrefix}-web.fly.dev`;
125
+ }
126
+
127
+ // Fly.io environment apps: test-api.fly.dev -> test-web.fly.dev
128
+ const flyEnvMatch = apiRoot.match(/https:\/\/([\w-]+)-api\.fly\.dev/);
129
+ if (flyEnvMatch) {
130
+ const [, prefix] = flyEnvMatch;
131
+ return `https://${prefix}-web.fly.dev`;
138
132
  }
139
133
 
140
134
  // Cloudflare tunnels, custom domains, etc.: the web console is served
@@ -446,7 +440,7 @@ class Dashcam {
446
440
  this.recording = false;
447
441
 
448
442
  // Extract the /replay/... path from CLI output and reconstruct the URL
449
- // using getConsoleUrl(). The CLI may return a wrong domain (e.g. canary-web.onrender.com)
443
+ // using getConsoleUrl(). The CLI may return a wrong domain
450
444
  // so we always rewrite the base URL to match the current environment.
451
445
  if (output) {
452
446
  // Match /replay/{id} with optional query params from any URL or broken prefix
@@ -1,18 +1,22 @@
1
1
  {
2
2
  "dev": {
3
3
  "apiRoot": "https://api-dev.testdriver.ai",
4
- "consoleUrl": "https://console-dev.testdriver.ai"
4
+ "consoleUrl": "https://console-dev.testdriver.ai",
5
+ "tdEnv": "dev"
5
6
  },
6
7
  "test": {
7
8
  "apiRoot": "https://api-test.testdriver.ai",
8
- "consoleUrl": "https://console-test.testdriver.ai"
9
+ "consoleUrl": "https://console-test.testdriver.ai",
10
+ "tdEnv": "staging"
9
11
  },
10
12
  "canary": {
11
13
  "apiRoot": "https://api-canary.testdriver.ai",
12
- "consoleUrl": "https://console-canary.testdriver.ai"
14
+ "consoleUrl": "https://console-canary.testdriver.ai",
15
+ "tdEnv": "production"
13
16
  },
14
17
  "stable": {
15
18
  "apiRoot": "https://api.testdriver.ai",
16
- "consoleUrl": "https://console.testdriver.ai"
19
+ "consoleUrl": "https://console.testdriver.ai",
20
+ "tdEnv": "production"
17
21
  }
18
22
  }