testdriverai 7.8.0-test.7 → 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.
- package/agent/index.js +6 -5
- package/agent/lib/commands.js +3 -2
- package/agent/lib/http.js +144 -0
- package/agent/lib/sandbox.js +117 -102
- package/agent/lib/sdk.js +4 -2
- package/agent/lib/system.js +25 -65
- package/ai/skills/testdriver-cache/SKILL.md +221 -0
- package/ai/skills/testdriver-errors/SKILL.md +246 -0
- package/ai/skills/testdriver-events/SKILL.md +356 -0
- package/ai/skills/testdriver-mcp/SKILL.md +7 -0
- package/ai/skills/testdriver-provision/SKILL.md +331 -0
- package/ai/skills/testdriver-redraw/SKILL.md +214 -0
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
- package/docs/changelog.mdx +122 -8
- package/docs/docs.json +44 -37
- package/docs/images/content/vscode/v7-chat.png +0 -0
- package/docs/images/content/vscode/v7-choose-agent.png +0 -0
- package/docs/images/content/vscode/v7-full.png +0 -0
- package/docs/images/content/vscode/v7-onboarding.png +0 -0
- package/docs/v7/cache.mdx +223 -0
- package/docs/v7/copilot/auto-healing.mdx +265 -0
- package/docs/v7/copilot/creating-tests.mdx +156 -0
- package/docs/v7/copilot/github.mdx +143 -0
- package/docs/v7/copilot/running-tests.mdx +149 -0
- package/docs/v7/copilot/setup.mdx +143 -0
- package/docs/v7/enterprise.mdx +3 -110
- package/docs/v7/errors.mdx +248 -0
- package/docs/v7/events.mdx +358 -0
- package/docs/v7/examples/exec-output.mdx +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
- package/docs/v7/mcp.mdx +9 -0
- package/docs/v7/provision.mdx +333 -0
- package/docs/v7/quickstart.mdx +30 -2
- package/docs/v7/redraw.mdx +216 -0
- package/docs/v7/running-tests.mdx +1 -1
- package/docs/v7/screenshots.mdx +186 -0
- package/docs/v7/self-hosted.mdx +127 -44
- package/interfaces/logger.js +0 -12
- package/interfaces/vitest-plugin.mjs +3 -0
- package/lib/core/Dashcam.js +11 -7
- package/lib/environments.json +18 -0
- package/lib/resolve-channel.js +4 -3
- package/{examples → manual}/drag-and-drop.test.mjs +1 -1
- package/package.json +3 -3
- package/sdk.js +3 -3
- package/vitest.config.mjs +20 -32
- /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
- /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
- /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
- /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
- /package/{examples → manual}/flake-shared.mjs +0 -0
- /package/{examples → manual}/no-provision.test.mjs +0 -0
- /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://
|
|
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
|
|
1956
|
+
return environments.stable.consoleUrl;
|
|
1956
1957
|
}
|
|
1957
1958
|
|
|
1958
1959
|
// Write session file for IDE preview (VSCode extension watches for these)
|
package/agent/lib/commands.js
CHANGED
|
@@ -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
|
+
};
|
package/agent/lib/sandbox.js
CHANGED
|
@@ -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
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/agent/lib/system.js
CHANGED
|
@@ -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
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
46
|
+
return Buffer.from(imageResponse.data).toString("base64");
|
|
87
47
|
};
|
|
88
48
|
|
|
89
49
|
const screenshot = async (options) => {
|