testdriverai 7.8.0-test.8 → 7.8.0-test.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.
Files changed (54) hide show
  1. package/agent/index.js +6 -5
  2. package/agent/lib/commands.js +3 -2
  3. package/agent/lib/http.js +144 -0
  4. package/agent/lib/sandbox.js +117 -102
  5. package/agent/lib/sdk.js +4 -2
  6. package/agent/lib/system.js +25 -65
  7. package/ai/skills/testdriver-mcp/SKILL.md +7 -0
  8. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  9. package/docs/changelog.mdx +122 -8
  10. package/docs/docs.json +44 -37
  11. package/docs/images/content/vscode/v7-chat.png +0 -0
  12. package/docs/images/content/vscode/v7-choose-agent.png +0 -0
  13. package/docs/images/content/vscode/v7-full.png +0 -0
  14. package/docs/images/content/vscode/v7-onboarding.png +0 -0
  15. package/docs/v7/cache.mdx +223 -0
  16. package/docs/v7/copilot/auto-healing.mdx +265 -0
  17. package/docs/v7/copilot/creating-tests.mdx +156 -0
  18. package/docs/v7/copilot/github.mdx +143 -0
  19. package/docs/v7/copilot/running-tests.mdx +149 -0
  20. package/docs/v7/copilot/setup.mdx +143 -0
  21. package/docs/v7/enterprise.mdx +3 -110
  22. package/docs/v7/errors.mdx +248 -0
  23. package/docs/v7/events.mdx +358 -0
  24. package/docs/v7/examples/exec-output.mdx +85 -0
  25. package/docs/v7/examples/exec-pwsh.mdx +83 -0
  26. package/docs/v7/examples/focus-window.mdx +62 -0
  27. package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
  28. package/docs/v7/mcp.mdx +9 -0
  29. package/docs/v7/provision.mdx +333 -0
  30. package/docs/v7/quickstart.mdx +30 -2
  31. package/docs/v7/redraw.mdx +216 -0
  32. package/docs/v7/running-tests.mdx +1 -1
  33. package/docs/v7/screenshots.mdx +186 -0
  34. package/docs/v7/self-hosted.mdx +127 -44
  35. package/interfaces/logger.js +0 -12
  36. package/interfaces/vitest-plugin.mjs +3 -0
  37. package/lib/core/Dashcam.js +11 -7
  38. package/lib/environments.json +18 -0
  39. package/lib/resolve-channel.js +4 -3
  40. package/{examples → manual}/drag-and-drop.test.mjs +1 -1
  41. package/package.json +3 -3
  42. package/sdk.js +3 -3
  43. package/vitest.config.mjs +20 -32
  44. /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
  45. /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
  46. /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
  47. /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
  48. /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
  49. /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
  50. /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
  51. /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
  52. /package/{examples → manual}/flake-shared.mjs +0 -0
  53. /package/{examples → manual}/no-provision.test.mjs +0 -0
  54. /package/{examples → manual}/scroll-until-text.test.mjs +0 -0
package/agent/index.js CHANGED
@@ -1941,18 +1941,19 @@ ${regression}
1941
1941
  // Allow explicit override via env (e.g. VITE_DOMAIN from .env)
1942
1942
  if (process.env.VITE_DOMAIN) return process.env.VITE_DOMAIN;
1943
1943
 
1944
+ const environments = require("../lib/environments.json");
1944
1945
  const mapping = {
1945
- "https://api.testdriver.ai": "https://console.testdriver.ai",
1946
- "https://v6.testdriver.ai": "https://console.testdriver.ai",
1947
- "https://api-canary.testdriver.ai": "https://console-canary.testdriver.ai",
1948
- "https://api-test.testdriver.ai": "https://console-test.testdriver.ai",
1946
+ "https://v6.testdriver.ai": environments.stable.consoleUrl,
1949
1947
  };
1948
+ for (const env of Object.values(environments)) {
1949
+ mapping[env.apiRoot] = env.consoleUrl;
1950
+ }
1950
1951
  if (mapping[apiRoot]) return mapping[apiRoot];
1951
1952
  // Local dev: API on localhost:1337 -> Web on localhost:3001
1952
1953
  if (apiRoot.includes("localhost:1337") || apiRoot.includes("127.0.0.1:1337")) {
1953
1954
  return "http://localhost:3001";
1954
1955
  }
