testdriverai 7.8.0-test.7 → 7.8.0-test.70

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 (98) hide show
  1. package/agent/index.js +18 -5
  2. package/agent/lib/commands.js +3 -2
  3. package/agent/lib/http.js +162 -0
  4. package/agent/lib/logger.js +15 -0
  5. package/agent/lib/sandbox.js +554 -209
  6. package/agent/lib/sdk.js +5 -22
  7. package/agent/lib/system.js +25 -65
  8. package/ai/skills/testdriver-cache/SKILL.md +221 -0
  9. package/ai/skills/testdriver-errors/SKILL.md +246 -0
  10. package/ai/skills/testdriver-events/SKILL.md +356 -0
  11. package/ai/skills/testdriver-find/SKILL.md +14 -20
  12. package/ai/skills/testdriver-mcp/SKILL.md +7 -0
  13. package/ai/skills/testdriver-provision/SKILL.md +331 -0
  14. package/ai/skills/testdriver-redraw/SKILL.md +214 -0
  15. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  16. package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
  17. package/docs/_data/examples-manifest.json +46 -46
  18. package/docs/_scripts/extract-example-urls.js +67 -72
  19. package/docs/changelog.mdx +148 -8
  20. package/docs/docs.json +46 -38
  21. package/docs/images/content/vscode/v7-chat.png +0 -0
  22. package/docs/images/content/vscode/v7-choose-agent.png +0 -0
  23. package/docs/images/content/vscode/v7-full.png +0 -0
  24. package/docs/images/content/vscode/v7-onboarding.png +0 -0
  25. package/docs/v7/cache.mdx +223 -0
  26. package/docs/v7/copilot/auto-healing.mdx +265 -0
  27. package/docs/v7/copilot/creating-tests.mdx +156 -0
  28. package/docs/v7/copilot/github.mdx +143 -0
  29. package/docs/v7/copilot/running-tests.mdx +149 -0
  30. package/docs/v7/copilot/setup.mdx +143 -0
  31. package/docs/v7/enterprise.mdx +3 -110
  32. package/docs/v7/errors.mdx +248 -0
  33. package/docs/v7/events.mdx +358 -0
  34. package/docs/v7/examples/ai.mdx +1 -1
  35. package/docs/v7/examples/assert.mdx +1 -1
  36. package/docs/v7/examples/captcha-api.mdx +1 -1
  37. package/docs/v7/examples/chrome-extension.mdx +1 -1
  38. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  39. package/docs/v7/examples/element-not-found.mdx +1 -1
  40. package/docs/v7/examples/exec-output.mdx +85 -0
  41. package/docs/v7/examples/exec-pwsh.mdx +83 -0
  42. package/docs/v7/examples/focus-window.mdx +62 -0
  43. package/docs/v7/examples/hover-image.mdx +1 -1
  44. package/docs/v7/examples/hover-text.mdx +1 -1
  45. package/docs/v7/examples/installer.mdx +1 -1
  46. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  47. package/docs/v7/examples/match-image.mdx +1 -1
  48. package/docs/v7/examples/press-keys.mdx +1 -1
  49. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  50. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  51. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  52. package/docs/v7/examples/scroll.mdx +1 -1
  53. package/docs/v7/examples/type.mdx +1 -1
  54. package/docs/v7/examples/windows-installer.mdx +1 -1
  55. package/docs/v7/find.mdx +14 -20
  56. package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
  57. package/docs/v7/mcp.mdx +9 -0
  58. package/docs/v7/provision.mdx +333 -0
  59. package/docs/v7/quickstart.mdx +30 -2
  60. package/docs/v7/redraw.mdx +216 -0
  61. package/docs/v7/running-tests.mdx +1 -1
  62. package/docs/v7/screenshots.mdx +186 -0
  63. package/docs/v7/self-hosted.mdx +127 -44
  64. package/docs/v7/test-results-json.mdx +258 -0
  65. package/examples/scroll-keyboard.test.mjs +1 -1
  66. package/examples/scroll.test.mjs +1 -12
  67. package/interfaces/logger.js +0 -12
  68. package/interfaces/vitest-plugin.mjs +170 -51
  69. package/lib/core/Dashcam.js +30 -23
  70. package/lib/environments.json +22 -0
  71. package/lib/github-comment.mjs +58 -40
  72. package/lib/init-project.js +1 -61
  73. package/lib/resolve-channel.js +42 -12
  74. package/lib/sentry.js +47 -23
  75. package/lib/vitest/hooks.mjs +63 -3
  76. package/{examples → manual}/drag-and-drop.test.mjs +1 -1
  77. package/manual/exec-stream-logs.test.mjs +25 -0
  78. package/mcp-server/dist/server.mjs +28 -8
  79. package/mcp-server/src/server.ts +31 -8
  80. package/package.json +3 -3
  81. package/sdk.d.ts +4 -0
  82. package/sdk.js +45 -15
  83. package/setup/aws/install-dev-runner.sh +79 -0
  84. package/setup/aws/spawn-runner.sh +165 -0
  85. package/test-sentry-span.js +35 -0
  86. package/vitest.config.mjs +22 -34
  87. package/vitest.runner.config.mjs +33 -0
  88. /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
  89. /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
  90. /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
  91. /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
  92. /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
  93. /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
  94. /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
  95. /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
  96. /package/{examples → manual}/flake-shared.mjs +0 -0
  97. /package/{examples → manual}/no-provision.test.mjs +0 -0
  98. /package/{examples → manual}/scroll-until-text.test.mjs +0 -0
