testdriverai 7.8.0-test.7 → 7.8.0-test.71
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 +18 -5
- package/agent/lib/commands.js +3 -2
- package/agent/lib/http.js +162 -0
- package/agent/lib/logger.js +15 -0
- package/agent/lib/sandbox.js +554 -209
- package/agent/lib/sdk.js +5 -22
- 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-find/SKILL.md +14 -20
- 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/_data/examples-manifest.json +46 -46
- package/docs/_scripts/extract-example-urls.js +67 -72
- package/docs/changelog.mdx +148 -8
- package/docs/docs.json +46 -38
- 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/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 +85 -0
- package/docs/v7/examples/exec-pwsh.mdx +83 -0
- package/docs/v7/examples/focus-window.mdx +62 -0
- 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/{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/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/logger.js +0 -12
- package/interfaces/vitest-plugin.mjs +170 -51
- package/lib/core/Dashcam.js +30 -23
- package/lib/environments.json +22 -0
- package/lib/github-comment.mjs +58 -40
- package/lib/init-project.js +5 -67
- package/lib/resolve-channel.js +42 -12
- package/lib/sentry.js +47 -23
- package/lib/vitest/hooks.mjs +63 -3
- package/{examples → manual}/drag-and-drop.test.mjs +1 -1
- 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 +4 -3
- package/sdk.d.ts +4 -0
- package/sdk.js +45 -15
- 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 +22 -34
- package/vitest.runner.config.mjs +33 -0
- /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/lib/sandbox.js
CHANGED
|
@@ -1,93 +1,17 @@
|
|
|
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
|
-
|
|
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");
|
|
7
|
+
const sentry = require("../../lib/sentry");
|
|
81
8
|
|
|
82
9
|
const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
83
10
|
class Sandbox {
|
|
84
11
|
constructor() {
|
|
85
12
|
this._ably = null;
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this._ctrlChannel = null;
|
|
89
|
-
this._filesChannel = null;
|
|
90
|
-
this._channelNames = null;
|
|
13
|
+
this._sessionChannel = null;
|
|
14
|
+
this._channelName = null;
|
|
91
15
|
this.ps = {};
|
|
92
16
|
this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
|
|
93
17
|
this.heartbeat = null;
|
|
@@ -105,6 +29,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
105
29
|
this._lastConnectParams = null;
|
|
106
30
|
this._teamId = null;
|
|
107
31
|
this._sandboxId = null;
|
|
32
|
+
this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
|
|
33
|
+
|
|
34
|
+
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
35
|
+
this._publishLastTime = 0;
|
|
36
|
+
this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
|
|
37
|
+
this._publishCount = 0;
|
|
38
|
+
this._publishWindowStart = Date.now();
|
|
108
39
|
}
|
|
109
40
|
|
|
110
41
|
getTraceId() {
|
|
@@ -118,7 +49,11 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
118
49
|
);
|
|
119
50
|
}
|
|
120
51
|
|
|
121
|
-
|
|
52
|
+
getPublishCount() {
|
|
53
|
+
return this._publishCount;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _initAbly(ablyToken, channelName) {
|
|
122
57
|
if (this._ably) {
|
|
123
58
|
try {
|
|
124
59
|
this._ably.close();
|
|
@@ -126,48 +61,69 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
126
61
|
/* ignore */
|
|
127
62
|
}
|
|
128
63
|
}
|
|
129
|
-
this.
|
|
64
|
+
this._channelName = channelName;
|
|
130
65
|
var self = this;
|
|
131
66
|
|
|
132
67
|
this._ably = new Ably.Realtime({
|
|
133
|
-
authCallback: function (tokenParams, callback) {
|
|
134
|
-
|
|
68
|
+
authCallback: async function (tokenParams, callback) {
|
|
69
|
+
// On initial connect Ably may supply the token directly; on renewal
|
|
70
|
+
// we must fetch a fresh one from the API (the original token will
|
|
71
|
+
// have expired, causing 40143 token.unrecognized if reused).
|
|
72
|
+
try {
|
|
73
|
+
const response = await axios({
|
|
74
|
+
method: "post",
|
|
75
|
+
url: self.apiRoot + "/api/v7/sandbox/ably-token",
|
|
76
|
+
data: { apiKey: self.apiKey, sandboxId: self._sandboxId },
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
timeout: 15000,
|
|
79
|
+
});
|
|
80
|
+
callback(null, response.data.token);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.warn("[ably] Token renewal failed, falling back to original token: " + (err.message || err));
|
|
83
|
+
callback(null, ablyToken);
|
|
84
|
+
}
|
|
135
85
|
},
|
|
136
86
|
clientId: "sdk-" + this._sandboxId,
|
|
87
|
+
echoMessages: false, // don't receive our own published messages
|
|
137
88
|
disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
|
|
138
89
|
suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
|
|
139
90
|
});
|
|
140
91
|
|
|
92
|
+
logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
|
|
93
|
+
|
|
141
94
|
await new Promise(function (resolve, reject) {
|
|
142
95
|
self._ably.connection.on("connected", resolve);
|
|
143
96
|
self._ably.connection.on("failed", function () {
|
|
144
|
-
reject(new Error("
|
|
97
|
+
reject(new Error("Realtime connection failed"));
|
|
145
98
|
});
|
|
146
99
|
setTimeout(function () {
|
|
147
|
-
reject(new Error("
|
|
100
|
+
reject(new Error("Realtime connection timeout"));
|
|
148
101
|
}, 30000);
|
|
149
102
|
});
|
|
150
103
|
|
|
151
|
-
this.
|
|
152
|
-
this._respChannel = this._ably.channels.get(channelNames.responses);
|
|
153
|
-
this._ctrlChannel = this._ably.channels.get(channelNames.control);
|
|
154
|
-
this._filesChannel = this._ably.channels.get(channelNames.files);
|
|
104
|
+
this._sessionChannel = this._ably.channels.get(channelName);
|
|
155
105
|
|
|
156
|
-
|
|
106
|
+
logger.debug(`[realtime] Channel initialized: ${channelName}`);
|
|
107
|
+
|
|
108
|
+
// Enter presence on the session channel so the API can count connected SDK clients
|
|
157
109
|
try {
|
|
158
|
-
await this.
|
|
110
|
+
await this._sessionChannel.presence.enter({
|
|
159
111
|
sandboxId: this._sandboxId,
|
|
160
112
|
connectedAt: Date.now(),
|
|
161
113
|
});
|
|
114
|
+
logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
|
|
162
115
|
} catch (e) {
|
|
163
116
|
// Non-fatal — presence is used for concurrency counting, not critical path
|
|
164
|
-
logger.warn("Failed to enter presence on
|
|
117
|
+
logger.warn("Failed to enter presence on session channel: " + (e.message || e));
|
|
165
118
|
}
|
|
166
119
|
|
|
167
|
-
|
|
120
|
+
// Save subscription references for historyBeforeSubscribe() during discontinuity recovery
|
|
121
|
+
this._onResponseMsg = function (msg) {
|
|
168
122
|
var message = msg.data;
|
|
169
123
|
if (!message) return;
|
|
170
124
|
|
|
125
|
+
logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
126
|
+
|
|
171
127
|
if (message.type === "sandbox.progress") {
|
|
172
128
|
emitter.emit(events.sandbox.progress, {
|
|
173
129
|
step: message.step,
|
|
@@ -218,31 +174,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
218
174
|
}
|
|
219
175
|
|
|
220
176
|
if (!message.requestId || !self.ps[message.requestId]) {
|
|
221
|
-
var
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
177
|
+
var pendingIds = Object.keys(self.ps);
|
|
178
|
+
var pendingSummary = pendingIds.length > 0
|
|
179
|
+
? pendingIds.map(function (rid) {
|
|
180
|
+
var e = self.ps[rid];
|
|
181
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
|
|
182
|
+
}).join(', ')
|
|
183
|
+
: 'none';
|
|
184
|
+
logger.debug(
|
|
185
|
+
'[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
|
|
186
|
+
' | response type=' + (message.type || 'unknown') +
|
|
187
|
+
' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
|
|
188
|
+
' | currently pending: [' + pendingSummary + ']'
|
|
189
|
+
);
|
|
229
190
|
return;
|
|
230
191
|
}
|
|
231
192
|
|
|
232
193
|
if (message.error) {
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
194
|
+
var pendingEntry = self.ps[message.requestId];
|
|
195
|
+
var pendingMessage = pendingEntry && pendingEntry.message;
|
|
196
|
+
var pendingAge = pendingEntry && pendingEntry.startTime
|
|
197
|
+
? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
|
|
198
|
+
: '?';
|
|
199
|
+
logger.debug(
|
|
200
|
+
'[realtime] Promise REJECTED: requestId=' + message.requestId +
|
|
201
|
+
' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
|
|
202
|
+
' | age=' + pendingAge +
|
|
203
|
+
' | error=' + (message.errorMessage || 'Sandbox error')
|
|
204
|
+
);
|
|
236
205
|
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
237
206
|
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
238
207
|
}
|
|
239
208
|
var error = new Error(message.errorMessage || "Sandbox error");
|
|
240
209
|
error.responseData = message;
|
|
241
210
|
delete self._execBuffers[message.requestId];
|
|
242
|
-
|
|
211
|
+
pendingEntry.reject(error);
|
|
243
212
|
} else {
|
|
244
213
|
emitter.emit(events.sandbox.received);
|
|
245
214
|
if (self.ps[message.requestId]) {
|
|
215
|
+
var resolveEntry = self.ps[message.requestId];
|
|
216
|
+
var resolveAge = resolveEntry.startTime
|
|
217
|
+
? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
|
|
218
|
+
: '?';
|
|
219
|
+
logger.debug(
|
|
220
|
+
'[realtime] Promise RESOLVED: requestId=' + message.requestId +
|
|
221
|
+
' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
|
|
222
|
+
' | age=' + resolveAge
|
|
223
|
+
);
|
|
246
224
|
// Unwrap the result from the Ably response envelope
|
|
247
225
|
// The runner sends { requestId, type, result, success }
|
|
248
226
|
// But SDK commands expect just the result object
|
|
@@ -261,77 +239,230 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
261
239
|
}
|
|
262
240
|
}
|
|
263
241
|
delete self.ps[message.requestId];
|
|
264
|
-
}
|
|
242
|
+
};
|
|
243
|
+
this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
|
|
265
244
|
|
|
266
|
-
this.
|
|
245
|
+
this._onFileMsg = function (msg) {
|
|
267
246
|
var message = msg.data;
|
|
268
247
|
if (!message) return;
|
|
248
|
+
logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
269
249
|
if (message.requestId && self.ps[message.requestId]) {
|
|
270
250
|
emitter.emit(events.sandbox.received);
|
|
271
251
|
self.ps[message.requestId].resolve(message);
|
|
272
252
|
delete self.ps[message.requestId];
|
|
273
253
|
}
|
|
274
254
|
emitter.emit(events.sandbox.file, message);
|
|
275
|
-
}
|
|
255
|
+
};
|
|
256
|
+
this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
|
|
276
257
|
|
|
277
|
-
this.heartbeat = setInterval(function () {}, 5000);
|
|
258
|
+
this.heartbeat = setInterval(function () { }, 5000);
|
|
278
259
|
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
279
260
|
|
|
261
|
+
// ─── Periodic stats logging ────────────────────────────────────────
|
|
262
|
+
this._statsInterval = setInterval(() => {
|
|
263
|
+
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
264
|
+
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
265
|
+
const pendingIds = Object.keys(this.ps);
|
|
266
|
+
const pending = pendingIds.length;
|
|
267
|
+
logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
268
|
+
if (pending > 0) {
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
for (const rid of pendingIds) {
|
|
271
|
+
const entry = this.ps[rid];
|
|
272
|
+
if (!entry) continue;
|
|
273
|
+
const type = entry.message ? entry.message.type : 'unknown';
|
|
274
|
+
const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
|
|
275
|
+
logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}, 10000);
|
|
279
|
+
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
280
|
+
|
|
280
281
|
this._ably.connection.on("disconnected", function () {
|
|
281
|
-
logger.
|
|
282
|
+
logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
|
|
283
|
+
self._disconnectedAt = Date.now();
|
|
282
284
|
});
|
|
283
285
|
|
|
284
286
|
this._ably.connection.on("connected", function () {
|
|
285
287
|
// Log reconnection so the user knows the blip was recovered
|
|
286
|
-
logger.
|
|
288
|
+
logger.debug("[realtime] Connection: reconnected");
|
|
289
|
+
// Extend any pending command timeouts by the disconnection duration so
|
|
290
|
+
// commands whose timer was counting down while the connection was down
|
|
291
|
+
// don't get incorrectly timed out.
|
|
292
|
+
if (self._disconnectedAt) {
|
|
293
|
+
var disconnectionDurationMs = Date.now() - self._disconnectedAt;
|
|
294
|
+
self._disconnectedAt = null;
|
|
295
|
+
var pendingIds = Object.keys(self.ps);
|
|
296
|
+
if (pendingIds.length > 0) {
|
|
297
|
+
logger.debug(
|
|
298
|
+
'[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
|
|
299
|
+
disconnectionDurationMs + 'ms after disconnection'
|
|
300
|
+
);
|
|
301
|
+
for (var i = 0; i < pendingIds.length; i++) {
|
|
302
|
+
var entry = self.ps[pendingIds[i]];
|
|
303
|
+
if (entry && typeof entry.extendTimeout === 'function') {
|
|
304
|
+
entry.extendTimeout(disconnectionDurationMs);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
287
309
|
});
|
|
288
310
|
|
|
289
311
|
this._ably.connection.on("suspended", function () {
|
|
290
|
-
logger.
|
|
312
|
+
logger.debug("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
|
|
291
313
|
});
|
|
292
314
|
|
|
293
315
|
this._ably.connection.on("failed", function () {
|
|
316
|
+
logger.debug("[realtime] Connection: failed");
|
|
294
317
|
self.apiSocketConnected = false;
|
|
295
318
|
self.instanceSocketConnected = false;
|
|
296
|
-
emitter.emit(events.error.sandbox, "
|
|
319
|
+
emitter.emit(events.error.sandbox, "Realtime connection failed");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ─── Channel discontinuity detection ──────────────────────────────
|
|
323
|
+
// Set up BEFORE subscribing so we catch any continuity loss during
|
|
324
|
+
// the initial attachment. Fires at the channel level, covering all
|
|
325
|
+
// message types (response, file, control).
|
|
326
|
+
this._sessionChannel.on(function (stateChange) {
|
|
327
|
+
var current = stateChange.current;
|
|
328
|
+
var previous = stateChange.previous;
|
|
329
|
+
var reason = stateChange.reason;
|
|
330
|
+
var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
|
|
331
|
+
|
|
332
|
+
if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
|
|
333
|
+
logger.debug('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
|
|
334
|
+
emitter.emit(events.sandbox.progress, {
|
|
335
|
+
step: 'discontinuity',
|
|
336
|
+
message: 'Recovering missed messages after connection interruption...',
|
|
337
|
+
});
|
|
338
|
+
self._recoverFromDiscontinuity();
|
|
339
|
+
}
|
|
297
340
|
});
|
|
298
341
|
}
|
|
299
342
|
|
|
300
343
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
344
|
+
* Recover missed messages after a channel discontinuity.
|
|
345
|
+
* Uses historyBeforeSubscribe() on each subscription, which guarantees
|
|
346
|
+
* no gap between historical and live messages. Each recovered message
|
|
347
|
+
* is dispatched through the same handler that processes live messages
|
|
348
|
+
* so that pending promises are resolved/rejected correctly.
|
|
349
|
+
*/
|
|
350
|
+
async _recoverFromDiscontinuity() {
|
|
351
|
+
var subs = [
|
|
352
|
+
{ name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
|
|
353
|
+
{ name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
|
|
354
|
+
];
|
|
355
|
+
var totalRecovered = 0;
|
|
356
|
+
for (var i = 0; i < subs.length; i++) {
|
|
357
|
+
var entry = subs[i];
|
|
358
|
+
if (!entry.sub) continue;
|
|
359
|
+
try {
|
|
360
|
+
logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
|
|
361
|
+
var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
|
|
362
|
+
var recovered = 0;
|
|
363
|
+
while (page) {
|
|
364
|
+
// Replay each missed message through the handler so pending
|
|
365
|
+
// promises get resolved instead of timing out.
|
|
366
|
+
for (var j = 0; j < page.items.length; j++) {
|
|
367
|
+
recovered++;
|
|
368
|
+
try {
|
|
369
|
+
if (entry.handler) {
|
|
370
|
+
logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
|
|
371
|
+
entry.handler(page.items[j]);
|
|
372
|
+
}
|
|
373
|
+
} catch (replayErr) {
|
|
374
|
+
logger.debug('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
page = page.hasNext() ? await page.next() : null;
|
|
378
|
+
}
|
|
379
|
+
totalRecovered += recovered;
|
|
380
|
+
logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
381
|
+
} catch (err) {
|
|
382
|
+
logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (totalRecovered > 0) {
|
|
386
|
+
logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
387
|
+
} else {
|
|
388
|
+
logger.debug('[realtime] Discontinuity recovery: no missed messages found');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* POST to the API with retry for transient network errors (via withRetry)
|
|
394
|
+
* and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
|
|
395
|
+
* testTimeout kills the test).
|
|
305
396
|
*/
|
|
306
397
|
async _httpPostWithConcurrencyRetry(path, body, timeout) {
|
|
307
|
-
var
|
|
398
|
+
var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
|
|
308
399
|
var startTime = Date.now();
|
|
400
|
+
var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
|
|
401
|
+
|
|
402
|
+
var self = this;
|
|
403
|
+
var makeRequest = function () {
|
|
404
|
+
return axios({
|
|
405
|
+
method: "post",
|
|
406
|
+
url: self.apiRoot + path,
|
|
407
|
+
data: body,
|
|
408
|
+
headers: {
|
|
409
|
+
"Content-Type": "application/json",
|
|
410
|
+
"User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
|
|
411
|
+
...getSentryTraceHeaders(sessionId),
|
|
412
|
+
},
|
|
413
|
+
timeout: timeout || 120000,
|
|
414
|
+
});
|
|
415
|
+
};
|
|
309
416
|
|
|
310
417
|
while (true) {
|
|
311
418
|
try {
|
|
312
|
-
|
|
419
|
+
var response = await withRetry(makeRequest, {
|
|
420
|
+
retryConfig: {
|
|
421
|
+
maxRetries: 3,
|
|
422
|
+
baseDelayMs: 2000,
|
|
423
|
+
retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
|
|
424
|
+
},
|
|
425
|
+
onRetry: function (attempt, error, delayMs) {
|
|
426
|
+
var elapsed = Date.now() - startTime;
|
|
427
|
+
logger.warn(
|
|
428
|
+
"Transient network error: " + (error.message || error.code) +
|
|
429
|
+
" — POST " + path +
|
|
430
|
+
" — retry " + attempt + "/3" +
|
|
431
|
+
" in " + (delayMs / 1000).toFixed(1) + "s" +
|
|
432
|
+
" (" + Math.round(elapsed / 1000) + "s elapsed)...",
|
|
433
|
+
);
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
return response.data;
|
|
313
437
|
} catch (err) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
var elapsed = Date.now() - startTime;
|
|
323
|
-
|
|
324
|
-
logger.log(
|
|
325
|
-
"Concurrency limit reached — waiting " +
|
|
326
|
-
retryInterval / 1000 +
|
|
438
|
+
// Concurrency limit — poll forever until a slot opens
|
|
439
|
+
var responseData = err.response && err.response.data;
|
|
440
|
+
if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
|
|
441
|
+
var elapsed = Date.now() - startTime;
|
|
442
|
+
logger.log(
|
|
443
|
+
"Concurrency limit reached — waiting " +
|
|
444
|
+
concurrencyRetryInterval / 1000 +
|
|
327
445
|
"s for a slot to become available (" +
|
|
328
446
|
Math.round(elapsed / 1000) +
|
|
329
447
|
"s elapsed)...",
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
448
|
+
);
|
|
449
|
+
await new Promise(function (resolve) {
|
|
450
|
+
var t = setTimeout(resolve, concurrencyRetryInterval);
|
|
451
|
+
if (t.unref) t.unref();
|
|
452
|
+
});
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Non-retryable HTTP error — preserve responseData for callers
|
|
457
|
+
if (responseData) {
|
|
458
|
+
var httpErr = new Error(
|
|
459
|
+
responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
|
|
460
|
+
);
|
|
461
|
+
httpErr.responseData = responseData;
|
|
462
|
+
throw httpErr;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
throw err;
|
|
335
466
|
}
|
|
336
467
|
}
|
|
337
468
|
}
|
|
@@ -362,6 +493,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
362
493
|
body.ci = message.ci;
|
|
363
494
|
if (message.ami) body.ami = message.ami;
|
|
364
495
|
if (message.instanceType) body.instanceType = message.instanceType;
|
|
496
|
+
if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
|
|
365
497
|
if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
|
|
366
498
|
}
|
|
367
499
|
|
|
@@ -390,14 +522,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
390
522
|
this._teamId = reply.teamId;
|
|
391
523
|
|
|
392
524
|
if (reply.ably && reply.ably.token) {
|
|
393
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
525
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
394
526
|
this.instanceSocketConnected = true;
|
|
395
527
|
|
|
396
528
|
// Tell the runner to enable debug log forwarding if debug mode is on
|
|
397
529
|
var debugMode =
|
|
398
530
|
process.env.VERBOSE || process.env.TD_DEBUG;
|
|
399
|
-
if (debugMode && this.
|
|
400
|
-
this.
|
|
531
|
+
if (debugMode && this._sessionChannel) {
|
|
532
|
+
this._sessionChannel.publish("control", {
|
|
401
533
|
type: "debug",
|
|
402
534
|
enabled: true,
|
|
403
535
|
});
|
|
@@ -405,47 +537,67 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
405
537
|
}
|
|
406
538
|
|
|
407
539
|
if (message.type === "create") {
|
|
408
|
-
// E2B (Linux) sandboxes
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return {
|
|
413
|
-
success: true,
|
|
414
|
-
sandbox: {
|
|
415
|
-
sandboxId: reply.sandboxId,
|
|
416
|
-
instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
|
|
417
|
-
os: body.os || 'linux',
|
|
418
|
-
url: reply.url,
|
|
419
|
-
},
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
540
|
+
// E2B (Linux) sandboxes return a url directly.
|
|
541
|
+
// We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
|
|
542
|
+
const isE2B = !!reply.url;
|
|
543
|
+
|
|
423
544
|
const runnerIp = reply.runner && reply.runner.ip;
|
|
424
545
|
const noVncPort = reply.runner && reply.runner.noVncPort;
|
|
425
546
|
const runnerVncUrl = reply.runner && reply.runner.vncUrl;
|
|
426
547
|
|
|
427
|
-
|
|
548
|
+
// Log image version info (AMI for Windows, E2B template for Linux)
|
|
549
|
+
if (reply.imageVersion) {
|
|
550
|
+
if (isE2B) {
|
|
551
|
+
logger.log('E2B image version: v' + reply.imageVersion + (reply.e2bTemplateId ? ' (template: ' + reply.e2bTemplateId + ')' : ''));
|
|
552
|
+
} else {
|
|
553
|
+
logger.log('AMI image version: v' + reply.imageVersion + (reply.amiId ? ' (ami: ' + reply.amiId + ')' : ''));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!isE2B) {
|
|
558
|
+
logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
|
|
559
|
+
}
|
|
428
560
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
561
|
+
// Wait for the runner agent to signal readiness before sending commands.
|
|
562
|
+
// Without this gate, commands published before the agent subscribes are lost.
|
|
563
|
+
// This applies to:
|
|
564
|
+
// - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
|
|
565
|
+
// - Windows EC2 sandboxes without presence runners
|
|
566
|
+
// For presence-based Windows runners (reply.runner already set), the runner
|
|
567
|
+
// is already listening so we can skip the wait.
|
|
432
568
|
var self = this;
|
|
433
|
-
|
|
569
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
570
|
+
if (needsReadyWait) {
|
|
434
571
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
435
|
-
|
|
572
|
+
// E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
|
|
573
|
+
// EC2 (Windows) cold starts can be slow due to AV scanning and native module loading
|
|
574
|
+
var readyTimeout = isE2B ? 300000 : 180000; // 5 min for E2B (S3+npm), 3 min for EC2
|
|
436
575
|
await new Promise(function (resolve, reject) {
|
|
437
576
|
var resolved = false;
|
|
577
|
+
var waitStart = Date.now();
|
|
438
578
|
function finish(data) {
|
|
439
579
|
if (resolved) return;
|
|
440
580
|
resolved = true;
|
|
441
581
|
clearTimeout(timer);
|
|
442
|
-
|
|
582
|
+
clearInterval(progressTimer);
|
|
583
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
443
584
|
// Update runner info if provided
|
|
444
585
|
if (data && data.os) reply.runner = reply.runner || {};
|
|
445
586
|
if (data && data.os && reply.runner) reply.runner.os = data.os;
|
|
446
587
|
if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
|
|
447
588
|
if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
|
|
589
|
+
// Persist version metadata for test result reporting
|
|
590
|
+
self._runnerVersionBefore = reply.imageVersion || null;
|
|
591
|
+
self._runnerVersionAfter = (data && data.runnerVersion) || reply.imageVersion || null;
|
|
592
|
+
self._wasUpdated = !!(data && data.runnerVersion && reply.imageVersion && data.runnerVersion !== reply.imageVersion);
|
|
448
593
|
logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
594
|
+
// Show upgrade info: if the runner's npm version differs from the baked image version,
|
|
595
|
+
// the runner was upgraded during provisioning.
|
|
596
|
+
var runnerVer = data && data.runnerVersion;
|
|
597
|
+
var imageVer = reply.imageVersion;
|
|
598
|
+
if (runnerVer && imageVer && runnerVer !== imageVer) {
|
|
599
|
+
logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
|
|
600
|
+
}
|
|
449
601
|
if (data && data.update) {
|
|
450
602
|
var u = data.update;
|
|
451
603
|
if (u.status === 'up-to-date') {
|
|
@@ -464,12 +616,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
464
616
|
var timer = setTimeout(function () {
|
|
465
617
|
if (!resolved) {
|
|
466
618
|
resolved = true;
|
|
467
|
-
|
|
468
|
-
|
|
619
|
+
clearInterval(progressTimer);
|
|
620
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
621
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
|
|
622
|
+
sentry.captureException(err, {
|
|
623
|
+
tags: { phase: 'runner_ready', connection_type: 'create' },
|
|
624
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
|
|
625
|
+
});
|
|
626
|
+
reject(err);
|
|
469
627
|
}
|
|
470
628
|
}, readyTimeout);
|
|
471
629
|
if (timer.unref) timer.unref();
|
|
472
630
|
|
|
631
|
+
// Log progress every 15s so the user knows we're still waiting
|
|
632
|
+
var progressTimer = setInterval(function () {
|
|
633
|
+
if (resolved) return;
|
|
634
|
+
var elapsed = Math.round((Date.now() - waitStart) / 1000);
|
|
635
|
+
logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
|
|
636
|
+
}, 15000);
|
|
637
|
+
|
|
473
638
|
// Listen for live runner.ready messages
|
|
474
639
|
var onCtrl;
|
|
475
640
|
onCtrl = function (msg) {
|
|
@@ -478,12 +643,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
478
643
|
finish(data);
|
|
479
644
|
}
|
|
480
645
|
};
|
|
481
|
-
self.
|
|
646
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
482
647
|
|
|
483
648
|
// Also check channel history in case runner.ready was published
|
|
484
649
|
// before we subscribed (race condition on fast-booting agents).
|
|
485
650
|
try {
|
|
486
|
-
self.
|
|
651
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
487
652
|
if (err) {
|
|
488
653
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
489
654
|
return;
|
|
@@ -505,9 +670,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
505
670
|
});
|
|
506
671
|
}
|
|
507
672
|
// Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
|
|
673
|
+
// For E2B sandboxes, use the url from the API reply.
|
|
508
674
|
// Fall back to constructing from ip + noVncPort for older runners.
|
|
509
675
|
let url;
|
|
510
|
-
if (
|
|
676
|
+
if (isE2B && reply.url) {
|
|
677
|
+
url = reply.url;
|
|
678
|
+
logger.log(`E2B sandbox ready — url=${url}`);
|
|
679
|
+
} else if (runnerVncUrl) {
|
|
511
680
|
url = runnerVncUrl;
|
|
512
681
|
logger.log(`Using runner-provided vncUrl: ${url}`);
|
|
513
682
|
} else if (runnerIp && noVncPort) {
|
|
@@ -529,6 +698,15 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
529
698
|
url: url,
|
|
530
699
|
vncPort: noVncPort || undefined,
|
|
531
700
|
runner: reply.runner,
|
|
701
|
+
// Extra metadata for test result reporting
|
|
702
|
+
amiId: reply.amiId || null,
|
|
703
|
+
e2bTemplateId: reply.e2bTemplateId || null,
|
|
704
|
+
imageVersion: reply.imageVersion || null,
|
|
705
|
+
runnerVersionBefore: this._runnerVersionBefore || reply.imageVersion || null,
|
|
706
|
+
runnerVersionAfter: this._runnerVersionAfter || reply.runner?.version || null,
|
|
707
|
+
wasUpdated: this._wasUpdated || false,
|
|
708
|
+
vncUrl: url || null,
|
|
709
|
+
channelName: this._channelName || null,
|
|
532
710
|
},
|
|
533
711
|
};
|
|
534
712
|
}
|
|
@@ -538,26 +716,33 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
538
716
|
// provision the config to the instance via SSM (client-side).
|
|
539
717
|
// This runs from the user's infrastructure where AWS permissions exist,
|
|
540
718
|
// rather than from the API server.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
719
|
+
// NOTE: For direct connections, the user MUST provide the AWS instanceId
|
|
720
|
+
// because the API only knows the sandboxId, not the actual EC2 instance ID.
|
|
721
|
+
var instanceId = message.instanceId;
|
|
722
|
+
if (reply.agentConfig && instanceId) {
|
|
723
|
+
logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
|
|
724
|
+
await this._provisionAgentConfig(instanceId, reply.agentConfig);
|
|
544
725
|
logger.log('Agent config provisioned successfully.');
|
|
726
|
+
} else if (reply.agentConfig && !instanceId) {
|
|
727
|
+
logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
|
|
545
728
|
}
|
|
546
729
|
|
|
547
730
|
// If the API returned agent credentials (reply.agent present),
|
|
548
731
|
// wait for the runner agent to signal readiness before sending commands.
|
|
549
732
|
// Without this gate, commands published before the agent subscribes are lost.
|
|
550
733
|
var self = this;
|
|
551
|
-
if (reply.agent && this.
|
|
734
|
+
if (reply.agent && this._sessionChannel) {
|
|
552
735
|
logger.log('Waiting for runner agent to signal readiness (direct connection)...');
|
|
553
|
-
var readyTimeout =
|
|
736
|
+
var readyTimeout = 60000 * 5;
|
|
554
737
|
await new Promise(function (resolve, reject) {
|
|
555
738
|
var resolved = false;
|
|
739
|
+
var waitStart = Date.now();
|
|
556
740
|
function finish(data) {
|
|
557
741
|
if (resolved) return;
|
|
558
742
|
resolved = true;
|
|
559
743
|
clearTimeout(timer);
|
|
560
|
-
|
|
744
|
+
clearInterval(progressTimer);
|
|
745
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
561
746
|
logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
|
|
562
747
|
if (data && data.update) {
|
|
563
748
|
var u = data.update;
|
|
@@ -577,12 +762,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
577
762
|
var timer = setTimeout(function () {
|
|
578
763
|
if (!resolved) {
|
|
579
764
|
resolved = true;
|
|
580
|
-
|
|
581
|
-
|
|
765
|
+
clearInterval(progressTimer);
|
|
766
|
+
self._sessionChannel.unsubscribe('control', onCtrl);
|
|
767
|
+
var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
|
|
768
|
+
sentry.captureException(err, {
|
|
769
|
+
tags: { phase: 'runner_ready', connection_type: 'direct' },
|
|
770
|
+
extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
|
|
771
|
+
});
|
|
772
|
+
reject(err);
|
|
582
773
|
}
|
|
583
774
|
}, readyTimeout);
|
|
584
775
|
if (timer.unref) timer.unref();
|
|
585
776
|
|
|
777
|
+
// Log progress every 15s so the user knows we're still waiting
|
|
778
|
+
var progressTimer = setInterval(function () {
|
|
779
|
+
if (resolved) return;
|
|
780
|
+
var elapsed = Math.round((Date.now() - waitStart) / 1000);
|
|
781
|
+
logger.log('Still waiting for runner agent... (' + elapsed + 's elapsed, timeout=' + Math.round(readyTimeout / 1000) + 's)');
|
|
782
|
+
}, 15000);
|
|
783
|
+
|
|
586
784
|
// Listen for live runner.ready messages
|
|
587
785
|
var onCtrl;
|
|
588
786
|
onCtrl = function (msg) {
|
|
@@ -591,12 +789,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
591
789
|
finish(data);
|
|
592
790
|
}
|
|
593
791
|
};
|
|
594
|
-
self.
|
|
792
|
+
self._sessionChannel.subscribe('control', onCtrl);
|
|
595
793
|
|
|
596
794
|
// Also check channel history in case runner.ready was published
|
|
597
795
|
// before we subscribed (race condition on fast-booting agents).
|
|
598
796
|
try {
|
|
599
|
-
self.
|
|
797
|
+
self._sessionChannel.history({ limit: 50 }, function (err, page) {
|
|
600
798
|
if (err) {
|
|
601
799
|
logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
|
|
602
800
|
return;
|
|
@@ -638,7 +836,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
638
836
|
_sendAbly(message, timeout) {
|
|
639
837
|
if (timeout === undefined) timeout = 300000;
|
|
640
838
|
|
|
641
|
-
if (!this.
|
|
839
|
+
if (!this._sessionChannel || !this._ably) {
|
|
642
840
|
return Promise.reject(
|
|
643
841
|
new Error("Sandbox not connected (no Ably client)"),
|
|
644
842
|
);
|
|
@@ -666,7 +864,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
666
864
|
function onFailed() {
|
|
667
865
|
clearTimeout(timer);
|
|
668
866
|
self._ably.connection.off("connected", onConnected);
|
|
669
|
-
reject(new Error("
|
|
867
|
+
reject(new Error("Realtime connection failed while waiting to send"));
|
|
670
868
|
}
|
|
671
869
|
self._ably.connection.once("connected", onConnected);
|
|
672
870
|
self._ably.connection.once("failed", onFailed);
|
|
@@ -724,21 +922,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
724
922
|
|
|
725
923
|
var requestId = message.requestId;
|
|
726
924
|
|
|
727
|
-
|
|
925
|
+
// timeoutId and timeoutExpiresAt are declared as vars so they can be
|
|
926
|
+
// updated by extendTimeout() (closure mutation).
|
|
927
|
+
var timeoutId;
|
|
928
|
+
var timeoutExpiresAt;
|
|
929
|
+
|
|
930
|
+
var timeoutFn = function () {
|
|
728
931
|
if (self.ps[requestId]) {
|
|
932
|
+
var pendingIds = Object.keys(self.ps);
|
|
933
|
+
var pendingSummary = pendingIds.map(function (rid) {
|
|
934
|
+
var e = self.ps[rid];
|
|
935
|
+
var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
|
|
936
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
|
|
937
|
+
}).join(', ');
|
|
938
|
+
logger.error(
|
|
939
|
+
'[realtime] Promise TIMEOUT: requestId=' + requestId +
|
|
940
|
+
' | type=' + message.type +
|
|
941
|
+
' | timeout=' + timeout + 'ms' +
|
|
942
|
+
' | all pending: [' + pendingSummary + ']'
|
|
943
|
+
);
|
|
729
944
|
delete self.ps[requestId];
|
|
730
945
|
delete self._execBuffers[requestId];
|
|
731
946
|
rejectPromise(
|
|
732
947
|
new Error(
|
|
733
948
|
"Sandbox message '" +
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
949
|
+
message.type +
|
|
950
|
+
"' timed out after " +
|
|
951
|
+
timeout +
|
|
952
|
+
"ms",
|
|
738
953
|
),
|
|
739
954
|
);
|
|
740
955
|
}
|
|
741
|
-
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
timeoutId = setTimeout(timeoutFn, timeout);
|
|
959
|
+
timeoutExpiresAt = Date.now() + timeout;
|
|
742
960
|
if (timeoutId.unref) timeoutId.unref();
|
|
743
961
|
|
|
744
962
|
this.ps[requestId] = {
|
|
@@ -751,16 +969,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
751
969
|
clearTimeout(timeoutId);
|
|
752
970
|
rejectPromise(error);
|
|
753
971
|
},
|
|
972
|
+
/**
|
|
973
|
+
* Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
|
|
974
|
+
* to compensate for time spent disconnected.
|
|
975
|
+
*/
|
|
976
|
+
extendTimeout: function (disconnectionDurationMs) {
|
|
977
|
+
clearTimeout(timeoutId);
|
|
978
|
+
// Clamp remaining to 0 so a command whose timer expired during the
|
|
979
|
+
// outage still gets the full disconnection duration as its new budget.
|
|
980
|
+
var remaining = Math.max(0, timeoutExpiresAt - Date.now());
|
|
981
|
+
// Minimum 5s remaining after extension to allow the response to arrive.
|
|
982
|
+
var MIN_REMAINING_MS = 5000;
|
|
983
|
+
var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
|
|
984
|
+
timeoutExpiresAt = Date.now() + newRemaining;
|
|
985
|
+
timeoutId = setTimeout(timeoutFn, newRemaining);
|
|
986
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
987
|
+
logger.log(
|
|
988
|
+
'[realtime] Extended timeout for requestId=' + requestId +
|
|
989
|
+
' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
|
|
990
|
+
);
|
|
991
|
+
},
|
|
754
992
|
message: message,
|
|
755
993
|
startTime: Date.now(),
|
|
756
994
|
};
|
|
757
995
|
|
|
758
996
|
if (message.type === "output") {
|
|
759
|
-
p.catch(function () {});
|
|
997
|
+
p.catch(function () { });
|
|
760
998
|
}
|
|
761
999
|
|
|
762
|
-
this.
|
|
763
|
-
.publish("command", message)
|
|
1000
|
+
this._throttledPublish(this._sessionChannel, "command", message)
|
|
764
1001
|
.then(function () {
|
|
765
1002
|
emitter.emit(events.sandbox.sent, message);
|
|
766
1003
|
})
|
|
@@ -777,6 +1014,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
777
1014
|
return p;
|
|
778
1015
|
}
|
|
779
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
|
|
1019
|
+
* Also tracks and logs the current publish rate for debugging.
|
|
1020
|
+
* @param {Object} channel - Ably channel to publish on
|
|
1021
|
+
* @param {string} eventName - Event name for the publish
|
|
1022
|
+
* @param {Object} message - Message payload
|
|
1023
|
+
* @returns {Promise} - Resolves when publish completes
|
|
1024
|
+
*/
|
|
1025
|
+
async _throttledPublish(channel, eventName, message) {
|
|
1026
|
+
var self = this;
|
|
1027
|
+
var now = Date.now();
|
|
1028
|
+
|
|
1029
|
+
// Rate limiting: wait if too soon since last publish
|
|
1030
|
+
var elapsed = now - this._publishLastTime;
|
|
1031
|
+
if (elapsed < this._publishMinIntervalMs) {
|
|
1032
|
+
var waitMs = this._publishMinIntervalMs - elapsed;
|
|
1033
|
+
await new Promise(function (resolve) {
|
|
1034
|
+
var timer = setTimeout(resolve, waitMs);
|
|
1035
|
+
if (timer.unref) timer.unref();
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
this._publishLastTime = Date.now();
|
|
1039
|
+
|
|
1040
|
+
// Metrics: track messages per second
|
|
1041
|
+
this._publishCount++;
|
|
1042
|
+
var windowElapsed = Date.now() - this._publishWindowStart;
|
|
1043
|
+
if (windowElapsed >= 1000) {
|
|
1044
|
+
var rate = (this._publishCount / windowElapsed) * 1000;
|
|
1045
|
+
var rateStr = rate.toFixed(1);
|
|
1046
|
+
|
|
1047
|
+
// Log rate - warning if approaching limit, debug otherwise
|
|
1048
|
+
if (rate > 45) {
|
|
1049
|
+
logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
|
|
1050
|
+
} else if (process.env.VERBOSE || process.env.TD_DEBUG) {
|
|
1051
|
+
logger.log("Ably publish rate: " + rateStr + " msg/sec");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Reset window
|
|
1055
|
+
this._publishCount = 0;
|
|
1056
|
+
this._publishWindowStart = Date.now();
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return channel.publish(eventName, message).then(function () {
|
|
1060
|
+
logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
780
1064
|
async auth(apiKey) {
|
|
781
1065
|
this.apiKey = apiKey;
|
|
782
1066
|
var sessionId = this.sessionInstance
|
|
@@ -803,7 +1087,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
803
1087
|
logger.log("Trace Report (Share When Reporting Bugs):");
|
|
804
1088
|
logger.log(
|
|
805
1089
|
"https://testdriver.sentry.io/explore/traces/trace/" +
|
|
806
|
-
|
|
1090
|
+
reply.traceId,
|
|
807
1091
|
);
|
|
808
1092
|
}
|
|
809
1093
|
|
|
@@ -846,7 +1130,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
846
1130
|
this._sandboxId = reply.sandboxId;
|
|
847
1131
|
|
|
848
1132
|
if (reply.ably && reply.ably.token) {
|
|
849
|
-
await this._initAbly(reply.ably.token, reply.ably.
|
|
1133
|
+
await this._initAbly(reply.ably.token, reply.ably.channel);
|
|
850
1134
|
}
|
|
851
1135
|
|
|
852
1136
|
this.setConnectionParams({
|
|
@@ -895,38 +1179,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
895
1179
|
clearInterval(this.heartbeat);
|
|
896
1180
|
this.heartbeat = null;
|
|
897
1181
|
}
|
|
1182
|
+
if (this._statsInterval) {
|
|
1183
|
+
clearInterval(this._statsInterval);
|
|
1184
|
+
this._statsInterval = null;
|
|
1185
|
+
}
|
|
898
1186
|
|
|
899
1187
|
// Send end-session control message to runner before disconnecting
|
|
900
|
-
if (this.
|
|
1188
|
+
if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
|
|
901
1189
|
try {
|
|
902
|
-
|
|
1190
|
+
logger.debug('[realtime] Publishing control: type=end-session');
|
|
1191
|
+
await this._sessionChannel.publish('control', { type: 'end-session' });
|
|
903
1192
|
} catch (e) {
|
|
904
1193
|
// Ignore - best effort
|
|
905
1194
|
}
|
|
906
1195
|
}
|
|
907
1196
|
|
|
908
|
-
// Leave presence on
|
|
909
|
-
if (this.
|
|
1197
|
+
// Leave presence on session channel
|
|
1198
|
+
if (this._sessionChannel) {
|
|
910
1199
|
try {
|
|
911
|
-
|
|
1200
|
+
logger.debug('[realtime] Leaving presence on session channel');
|
|
1201
|
+
await this._sessionChannel.presence.leave();
|
|
912
1202
|
} catch (e) {
|
|
913
1203
|
// ignore - best effort, Ably will auto-leave on disconnect
|
|
914
1204
|
}
|
|
915
1205
|
}
|
|
916
1206
|
|
|
917
1207
|
try {
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
921
|
-
|
|
922
|
-
this._filesChannel?.detach(),
|
|
923
|
-
].filter(Boolean));
|
|
1208
|
+
logger.debug('[realtime] Detaching session channel');
|
|
1209
|
+
if (this._sessionChannel) {
|
|
1210
|
+
await this._sessionChannel.detach();
|
|
1211
|
+
}
|
|
924
1212
|
} catch (e) {
|
|
925
1213
|
/* ignore */
|
|
926
1214
|
}
|
|
927
1215
|
|
|
928
1216
|
if (this._ably) {
|
|
929
1217
|
try {
|
|
1218
|
+
logger.debug('[realtime] Closing Realtime connection');
|
|
930
1219
|
this._ably.close();
|
|
931
1220
|
} catch (e) {
|
|
932
1221
|
/* ignore */
|
|
@@ -934,11 +1223,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
934
1223
|
this._ably = null;
|
|
935
1224
|
}
|
|
936
1225
|
|
|
937
|
-
this.
|
|
938
|
-
this.
|
|
939
|
-
this._ctrlChannel = null;
|
|
940
|
-
this._filesChannel = null;
|
|
941
|
-
this._channelNames = null;
|
|
1226
|
+
this._sessionChannel = null;
|
|
1227
|
+
this._channelName = null;
|
|
942
1228
|
this.apiSocketConnected = false;
|
|
943
1229
|
this.instanceSocketConnected = false;
|
|
944
1230
|
this.authenticated = false;
|
|
@@ -961,14 +1247,55 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
961
1247
|
const region = process.env.AWS_REGION || 'us-east-2';
|
|
962
1248
|
|
|
963
1249
|
// Write SSM parameters to a temp file to avoid shell quoting issues
|
|
1250
|
+
// Log key config details for debugging
|
|
1251
|
+
logger.log('Agent config being provisioned:');
|
|
1252
|
+
logger.log(' sandboxId: ' + agentConfig.sandboxId);
|
|
1253
|
+
logger.log(' apiRoot: ' + agentConfig.apiRoot);
|
|
1254
|
+
logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
|
|
1255
|
+
logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
|
|
1256
|
+
|
|
964
1257
|
const paramsJson = JSON.stringify({
|
|
965
1258
|
commands: [
|
|
1259
|
+
// Debug: show existing state
|
|
1260
|
+
"Write-Host '=== Checking existing state ==='",
|
|
1261
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1262
|
+
"if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
|
|
1263
|
+
"if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
|
|
1264
|
+
// Stop any running runner
|
|
1265
|
+
"Write-Host '=== Stopping runner ==='",
|
|
1266
|
+
"Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
|
|
1267
|
+
"Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
|
|
1268
|
+
// Write config
|
|
1269
|
+
"Write-Host '=== Writing config ==='",
|
|
966
1270
|
"$config = '" + configJson.replace(/'/g, "''") + "'",
|
|
967
1271
|
"[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
|
|
968
1272
|
"Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
|
|
1273
|
+
// Show what was written (redact token)
|
|
1274
|
+
"Write-Host '=== New config (token redacted) ==='",
|
|
1275
|
+
"$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
|
|
1276
|
+
"Write-Host \"sandboxId: $($cfg.sandboxId)\"",
|
|
1277
|
+
"Write-Host \"apiRoot: $($cfg.apiRoot)\"",
|
|
1278
|
+
"Write-Host \"channel: $($cfg.ably.channel)\"",
|
|
1279
|
+
"Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
|
|
1280
|
+
// Start the runner
|
|
1281
|
+
"Write-Host '=== Starting runner ==='",
|
|
1282
|
+
"Start-Sleep -Seconds 1",
|
|
1283
|
+
"Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
|
|
1284
|
+
"$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
|
|
1285
|
+
"Write-Host \"Task state after start: $($task.State)\"",
|
|
1286
|
+
// Check if node process started
|
|
1287
|
+
"Start-Sleep -Seconds 3",
|
|
1288
|
+
"Write-Host '=== Checking runner process ==='",
|
|
1289
|
+
"$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
|
|
1290
|
+
"if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
|
|
1291
|
+
// Check runner logs
|
|
1292
|
+
"Write-Host '=== Runner log (last 30 lines) ==='",
|
|
1293
|
+
"if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
|
|
1294
|
+
"Write-Host '=== Done ==='",
|
|
969
1295
|
],
|
|
970
1296
|
});
|
|
971
|
-
const
|
|
1297
|
+
const { randomUUID } = require('crypto');
|
|
1298
|
+
const tmpFile = join(tmpdir(), 'td-provision-' + randomUUID() + '.json');
|
|
972
1299
|
writeFileSync(tmpFile, paramsJson);
|
|
973
1300
|
|
|
974
1301
|
try {
|
|
@@ -987,6 +1314,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
987
1314
|
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
|
|
988
1315
|
{ encoding: 'utf-8', timeout: 60000 }
|
|
989
1316
|
);
|
|
1317
|
+
|
|
1318
|
+
// Get the command output for debugging
|
|
1319
|
+
try {
|
|
1320
|
+
const invocationOutput = execSync(
|
|
1321
|
+
'aws ssm get-command-invocation --region "' + region + '" ' +
|
|
1322
|
+
'--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
|
|
1323
|
+
{ encoding: 'utf-8', timeout: 30000 }
|
|
1324
|
+
);
|
|
1325
|
+
const invocation = JSON.parse(invocationOutput);
|
|
1326
|
+
if (invocation.StandardOutputContent) {
|
|
1327
|
+
logger.log('SSM output:\n' + invocation.StandardOutputContent);
|
|
1328
|
+
}
|
|
1329
|
+
if (invocation.StandardErrorContent) {
|
|
1330
|
+
logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
|
|
1331
|
+
}
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
logger.warn('Could not retrieve SSM command output: ' + e.message);
|
|
1334
|
+
}
|
|
990
1335
|
} finally {
|
|
991
1336
|
try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
|
|
992
1337
|
}
|