testdriverai 7.8.0-test.4 → 7.8.0-test.40

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 (87) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/agent/index.js +6 -5
  3. package/agent/lib/commands.js +3 -2
  4. package/agent/lib/http.js +144 -0
  5. package/agent/lib/sandbox.js +326 -164
  6. package/agent/lib/sdk.js +4 -2
  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-mcp/SKILL.md +7 -0
  12. package/ai/skills/testdriver-provision/SKILL.md +331 -0
  13. package/ai/skills/testdriver-redraw/SKILL.md +214 -0
  14. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  15. package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
  16. package/docs/_data/examples-manifest.json +46 -46
  17. package/docs/changelog.mdx +155 -3
  18. package/docs/docs.json +44 -37
  19. package/docs/images/content/vscode/v7-chat.png +0 -0
  20. package/docs/images/content/vscode/v7-choose-agent.png +0 -0
  21. package/docs/images/content/vscode/v7-full.png +0 -0
  22. package/docs/images/content/vscode/v7-onboarding.png +0 -0
  23. package/docs/v7/cache.mdx +223 -0
  24. package/docs/v7/copilot/auto-healing.mdx +265 -0
  25. package/docs/v7/copilot/creating-tests.mdx +156 -0
  26. package/docs/v7/copilot/github.mdx +143 -0
  27. package/docs/v7/copilot/running-tests.mdx +149 -0
  28. package/docs/v7/copilot/setup.mdx +143 -0
  29. package/docs/v7/enterprise.mdx +3 -110
  30. package/docs/v7/errors.mdx +248 -0
  31. package/docs/v7/events.mdx +358 -0
  32. package/docs/v7/examples/ai.mdx +1 -1
  33. package/docs/v7/examples/assert.mdx +1 -1
  34. package/docs/v7/examples/captcha-api.mdx +1 -1
  35. package/docs/v7/examples/chrome-extension.mdx +1 -1
  36. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  37. package/docs/v7/examples/element-not-found.mdx +1 -1
  38. package/docs/v7/examples/exec-output.mdx +85 -0
  39. package/docs/v7/examples/exec-pwsh.mdx +83 -0
  40. package/docs/v7/examples/focus-window.mdx +62 -0
  41. package/docs/v7/examples/hover-image.mdx +1 -1
  42. package/docs/v7/examples/hover-text.mdx +1 -1
  43. package/docs/v7/examples/installer.mdx +1 -1
  44. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  45. package/docs/v7/examples/match-image.mdx +1 -1
  46. package/docs/v7/examples/press-keys.mdx +1 -1
  47. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  48. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  49. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  50. package/docs/v7/examples/scroll.mdx +1 -1
  51. package/docs/v7/examples/type.mdx +1 -1
  52. package/docs/v7/examples/windows-installer.mdx +1 -1
  53. package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
  54. package/docs/v7/mcp.mdx +9 -0
  55. package/docs/v7/provision.mdx +333 -0
  56. package/docs/v7/quickstart.mdx +30 -2
  57. package/docs/v7/redraw.mdx +216 -0
  58. package/docs/v7/running-tests.mdx +1 -1
  59. package/docs/v7/screenshots.mdx +186 -0
  60. package/docs/v7/self-hosted.mdx +127 -44
  61. package/interfaces/logger.js +0 -12
  62. package/interfaces/vitest-plugin.mjs +53 -43
  63. package/lib/core/Dashcam.js +30 -23
  64. package/lib/environments.json +18 -0
  65. package/lib/github-comment.mjs +58 -40
  66. package/lib/resolve-channel.js +4 -3
  67. package/lib/sentry.js +5 -0
  68. package/{examples → manual}/drag-and-drop.test.mjs +1 -1
  69. package/mcp-server/dist/server.mjs +4 -0
  70. package/mcp-server/src/server.ts +5 -0
  71. package/package.json +3 -3
  72. package/sdk.js +3 -3
  73. package/setup/aws/install-dev-runner.sh +79 -0
  74. package/setup/aws/spawn-runner.sh +134 -0
  75. package/vitest.config.mjs +20 -32
  76. package/vitest.runner.config.mjs +33 -0
  77. /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
  78. /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
  79. /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
  80. /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
  81. /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
  82. /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
  83. /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
  84. /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
  85. /package/{examples → manual}/flake-shared.mjs +0 -0
  86. /package/{examples → manual}/no-provision.test.mjs +0 -0
  87. /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,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