1955
- return "https://console.testdriver.ai";
1956
+ return environments.stable.consoleUrl;
1956
1957
  }
1957
1958
 
1958
1959
  // Write session file for IDE preview (VSCode extension watches for these)
@@ -580,11 +580,12 @@ const createCommands = (
580
580
  } else if (action === "mouseDown") {
581
581
  await sandbox.send({ type: "mousePress", button: "left", x, y, ...elementData });
582
582
  } else if (action === "mouseUp") {
583
+ // Move first to create drag motion, then release
584
+ // (pyautogui.mouseUp with x/y teleports instead of dragging)
585
+ await sandbox.send({ type: "moveMouse", x, y, ...elementData });
583
586
  await sandbox.send({
584
587
  type: "mouseRelease",
585
588
  button: "left",
586
- x,
587
- y,
588
589
  ...elementData
589
590
  });
590
591
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Shared HTTP client for the TestDriver SDK.
3
+ *
4
+ * All SDK HTTP traffic should go through these helpers so that
5
+ * User-Agent, timeouts, Sentry tracing headers, and response
6
+ * parsing are handled in one place.
7
+ *
8
+ * Uses axios under the hood — the same library the rest of the SDK
9
+ * already depends on.
10
+ */
11
+
12
+ const axios = require("axios");
13
+ const crypto = require("crypto");
14
+ const { version } = require("../../package.json");
15
+
16
+ const USER_AGENT = `TestDriverSDK/${version} (Node.js ${process.version})`;
17
+
18
+ /**
19
+ * Generate Sentry distributed-tracing headers from a session ID.
20
+ * Both sandbox.js and sdk.js duplicated this — it now lives here.
21
+ *
22
+ * @param {string} sessionId
23
+ * @returns {object} Headers object (empty if no sessionId)
24
+ */
25
+ function getSentryTraceHeaders(sessionId) {
26
+ if (!sessionId) return {};
27
+ const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
28
+ const spanId = crypto.randomBytes(8).toString("hex");
29
+ return {
30
+ "sentry-trace": traceId + "-" + spanId + "-1",
31
+ baggage:
32
+ "sentry-trace_id=" +
33
+ traceId +
34
+ ",sentry-sample_rate=1.0,sentry-sampled=true",
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Build common request headers.
40
+ * @param {object} [extra] - Additional headers to merge
41
+ * @returns {object}
42
+ */
43
+ function baseHeaders(extra) {
44
+ return {
45
+ "User-Agent": USER_AGENT,
46
+ ...extra,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * POST JSON to `url` and return the parsed response body.
52
+ *
53
+ * @param {string} url - Absolute URL
54
+ * @param {object} [data] - JSON body
55
+ * @param {object} [opts] - Extra axios config (headers, timeout, …)
56
+ * @returns {Promise<object>} Parsed response data
57
+ */
58
+ async function httpPost(url, data, opts = {}) {
59
+ const { headers: extraHeaders, ...rest } = opts;
60
+ const res = await axios({
61
+ method: "post",
62
+ url,
63
+ headers: baseHeaders({
64
+ "Content-Type": "application/json",
65
+ ...extraHeaders,
66
+ }),
67
+ data,
68
+ timeout: opts.timeout || 30000,
69
+ ...rest,
70
+ });
71
+ return res.data;
72
+ }
73
+
74
+ /**
75
+ * GET `url` and return the parsed response body.
76
+ *
77
+ * @param {string} url - Absolute URL
78
+ * @param {object} [opts] - Extra axios config
79
+ * @returns {Promise<object>} Parsed response data
80
+ */
81
+ async function httpGet(url, opts = {}) {
82
+ const { headers: extraHeaders, ...rest } = opts;
83
+ const res = await axios({
84
+ method: "get",
85
+ url,
86
+ headers: baseHeaders(extraHeaders),
87
+ timeout: opts.timeout || 30000,
88
+ ...rest,
89
+ });
90
+ return res.data;
91
+ }
92
+
93
+ /**
94
+ * PUT data to `url` (e.g. S3 presigned upload).
95
+ *
96
+ * @param {string} url - Absolute URL
97
+ * @param {Buffer|string} data - Request body
98
+ * @param {object} [opts] - Extra axios config (headers, timeout, …)
99
+ * @returns {Promise<object>} Parsed response data (or empty object for 2xx with no body)
100
+ */
101
+ async function httpPut(url, data, opts = {}) {
102
+ const { headers: extraHeaders, ...rest } = opts;
103
+ const res = await axios({
104
+ method: "put",
105
+ url,
106
+ headers: baseHeaders(extraHeaders),
107
+ data,
108
+ timeout: opts.timeout || 30000,
109
+ maxBodyLength: Infinity,
110
+ maxContentLength: Infinity,
111
+ ...rest,
112
+ });
113
+ return res.data;
114
+ }
115
+
116
+ /**
117
+ * Download a URL as a Buffer (e.g. screenshot from S3).
118
+ *
119
+ * @param {string} url - Absolute URL
120
+ * @param {object} [opts] - Extra axios config
121
+ * @returns {Promise<Buffer>}
122
+ */
123
+ async function downloadBuffer(url, opts = {}) {
124
+ const { headers: extraHeaders, ...rest } = opts;
125
+ const res = await axios({
126
+ method: "get",
127
+ url,
128
+ headers: baseHeaders(extraHeaders),
129
+ responseType: "arraybuffer",
130
+ timeout: opts.timeout || 60000,
131
+ ...rest,
132
+ });
133
+ return Buffer.from(res.data);
134
+ }
135
+
136
+ module.exports = {
137
+ httpPost,
138
+ httpGet,
139
+ httpPut,
140
+ downloadBuffer,
141
+ getSentryTraceHeaders,
142
+ USER_AGENT,
143
+ baseHeaders,
144
+ };
@@ -1,83 +1,9 @@
1
- const crypto = require("crypto");
2
1
  const Ably = require("ably");
2
+ const axios = require("axios");
3
3
  const { events } = require("../events");
4
4
  const logger = require("./logger");
5
5
  const { version } = require("../../package.json");
6
-
7
- function getSentryTraceHeaders(sessionId) {
8
- if (!sessionId) return {};
9
- const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
10
- const spanId = crypto.randomBytes(8).toString("hex");
11
- return {
12
- "sentry-trace": traceId + "-" + spanId + "-1",
13
- baggage:
14
- "sentry-trace_id=" +
15
- traceId +
16
- ",sentry-sample_rate=1.0,sentry-sampled=true",
17
- };
18
- }
19
-
20
- function httpPost(apiRoot, path, body, timeout) {
21
- const http = require("http");
22
- const https = require("https");
23
- const url = new URL(apiRoot + path);
24
- const transport = url.protocol === "https:" ? https : http;
25
- const bodyStr = JSON.stringify(body);
26
-
27
- return new Promise(function (resolve, reject) {
28
- var timeoutId = timeout
29
- ? setTimeout(function () {
30
- req.destroy();
31
- reject(
32
- new Error("HTTP request timed out after " + timeout + "ms"),
33
- );
34
- }, timeout)
35
- : null;
36
-
37
- var req = transport.request(
38
- url,
39
- {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- "Content-Length": Buffer.byteLength(bodyStr),
44
- "Connection": "close",
45
- },
46
- },
47
- function (res) {
48
- var data = "";
49
- res.on("data", function (chunk) {
50
- data += chunk;
51
- });
52
- res.on("end", function () {
53
- if (timeoutId) clearTimeout(timeoutId);
54
- try {
55
- var parsed = JSON.parse(data);
56
- if (res.statusCode >= 400) {
57
- var err = new Error(
58
- parsed.errorMessage ||
59
- parsed.message ||
60
- "HTTP " + res.statusCode,
61
- );
62
- err.responseData = parsed;
63
- reject(err);
64
- } else {
65
- resolve(parsed);
66
- }
67
- } catch (e) {
68
- reject(new Error("Failed to parse API response: " + data));
69
- }
70
- });
71
- },
72
- );
73
- req.on("error", function (err) {
74
- if (timeoutId) clearTimeout(timeoutId);
75
- reject(err);
76
- });
77
- req.write(bodyStr);
78
- req.end();
79
- });
80
- }
6
+ const { withRetry, getSentryTraceHeaders } = require("./sdk");
81
7
 
82
8
  const createSandbox = function (emitter, analytics, sessionInstance) {
83
9
  class Sandbox {
@@ -105,6 +31,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
105
31
  this._lastConnectParams = null;
106
32
  this._teamId = null;
107
33
  this._sandboxId = null;
34
+
35
+ // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
36
+ this._publishLastTime = 0;
37
+ this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
38
+ this._publishCount = 0;
39
+ this._publishWindowStart = Date.now();
108
40
  }
109
41
 
110
42
  getTraceId() {
@@ -298,40 +230,79 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
298
230
  }
299
231
 
300
232
  /**
301
- * Wrapper around httpPost that retries when the server responds with
302
- * CONCURRENCY_LIMIT_EXCEEDED (HTTP 429). Instead of failing the test
303
- * immediately, we wait for a slot to become available — polling every
304
- * 10 s until vitest's testTimeout kills the test.
233
+ * POST to the API with retry for transient network errors (via withRetry)
234
+ * and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
235
+ * testTimeout kills the test).
305
236
  */
306
237
  async _httpPostWithConcurrencyRetry(path, body, timeout) {
307
- var retryInterval = 10000; // 10 seconds between retries
238
+ var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
308
239
  var startTime = Date.now();
240
+ var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
241
+
242
+ var self = this;
243
+ var makeRequest = function () {
244
+ return axios({
245
+ method: "post",
246
+ url: self.apiRoot + path,
247
+ data: body,
248
+ headers: {
249
+ "Content-Type": "application/json",
250
+ "User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
251
+ ...getSentryTraceHeaders(sessionId),
252
+ },
253
+ timeout: timeout || 120000,
254
+ });
255
+ };
309
256
 
310
257
  while (true) {
311
258
  try {
312
- return await httpPost(this.apiRoot, path, body, timeout);
259
+ var response = await withRetry(makeRequest, {
260
+ retryConfig: {
261
+ maxRetries: 3,
262
+ baseDelayMs: 2000,
263
+ retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
264
+ },
265
+ onRetry: function (attempt, error, delayMs) {
266
+ var elapsed = Date.now() - startTime;
267
+ logger.warn(
268
+ "Transient network error: " + (error.message || error.code) +
269
+ " — POST " + path +
270
+ " — retry " + attempt + "/3" +
271
+ " in " + (delayMs / 1000).toFixed(1) + "s" +
272
+ " (" + Math.round(elapsed / 1000) + "s elapsed)...",
273
+ );
274
+ },
275
+ });
276
+ return response.data;
313
277
  } catch (err) {
314
- var isConcurrencyLimit =
315
- err.responseData &&
316
- err.responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED";
317
-
318
- if (!isConcurrencyLimit) {
319
- throw err;
278
+ // Concurrency limit — poll forever until a slot opens
279
+ var responseData = err.response && err.response.data;
280
+ if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
281
+ var elapsed = Date.now() - startTime;
282
+ logger.log(
283
+ "Concurrency limit reached — waiting " +
284
+ concurrencyRetryInterval / 1000 +
285
+ "s for a slot to become available (" +
286
+ Math.round(elapsed / 1000) +
287
+ "s elapsed)...",
288
+ );
289
+ await new Promise(function (resolve) {
290
+ var t = setTimeout(resolve, concurrencyRetryInterval);
291
+ if (t.unref) t.unref();
292
+ });
293
+ continue;
320
294
  }
321
295
 
322
- var elapsed = Date.now() - startTime;
296
+ // Non-retryable HTTP error preserve responseData for callers
297
+ if (responseData) {
298
+ var httpErr = new Error(
299
+ responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
300
+ );
301
+ httpErr.responseData = responseData;
302
+ throw httpErr;
303
+ }
323
304
 
324
- logger.log(
325
- "Concurrency limit reached — waiting " +
326
- retryInterval / 1000 +
327
- "s for a slot to become available (" +
328
- Math.round(elapsed / 1000) +
329
- "s elapsed)...",
330
- );
331
- await new Promise(function (resolve) {
332
- var t = setTimeout(resolve, retryInterval);
333
- if (t.unref) t.unref();
334
- });
305
+ throw err;
335
306
  }
336
307
  }
337
308
  }
@@ -759,8 +730,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
759
730
  p.catch(function () {});
760
731
  }
761
732
 
762
- this._cmdChannel
763
- .publish("command", message)
733
+ this._throttledPublish(this._cmdChannel, "command", message)
764
734
  .then(function () {
765
735
  emitter.emit(events.sandbox.sent, message);
766
736
  })
@@ -777,6 +747,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
777
747
  return p;
778
748
  }
779
749
 
750
+ /**
751
+ * Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
752
+ * Also tracks and logs the current publish rate for debugging.
753
+ * @param {Object} channel - Ably channel to publish on
754
+ * @param {string} eventName - Event name for the publish
755
+ * @param {Object} message - Message payload
756
+ * @returns {Promise} - Resolves when publish completes
757
+ */
758
+ async _throttledPublish(channel, eventName, message) {
759
+ var self = this;
760
+ var now = Date.now();
761
+
762
+ // Rate limiting: wait if too soon since last publish
763
+ var elapsed = now - this._publishLastTime;
764
+ if (elapsed < this._publishMinIntervalMs) {
765
+ var waitMs = this._publishMinIntervalMs - elapsed;
766
+ await new Promise(function (resolve) {
767
+ var timer = setTimeout(resolve, waitMs);
768
+ if (timer.unref) timer.unref();
769
+ });
770
+ }
771
+ this._publishLastTime = Date.now();
772
+
773
+ // Metrics: track messages per second
774
+ this._publishCount++;
775
+ var windowElapsed = Date.now() - this._publishWindowStart;
776
+ if (windowElapsed >= 1000) {
777
+ var rate = (this._publishCount / windowElapsed) * 1000;
778
+ var rateStr = rate.toFixed(1);
779
+
780
+ // Log rate - warning if approaching limit, debug otherwise
781
+ if (rate > 45) {
782
+ logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
783
+ } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
784
+ logger.log("Ably publish rate: " + rateStr + " msg/sec");
785
+ }
786
+
787
+ // Reset window
788
+ this._publishCount = 0;
789
+ this._publishWindowStart = Date.now();
790
+ }
791
+
792
+ return channel.publish(eventName, message);
793
+ }
794
+
780
795
  async auth(apiKey) {
781
796
  this.apiKey = apiKey;
782
797
  var sessionId = this.sessionInstance
package/agent/lib/sdk.js CHANGED
@@ -231,6 +231,7 @@ const createSDK = (emitter, config, sessionInstance) => {
231
231
  let res = await withRetry(
232
232
  () => axios(url, c),
233
233
  {
234
+ retryConfig: { maxRetries: 2 },
234
235
  onRetry: (attempt, error, delayMs) => {
235
236
  emitter.emit(events.sdk.retry, {
236
237
  path: 'auth/exchange-api-key',
@@ -381,6 +382,7 @@ const createSDK = (emitter, config, sessionInstance) => {
381
382
  "Content-Type": "application/json",
382
383
  "User-Agent": `TestDriverSDK/${version} (Node.js ${process.version})`,
383
384
  ...(token && { Authorization: `Bearer ${token}` }),
385
+ ...getSentryTraceHeaders(sessionInstance.get()),
384
386
  },
385
387
  timeout: 15000,
386
388
  data: {
@@ -627,5 +629,5 @@ const createSDK = (emitter, config, sessionInstance) => {
627
629
  return { req, auth };
628
630
  };
629
631
 
630
- // Export the factory function
631
- module.exports = { createSDK };
632
+ // Export the factory function and shared utilities
633
+ module.exports = { createSDK, withRetry, getSentryTraceHeaders, sleep };
@@ -4,6 +4,8 @@ const os = require("os");
4
4
  const path = require("path");
5
5
  const { randomUUID } = require("crypto");
6
6
  const Jimp = require("jimp");
7
+ const axios = require("axios");
8
+ const { withRetry } = require("./sdk");
7
9
  const { events } = require("../events.js");
8
10
 
9
11
  const createSystem = (emitter, sandbox, config) => {
@@ -11,79 +13,37 @@ const createSystem = (emitter, sandbox, config) => {
11
13
  // Download a screenshot from S3 when the runner returns an s3Key
12
14
  // (screenshots exceed Ably's 64KB message limit)
13
15
  const downloadFromS3 = async (s3Key) => {
14
- const https = require("https");
15
- const http = require("http");
16
16
  const apiRoot = config["TD_API_ROOT"] || sandbox.apiRoot;
17
17
  const apiKey = sandbox.apiKey;
18
18
 
19
- // Step 1: Get presigned download URL from API (with retry on rate-limit)
20
- const body = JSON.stringify({ apiKey, s3Key });
21
- const url = new URL(apiRoot + "/api/v7/runner/download-url");
22
- const transport = url.protocol === "https:" ? https : http;
23
-
24
- const MAX_RETRIES = 3;
25
- let downloadUrl;
26
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
27
- try {
28
- downloadUrl = await new Promise((resolve, reject) => {
29
- const req = transport.request(url, {
30
- method: "POST",
31
- headers: {
32
- "Content-Type": "application/json",
33
- "Content-Length": Buffer.byteLength(body),
34
- "Connection": "close",
35
- },
36
- }, (res) => {
37
- let data = "";
38
- res.on("data", (chunk) => { data += chunk; });
39
- res.on("end", () => {
40
- if (res.statusCode === 429) {
41
- return reject({ retryable: true, message: "Rate limited (429) from download-url endpoint" });
42
- }
43
- if (res.statusCode >= 400) {
44
- return reject(new Error(`download-url request failed (HTTP ${res.statusCode}): ${data}`));
45
- }
46
- try {
47
- const parsed = JSON.parse(data);
48
- if (parsed.downloadUrl) {
49
- resolve(parsed.downloadUrl);
50
- } else {
51
- reject(new Error("No downloadUrl in response: " + data));
52
- }
53
- } catch (e) {
54
- reject({ retryable: true, message: "Failed to parse download-url response: " + data });
55
- }
56
- });
57
- });
58
- req.on("error", reject);
59
- req.write(body);
60
- req.end();
61
- });
62
- break; // success — exit retry loop
63
- } catch (err) {
64
- if (err && err.retryable && attempt < MAX_RETRIES) {
65
- const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
66
- await new Promise((r) => setTimeout(r, delay));
67
- continue;
68
- }
69
- throw err instanceof Error ? err : new Error(err.message || String(err));
70
- }
19
+ // Step 1: Get presigned download URL from API (with retry)
20
+ const response = await withRetry(
21
+ () => axios({
22
+ method: "post",
23
+ url: apiRoot + "/api/v7/runner/download-url",
24
+ data: { apiKey, s3Key },
25
+ headers: { "Content-Type": "application/json" },
26
+ timeout: 15000,
27
+ }),
28
+ {
29
+ retryConfig: { maxRetries: 3, baseDelayMs: 1000 },
30
+ },
31
+ );
32
+
33
+ const downloadUrl = response.data.downloadUrl;
34
+ if (!downloadUrl) {
35
+ throw new Error("No downloadUrl in response: " + JSON.stringify(response.data));
71
36
  }
72
37
 
73
38
  // Step 2: Download the image from S3
74
- const imageUrl = new URL(downloadUrl);
75
- const s3Transport = imageUrl.protocol === "https:" ? https : http;
76
-
77
- const imageBuffer = await new Promise((resolve, reject) => {
78
- s3Transport.get(downloadUrl, { headers: { "Connection": "close" } }, (res) => {
79
- const chunks = [];
80
- res.on("data", (chunk) => chunks.push(chunk));
81
- res.on("end", () => resolve(Buffer.concat(chunks)));
82
- res.on("error", reject);
83
- }).on("error", reject);
39
+ const imageResponse = await axios({
40
+ method: "get",
41
+ url: downloadUrl,
42
+ responseType: "arraybuffer",
43
+ timeout: 30000,
84
44
  });
85
45
 
86
- return imageBuffer.toString("base64");
46
+ return Buffer.from(imageResponse.data).toString("base64");
87
47
  };
88
48
 
89
49
  const screenshot = async (options) => {
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: testdriver:mcp
3
+ description: mcp
4
+ ---
5
+ <!-- Generated from mcp.mdx. DO NOT EDIT. -->
6
+
7
+
@@ -133,7 +133,7 @@ export default defineConfig({
133
133
  <Card
134
134
  title="View Plans & Pricing"
135
135
  icon="credit-card"
136
- href="/v7/cloud"
136
+ href="/v7/hosted"
137
137
  >
138
138
  Compare plans and find the right level of parallelization for your team.
139
139
  </Card>