@@ -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
- 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");
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._cmdChannel = null;
87
- this._respChannel = null;
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
- async _initAbly(ablyToken, channelNames) {
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._channelNames = channelNames;
64
+ this._channelName = channelName;
130
65
  var self = this;
131
66
 
132
67
  this._ably = new Ably.Realtime({
133
- authCallback: function (tokenParams, callback) {
134
- callback(null, ablyToken);
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("Ably connection failed"));
97
+ reject(new Error("Realtime connection failed"));
145
98
  });
146
99
  setTimeout(function () {
147
- reject(new Error("Ably connection timeout"));
100
+ reject(new Error("Realtime connection timeout"));
148
101
  }, 30000);
149
102
  });
150
103
 
151
- this._cmdChannel = this._ably.channels.get(channelNames.commands);
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
- // Enter presence on control channel so the API can count connected SDK clients
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._ctrlChannel.presence.enter({
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 control channel: " + (e.message || e));
117
+ logger.warn("Failed to enter presence on session channel: " + (e.message || e));
165
118
  }
166
119
 
167
- this._respChannel.subscribe("response", function (msg) {
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 debugMode =
222
- process.env.VERBOSE || process.env.TD_DEBUG;
223
- if (debugMode) {
224
- console.warn(
225
- "No pending promise found for requestId:",
226
- message.requestId,
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 pendingMessage =
234
- self.ps[message.requestId] &&
235
- self.ps[message.requestId].message;
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
- self.ps[message.requestId].reject(error);
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._filesChannel.subscribe("response", function (msg) {
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.log("Ably disconnected - will auto-reconnect");
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.log("Ably reconnected");
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.warn("Ably suspended - connection lost for extended period, will keep retrying");
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, "Ably connection failed");
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
- * 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.
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 retryInterval = 10000; // 10 seconds between retries
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
- return await httpPost(this.apiRoot, path, body, timeout);
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
- var isConcurrencyLimit =
315
- err.responseData &&
316
- err.responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED";
317
-
318
- if (!isConcurrencyLimit) {
319
- throw err;
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
- await new Promise(function (resolve) {
332
- var t = setTimeout(resolve, retryInterval);
333
- if (t.unref) t.unref();
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.channels);
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._ctrlChannel) {
400
- this._ctrlChannel.publish("control", {
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: the API proxies commands and returns a url directly.
409
- // No runner agent involved skip runner.ready wait.
410
- if (reply.url) {
411
- logger.log(`E2B sandbox ready — url=${reply.url}`);
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
- logger.log(`Runner claimed ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
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
- // For cloud Windows sandboxes (no runner in reply), wait for the
430
- // agent to signal readiness before sending commands. Without this
431
- // gate, commands published before the agent subscribes are lost.
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
- if (!reply.runner && this._ctrlChannel) {
569
+ const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
570
+ if (needsReadyWait) {
434
571
  logger.log('Waiting for runner agent to signal readiness...');
435
- var readyTimeout = 120000; // 120s allows for EC2 boot + agent startup
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
- self._ctrlChannel.unsubscribe('control', onCtrl);
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
- self._ctrlChannel.unsubscribe('control', onCtrl);
468
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
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._ctrlChannel.subscribe('control', onCtrl);
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._ctrlChannel.history({ limit: 50 }, function (err, page) {
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 (runnerVncUrl) {
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
- if (reply.agentConfig && message.instanceId) {
542
- logger.log('Provisioning agent config to instance ' + message.instanceId + ' via SSM...');
543
- await this._provisionAgentConfig(message.instanceId, reply.agentConfig);
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._ctrlChannel) {
734
+ if (reply.agent && this._sessionChannel) {
552
735
  logger.log('Waiting for runner agent to signal readiness (direct connection)...');
553
- var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
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
- self._ctrlChannel.unsubscribe('control', onCtrl);
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
- self._ctrlChannel.unsubscribe('control', onCtrl);
581
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
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._ctrlChannel.subscribe('control', onCtrl);
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._ctrlChannel.history({ limit: 50 }, function (err, page) {
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._cmdChannel || !this._ably) {
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("Ably connection failed while waiting to send"));
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
- var timeoutId = setTimeout(function () {
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
- message.type +
735
- "' timed out after " +
736
- timeout +
737
- "ms",
949
+ message.type +
950
+ "' timed out after " +
951
+ timeout +
952
+ "ms",
738
953
  ),
739
954
  );
740
955
  }
741
- }, timeout);
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._cmdChannel
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
- reply.traceId,
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.channels);
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._ctrlChannel && this._ably?.connection?.state === 'connected') {
1188
+ if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
901
1189
  try {
902
- await this._ctrlChannel.publish('control', { type: 'end-session' });
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 control channel
909
- if (this._ctrlChannel) {
1197
+ // Leave presence on session channel
1198
+ if (this._sessionChannel) {
910
1199
  try {
911
- await this._ctrlChannel.presence.leave();
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
- await Promise.allSettled([
919
- this._cmdChannel?.detach(),
920
- this._respChannel?.detach(),
921
- this._ctrlChannel?.detach(),
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._cmdChannel = null;
938
- this._respChannel = null;
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 tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
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
  }