105
29
  this._lastConnectParams = null;
106
30
  this._teamId = null;
107
31
  this._sandboxId = null;
32
+
33
+ // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
34
+ this._publishLastTime = 0;
35
+ this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
36
+ this._publishCount = 0;
37
+ this._publishWindowStart = Date.now();
108
38
  }
109
39
 
110
40
  getTraceId() {
@@ -118,7 +48,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
118
48
  );
119
49
  }
120
50
 
121
- async _initAbly(ablyToken, channelNames) {
51
+ async _initAbly(ablyToken, channelName) {
122
52
  if (this._ably) {
123
53
  try {
124
54
  this._ably.close();
@@ -126,7 +56,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
126
56
  /* ignore */
127
57
  }
128
58
  }
129
- this._channelNames = channelNames;
59
+ this._channelName = channelName;
130
60
  var self = this;
131
61
 
132
62
  this._ably = new Ably.Realtime({
@@ -134,10 +64,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
134
64
  callback(null, ablyToken);
135
65
  },
136
66
  clientId: "sdk-" + this._sandboxId,
67
+ echoMessages: false, // don't receive our own published messages
137
68
  disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
138
69
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
139
70
  });
140
71
 
72
+ logger.log(`[ably] Connecting as sdk-${this._sandboxId}...`);
73
+
141
74
  await new Promise(function (resolve, reject) {
142
75
  self._ably.connection.on("connected", resolve);
143
76
  self._ably.connection.on("failed", function () {
@@ -148,26 +81,29 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
148
81
  }, 30000);
149
82
  });
150
83
 
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);
84
+ this._sessionChannel = this._ably.channels.get(channelName);
85
+
86
+ logger.log(`[ably] Channel initialized: ${channelName}`);
155
87
 
156
- // Enter presence on control channel so the API can count connected SDK clients
88
+ // Enter presence on the session channel so the API can count connected SDK clients
157
89
  try {
158
- await this._ctrlChannel.presence.enter({
90
+ await this._sessionChannel.presence.enter({
159
91
  sandboxId: this._sandboxId,
160
92
  connectedAt: Date.now(),
161
93
  });
94
+ logger.log(`[ably] Entered presence on session channel (sandbox=${this._sandboxId})`);
162
95
  } catch (e) {
163
96
  // Non-fatal — presence is used for concurrency counting, not critical path
164
- logger.warn("Failed to enter presence on control channel: " + (e.message || e));
97
+ logger.warn("Failed to enter presence on session channel: " + (e.message || e));
165
98
  }
166
99
 
167
- this._respChannel.subscribe("response", function (msg) {
100
+ // Save subscription references for historyBeforeSubscribe() during discontinuity recovery
101
+ this._responseSubscription = await this._sessionChannel.subscribe("response", function (msg) {
168
102
  var message = msg.data;
169
103
  if (!message) return;
170
104
 
105
+ logger.log(`[ably] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
106
+
171
107
  if (message.type === "sandbox.progress") {
172
108
  emitter.emit(events.sandbox.progress, {
173
109
  step: message.step,
@@ -263,9 +199,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
263
199
  delete self.ps[message.requestId];
264
200
  });
265
201
 
266
- this._filesChannel.subscribe("response", function (msg) {
202
+ this._fileSubscription = await this._sessionChannel.subscribe("file", function (msg) {
267
203
  var message = msg.data;
268
204
  if (!message) return;
205
+ logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
269
206
  if (message.requestId && self.ps[message.requestId]) {
270
207
  emitter.emit(events.sandbox.received);
271
208
  self.ps[message.requestId].resolve(message);
@@ -274,64 +211,168 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
274
211
  emitter.emit(events.sandbox.file, message);
275
212
  });
276
213
 
277
- this.heartbeat = setInterval(function () {}, 5000);
214
+ this.heartbeat = setInterval(function () { }, 5000);
278
215
  if (this.heartbeat.unref) this.heartbeat.unref();
279
216
 
217
+ // ─── Periodic stats logging ────────────────────────────────────────
218
+ this._statsInterval = setInterval(() => {
219
+ const connState = this._ably ? this._ably.connection.state : 'no-client';
220
+ const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
221
+ const pending = Object.keys(this.ps).length;
222
+ logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
223
+ }, 10000);
224
+ if (this._statsInterval.unref) this._statsInterval.unref();
225
+
280
226
  this._ably.connection.on("disconnected", function () {
281
- logger.log("Ably disconnected - will auto-reconnect");
227
+ logger.log("[ably] Connection: disconnected - will auto-reconnect");
282
228
  });
283
229
 
284
230
  this._ably.connection.on("connected", function () {
285
231
  // Log reconnection so the user knows the blip was recovered
286
- logger.log("Ably reconnected");
232
+ logger.log("[ably] Connection: reconnected");
287
233
  });
288
234
 
289
235
  this._ably.connection.on("suspended", function () {
290
- logger.warn("Ably suspended - connection lost for extended period, will keep retrying");
236
+ logger.warn("[ably] Connection: suspended - connection lost for extended period, will keep retrying");
291
237
  });
292
238
 
293
239
  this._ably.connection.on("failed", function () {
240
+ logger.error("[ably] Connection: failed");
294
241
  self.apiSocketConnected = false;
295
242
  self.instanceSocketConnected = false;
296
243
  emitter.emit(events.error.sandbox, "Ably connection failed");
297
244
  });
245
+
246
+ // ─── Channel discontinuity detection ──────────────────────────────
247
+ // Set up BEFORE subscribing so we catch any continuity loss during
248
+ // the initial attachment. Fires at the channel level, covering all
249
+ // message types (response, file, control).
250
+ this._sessionChannel.on(function (stateChange) {
251
+ var current = stateChange.current;
252
+ var previous = stateChange.previous;
253
+ var reason = stateChange.reason;
254
+ var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
255
+
256
+ if (current === 'attached' && stateChange.resumed === false && previous) {
257
+ logger.warn('[ably] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
258
+ emitter.emit(events.sandbox.progress, {
259
+ step: 'discontinuity',
260
+ message: 'Recovering missed messages after connection interruption...',
261
+ });
262
+ self._recoverFromDiscontinuity();
263
+ }
264
+ });
298
265
  }
299
266
 
300
267
  /**
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.
268
+ * Recover missed messages after a channel discontinuity.
269
+ * Uses historyBeforeSubscribe() on each subscription, which guarantees
270
+ * no gap between historical and live messages.
271
+ */
272
+ async _recoverFromDiscontinuity() {
273
+ var subs = [
274
+ { name: 'response', sub: this._responseSubscription },
275
+ { name: 'file', sub: this._fileSubscription },
276
+ ];
277
+ var totalRecovered = 0;
278
+ for (var i = 0; i < subs.length; i++) {
279
+ var entry = subs[i];
280
+ if (!entry.sub) continue;
281
+ try {
282
+ logger.log('[ably] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
283
+ var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
284
+ var recovered = 0;
285
+ while (page) {
286
+ recovered += page.items.length;
287
+ page = page.hasNext() ? await page.next() : null;
288
+ }
289
+ totalRecovered += recovered;
290
+ logger.log('[ably] Discontinuity recovery: found ' + recovered + ' ' + entry.name + ' message(s) in gap');
291
+ } catch (err) {
292
+ logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
293
+ }
294
+ }
295
+ if (totalRecovered > 0) {
296
+ logger.warn('[ably] Recovered ' + totalRecovered + ' message(s) that were missed during connection interruption');
297
+ } else {
298
+ logger.log('[ably] Discontinuity recovery: no missed messages found');
299
+ }
300
+ }
301
+
302
+ /**
303
+ * POST to the API with retry for transient network errors (via withRetry)
304
+ * and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
305
+ * testTimeout kills the test).
305
306
  */
306
307
  async _httpPostWithConcurrencyRetry(path, body, timeout) {
307
- var retryInterval = 10000; // 10 seconds between retries
308
+ var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
308
309
  var startTime = Date.now();
310
+ var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
311
+
312
+ var self = this;
313
+ var makeRequest = function () {
314
+ return axios({
315
+ method: "post",
316
+ url: self.apiRoot + path,
317
+ data: body,
318
+ headers: {
319
+ "Content-Type": "application/json",
320
+ "User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
321
+ ...getSentryTraceHeaders(sessionId),
322
+ },
323
+ timeout: timeout || 120000,
324
+ });
325
+ };
309
326
 
310
327
  while (true) {
311
328
  try {
312
- return await httpPost(this.apiRoot, path, body, timeout);
329
+ var response = await withRetry(makeRequest, {
330
+ retryConfig: {
331
+ maxRetries: 3,
332
+ baseDelayMs: 2000,
333
+ retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
334
+ },
335
+ onRetry: function (attempt, error, delayMs) {
336
+ var elapsed = Date.now() - startTime;
337
+ logger.warn(
338
+ "Transient network error: " + (error.message || error.code) +
339
+ " — POST " + path +
340
+ " — retry " + attempt + "/3" +
341
+ " in " + (delayMs / 1000).toFixed(1) + "s" +
342
+ " (" + Math.round(elapsed / 1000) + "s elapsed)...",
343
+ );
344
+ },
345
+ });
346
+ return response.data;
313
347
  } 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 +
348
+ // Concurrency limit — poll forever until a slot opens
349
+ var responseData = err.response && err.response.data;
350
+ if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
351
+ var elapsed = Date.now() - startTime;
352
+ logger.log(
353
+ "Concurrency limit reached — waiting " +
354
+ concurrencyRetryInterval / 1000 +
327
355
  "s for a slot to become available (" +
328
356
  Math.round(elapsed / 1000) +
329
357
  "s elapsed)...",
330
- );
331
- await new Promise(function (resolve) {
332
- var t = setTimeout(resolve, retryInterval);
333
- if (t.unref) t.unref();
334
- });
358
+ );
359
+ await new Promise(function (resolve) {
360
+ var t = setTimeout(resolve, concurrencyRetryInterval);
361
+ if (t.unref) t.unref();
362
+ });
363
+ continue;
364
+ }
365
+
366
+ // Non-retryable HTTP error — preserve responseData for callers
367
+ if (responseData) {
368
+ var httpErr = new Error(
369
+ responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
370
+ );
371
+ httpErr.responseData = responseData;
372
+ throw httpErr;
373
+ }
374
+
375
+ throw err;
335
376
  }
336
377
  }
337
378
  }
@@ -390,14 +431,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
390
431
  this._teamId = reply.teamId;
391
432
 
392
433
  if (reply.ably && reply.ably.token) {
393
- await this._initAbly(reply.ably.token, reply.ably.channels);
434
+ await this._initAbly(reply.ably.token, reply.ably.channel);
394
435
  this.instanceSocketConnected = true;
395
436
 
396
437
  // Tell the runner to enable debug log forwarding if debug mode is on
397
438
  var debugMode =
398
439
  process.env.VERBOSE || process.env.TD_DEBUG;
399
- if (debugMode && this._ctrlChannel) {
400
- this._ctrlChannel.publish("control", {
440
+ if (debugMode && this._sessionChannel) {
441
+ this._sessionChannel.publish("control", {
401
442
  type: "debug",
402
443
  enabled: true,
403
444
  });
@@ -430,7 +471,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
430
471
  // agent to signal readiness before sending commands. Without this
431
472
  // gate, commands published before the agent subscribes are lost.
432
473
  var self = this;
433
- if (!reply.runner && this._ctrlChannel) {
474
+ if (!reply.runner && this._sessionChannel) {
434
475
  logger.log('Waiting for runner agent to signal readiness...');
435
476
  var readyTimeout = 120000; // 120s — allows for EC2 boot + agent startup
436
477
  await new Promise(function (resolve, reject) {
@@ -439,7 +480,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
439
480
  if (resolved) return;
440
481
  resolved = true;
441
482
  clearTimeout(timer);
442
- self._ctrlChannel.unsubscribe('control', onCtrl);
483
+ self._sessionChannel.unsubscribe('control', onCtrl);
443
484
  // Update runner info if provided
444
485
  if (data && data.os) reply.runner = reply.runner || {};
445
486
  if (data && data.os && reply.runner) reply.runner.os = data.os;
@@ -464,8 +505,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
464
505
  var timer = setTimeout(function () {
465
506
  if (!resolved) {
466
507
  resolved = true;
467
- self._ctrlChannel.unsubscribe('control', onCtrl);
468
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
508
+ self._sessionChannel.unsubscribe('control', onCtrl);
509
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
510
+ sentry.captureException(err, {
511
+ tags: { phase: 'runner_ready', connection_type: 'create' },
512
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
513
+ });
514
+ reject(err);
469
515
  }
470
516
  }, readyTimeout);
471
517
  if (timer.unref) timer.unref();
@@ -478,12 +524,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
478
524
  finish(data);
479
525
  }
480
526
  };
481
- self._ctrlChannel.subscribe('control', onCtrl);
527
+ self._sessionChannel.subscribe('control', onCtrl);
482
528
 
483
529
  // Also check channel history in case runner.ready was published
484
530
  // before we subscribed (race condition on fast-booting agents).
485
531
  try {
486
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
532
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
487
533
  if (err) {
488
534
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
489
535
  return;
@@ -538,17 +584,22 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
538
584
  // provision the config to the instance via SSM (client-side).
539
585
  // This runs from the user's infrastructure where AWS permissions exist,
540
586
  // 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);
587
+ // NOTE: For direct connections, the user MUST provide the AWS instanceId
588
+ // because the API only knows the sandboxId, not the actual EC2 instance ID.
589
+ var instanceId = message.instanceId;
590
+ if (reply.agentConfig && instanceId) {
591
+ logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
592
+ await this._provisionAgentConfig(instanceId, reply.agentConfig);
544
593
  logger.log('Agent config provisioned successfully.');
594
+ } else if (reply.agentConfig && !instanceId) {
595
+ logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
545
596
  }
546
597
 
547
598
  // If the API returned agent credentials (reply.agent present),
548
599
  // wait for the runner agent to signal readiness before sending commands.
549
600
  // Without this gate, commands published before the agent subscribes are lost.
550
601
  var self = this;
551
- if (reply.agent && this._ctrlChannel) {
602
+ if (reply.agent && this._sessionChannel) {
552
603
  logger.log('Waiting for runner agent to signal readiness (direct connection)...');
553
604
  var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
554
605
  await new Promise(function (resolve, reject) {
@@ -557,7 +608,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
557
608
  if (resolved) return;
558
609
  resolved = true;
559
610
  clearTimeout(timer);
560
- self._ctrlChannel.unsubscribe('control', onCtrl);
611
+ self._sessionChannel.unsubscribe('control', onCtrl);
561
612
  logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
562
613
  if (data && data.update) {
563
614
  var u = data.update;
@@ -577,8 +628,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
577
628
  var timer = setTimeout(function () {
578
629
  if (!resolved) {
579
630
  resolved = true;
580
- self._ctrlChannel.unsubscribe('control', onCtrl);
581
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
631
+ self._sessionChannel.unsubscribe('control', onCtrl);
632
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
633
+ sentry.captureException(err, {
634
+ tags: { phase: 'runner_ready', connection_type: 'direct' },
635
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
636
+ });
637
+ reject(err);
582
638
  }
583
639
  }, readyTimeout);
584
640
  if (timer.unref) timer.unref();
@@ -591,12 +647,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
591
647
  finish(data);
592
648
  }
593
649
  };
594
- self._ctrlChannel.subscribe('control', onCtrl);
650
+ self._sessionChannel.subscribe('control', onCtrl);
595
651
 
596
652
  // Also check channel history in case runner.ready was published
597
653
  // before we subscribed (race condition on fast-booting agents).
598
654
  try {
599
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
655
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
600
656
  if (err) {
601
657
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
602
658
  return;
@@ -638,7 +694,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
638
694
  _sendAbly(message, timeout) {
639
695
  if (timeout === undefined) timeout = 300000;
640
696
 
641
- if (!this._cmdChannel || !this._ably) {
697
+ if (!this._sessionChannel || !this._ably) {
642
698
  return Promise.reject(
643
699
  new Error("Sandbox not connected (no Ably client)"),
644
700
  );
@@ -731,10 +787,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
731
787
  rejectPromise(
732
788
  new Error(
733
789
  "Sandbox message '" +
734
- message.type +
735
- "' timed out after " +
736
- timeout +
737
- "ms",
790
+ message.type +
791
+ "' timed out after " +
792
+ timeout +
793
+ "ms",
738
794
  ),
739
795
  );
740
796
  }
@@ -756,11 +812,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
756
812
  };
757
813
 
758
814
  if (message.type === "output") {
759
- p.catch(function () {});
815
+ p.catch(function () { });
760
816
  }
761
817
 
762
- this._cmdChannel
763
- .publish("command", message)
818
+ this._throttledPublish(this._sessionChannel, "command", message)
764
819
  .then(function () {
765
820
  emitter.emit(events.sandbox.sent, message);
766
821
  })
@@ -777,6 +832,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
777
832
  return p;
778
833
  }
779
834
 
835
+ /**
836
+ * Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
837
+ * Also tracks and logs the current publish rate for debugging.
838
+ * @param {Object} channel - Ably channel to publish on
839
+ * @param {string} eventName - Event name for the publish
840
+ * @param {Object} message - Message payload
841
+ * @returns {Promise} - Resolves when publish completes
842
+ */
843
+ async _throttledPublish(channel, eventName, message) {
844
+ var self = this;
845
+ var now = Date.now();
846
+
847
+ // Rate limiting: wait if too soon since last publish
848
+ var elapsed = now - this._publishLastTime;
849
+ if (elapsed < this._publishMinIntervalMs) {
850
+ var waitMs = this._publishMinIntervalMs - elapsed;
851
+ await new Promise(function (resolve) {
852
+ var timer = setTimeout(resolve, waitMs);
853
+ if (timer.unref) timer.unref();
854
+ });
855
+ }
856
+ this._publishLastTime = Date.now();
857
+
858
+ // Metrics: track messages per second
859
+ this._publishCount++;
860
+ var windowElapsed = Date.now() - this._publishWindowStart;
861
+ if (windowElapsed >= 1000) {
862
+ var rate = (this._publishCount / windowElapsed) * 1000;
863
+ var rateStr = rate.toFixed(1);
864
+
865
+ // Log rate - warning if approaching limit, debug otherwise
866
+ if (rate > 45) {
867
+ logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
868
+ } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
869
+ logger.log("Ably publish rate: " + rateStr + " msg/sec");
870
+ }
871
+
872
+ // Reset window
873
+ this._publishCount = 0;
874
+ this._publishWindowStart = Date.now();
875
+ }
876
+
877
+ return channel.publish(eventName, message).then(function () {
878
+ logger.log(`[ably] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
879
+ });
880
+ }
881
+
780
882
  async auth(apiKey) {
781
883
  this.apiKey = apiKey;
782
884
  var sessionId = this.sessionInstance
@@ -803,7 +905,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
803
905
  logger.log("Trace Report (Share When Reporting Bugs):");
804
906
  logger.log(
805
907
  "https://testdriver.sentry.io/explore/traces/trace/" +
806
- reply.traceId,
908
+ reply.traceId,
807
909
  );
808
910
  }
809
911
 
@@ -846,7 +948,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
846
948
  this._sandboxId = reply.sandboxId;
847
949
 
848
950
  if (reply.ably && reply.ably.token) {
849
- await this._initAbly(reply.ably.token, reply.ably.channels);
951
+ await this._initAbly(reply.ably.token, reply.ably.channel);
850
952
  }
851
953
 
852
954
  this.setConnectionParams({
@@ -895,38 +997,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
895
997
  clearInterval(this.heartbeat);
896
998
  this.heartbeat = null;
897
999
  }
1000
+ if (this._statsInterval) {
1001
+ clearInterval(this._statsInterval);
1002
+ this._statsInterval = null;
1003
+ }
898
1004
 
899
1005
  // Send end-session control message to runner before disconnecting
900
- if (this._ctrlChannel && this._ably?.connection?.state === 'connected') {
1006
+ if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
901
1007
  try {
902
- await this._ctrlChannel.publish('control', { type: 'end-session' });
1008
+ logger.log('[ably] Publishing control: type=end-session');
1009
+ await this._sessionChannel.publish('control', { type: 'end-session' });
903
1010
  } catch (e) {
904
1011
  // Ignore - best effort
905
1012
  }
906
1013
  }
907
1014
 
908
- // Leave presence on control channel
909
- if (this._ctrlChannel) {
1015
+ // Leave presence on session channel
1016
+ if (this._sessionChannel) {
910
1017
  try {
911
- await this._ctrlChannel.presence.leave();
1018
+ logger.log('[ably] Leaving presence on session channel');
1019
+ await this._sessionChannel.presence.leave();
912
1020
  } catch (e) {
913
1021
  // ignore - best effort, Ably will auto-leave on disconnect
914
1022
  }
915
1023
  }
916
1024
 
917
1025
  try {
918
- await Promise.allSettled([
919
- this._cmdChannel?.detach(),
920
- this._respChannel?.detach(),
921
- this._ctrlChannel?.detach(),
922
- this._filesChannel?.detach(),
923
- ].filter(Boolean));
1026
+ logger.log('[ably] Detaching session channel');
1027
+ if (this._sessionChannel) {
1028
+ await this._sessionChannel.detach();
1029
+ }
924
1030
  } catch (e) {
925
1031
  /* ignore */
926
1032
  }
927
1033
 
928
1034
  if (this._ably) {
929
1035
  try {
1036
+ logger.log('[ably] Closing Ably connection');
930
1037
  this._ably.close();
931
1038
  } catch (e) {
932
1039
  /* ignore */
@@ -934,11 +1041,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
934
1041
  this._ably = null;
935
1042
  }
936
1043
 
937
- this._cmdChannel = null;
938
- this._respChannel = null;
939
- this._ctrlChannel = null;
940
- this._filesChannel = null;
941
- this._channelNames = null;
1044
+ this._sessionChannel = null;
1045
+ this._channelName = null;
942
1046
  this.apiSocketConnected = false;
943
1047
  this.instanceSocketConnected = false;
944
1048
  this.authenticated = false;
@@ -961,11 +1065,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
961
1065
  const region = process.env.AWS_REGION || 'us-east-2';
962
1066
 
963
1067
  // Write SSM parameters to a temp file to avoid shell quoting issues
1068
+ // Log key config details for debugging
1069
+ logger.log('Agent config being provisioned:');
1070
+ logger.log(' sandboxId: ' + agentConfig.sandboxId);
1071
+ logger.log(' apiRoot: ' + agentConfig.apiRoot);
1072
+ logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
1073
+ logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
1074
+
964
1075
  const paramsJson = JSON.stringify({
965
1076
  commands: [
1077
+ // Debug: show existing state
1078
+ "Write-Host '=== Checking existing state ==='",
1079
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1080
+ "if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
1081
+ "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' }",
1082
+ // Stop any running runner
1083
+ "Write-Host '=== Stopping runner ==='",
1084
+ "Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
1085
+ "Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1086
+ // Write config
1087
+ "Write-Host '=== Writing config ==='",
966
1088
  "$config = '" + configJson.replace(/'/g, "''") + "'",
967
1089
  "[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
968
1090
  "Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
1091
+ // Show what was written (redact token)
1092
+ "Write-Host '=== New config (token redacted) ==='",
1093
+ "$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
1094
+ "Write-Host \"sandboxId: $($cfg.sandboxId)\"",
1095
+ "Write-Host \"apiRoot: $($cfg.apiRoot)\"",
1096
+ "Write-Host \"channel: $($cfg.ably.channel)\"",
1097
+ "Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
1098
+ // Start the runner
1099
+ "Write-Host '=== Starting runner ==='",
1100
+ "Start-Sleep -Seconds 1",
1101
+ "Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
1102
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
1103
+ "Write-Host \"Task state after start: $($task.State)\"",
1104
+ // Check if node process started
1105
+ "Start-Sleep -Seconds 3",
1106
+ "Write-Host '=== Checking runner process ==='",
1107
+ "$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
1108
+ "if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
1109
+ // Check runner logs
1110
+ "Write-Host '=== Runner log (last 30 lines) ==='",
1111
+ "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' }",
1112
+ "Write-Host '=== Done ==='",
969
1113
  ],
970
1114
  });
971
1115
  const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
@@ -987,6 +1131,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
987
1131
  '--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
988
1132
  { encoding: 'utf-8', timeout: 60000 }
989
1133
  );
1134
+
1135
+ // Get the command output for debugging
1136
+ try {
1137
+ const invocationOutput = execSync(
1138
+ 'aws ssm get-command-invocation --region "' + region + '" ' +
1139
+ '--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
1140
+ { encoding: 'utf-8', timeout: 30000 }
1141
+ );
1142
+ const invocation = JSON.parse(invocationOutput);
1143
+ if (invocation.StandardOutputContent) {
1144
+ logger.log('SSM output:\n' + invocation.StandardOutputContent);
1145
+ }
1146
+ if (invocation.StandardErrorContent) {
1147
+ logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
1148
+ }
1149
+ } catch (e) {
1150
+ logger.warn('Could not retrieve SSM command output: ' + e.message);
1151
+ }
990
1152
  } finally {
991
1153
  try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
992
1154
  }