@testdriverai/agent 7.8.0-canary.14 → 7.8.0-canary.16

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 (35) hide show
  1. package/agent/index.js +4 -0
  2. package/agent/lib/logger.js +15 -0
  3. package/agent/lib/sandbox.js +114 -64
  4. package/ai/skills/testdriver-find/SKILL.md +14 -20
  5. package/docs/_data/examples-manifest.json +46 -46
  6. package/docs/_scripts/extract-example-urls.js +67 -72
  7. package/docs/docs.json +2 -1
  8. package/docs/v7/examples/ai.mdx +1 -1
  9. package/docs/v7/examples/assert.mdx +1 -1
  10. package/docs/v7/examples/chrome-extension.mdx +1 -1
  11. package/docs/v7/examples/element-not-found.mdx +1 -1
  12. package/docs/v7/examples/exec-output.mdx +1 -1
  13. package/docs/v7/examples/exec-pwsh.mdx +1 -1
  14. package/docs/v7/examples/focus-window.mdx +1 -1
  15. package/docs/v7/examples/hover-image.mdx +1 -1
  16. package/docs/v7/examples/hover-text.mdx +1 -1
  17. package/docs/v7/examples/installer.mdx +1 -1
  18. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  19. package/docs/v7/examples/match-image.mdx +1 -1
  20. package/docs/v7/examples/press-keys.mdx +1 -1
  21. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  22. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  23. package/docs/v7/examples/scroll.mdx +1 -1
  24. package/docs/v7/examples/type.mdx +1 -1
  25. package/docs/v7/examples/windows-installer.mdx +1 -1
  26. package/docs/v7/find.mdx +14 -20
  27. package/docs/v7/test-results-json.mdx +258 -0
  28. package/examples/scroll-keyboard.test.mjs +1 -1
  29. package/interfaces/vitest-plugin.mjs +116 -7
  30. package/lib/vitest/hooks.mjs +60 -0
  31. package/package.json +1 -1
  32. package/sdk.d.ts +4 -0
  33. package/sdk.js +41 -11
  34. package/setup/aws/spawn-runner.sh +44 -13
  35. package/vitest.config.mjs +2 -2
package/agent/index.js CHANGED
@@ -70,6 +70,7 @@ class TestDriverAgent extends EventEmitter2 {
70
70
  this.sandboxId = flags["sandbox-id"] || null;
71
71
  this.sandboxAmi = flags["sandbox-ami"] || null;
72
72
  this.sandboxInstance = flags["sandbox-instance"] || null;
73
+ this.e2bTemplateId = flags["e2b-template-id"] || null;
73
74
  this.sandboxOs = flags.os || "linux";
74
75
  this.ip = flags.ip || null;
75
76
  this.workingDir = flags.workingDir || process.cwd();
@@ -2188,6 +2189,9 @@ Please check your network connection, TD_API_KEY, or the service status.`,
2188
2189
  if (this.sandboxInstance) {
2189
2190
  sandboxConfig.instanceType = this.sandboxInstance;
2190
2191
  }
2192
+ if (this.e2bTemplateId) {
2193
+ sandboxConfig.e2bTemplateId = this.e2bTemplateId;
2194
+ }
2191
2195
  // Add keepAlive TTL if specified
2192
2196
  if (this.keepAlive !== undefined && this.keepAlive !== null) {
2193
2197
  sandboxConfig.keepAlive = this.keepAlive;
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const useStderr = process.env.TD_STDIO === 'stderr';
10
+ const isDebug = process.env.TD_DEBUG === 'true' || process.env.VERBOSE === 'true';
10
11
 
11
12
  /**
12
13
  * Log a message - uses stdout by default, stderr if TD_STDIO=stderr
@@ -40,6 +41,19 @@ function warn(...args) {
40
41
  }
41
42
  }
42
43
 
44
+ /**
45
+ * Log a debug message - only outputs when DEBUG=true
46
+ * @param {...any} args - Arguments to log
47
+ */
48
+ function debug(...args) {
49
+ if (!isDebug) return;
50
+ if (useStderr) {
51
+ console.error(...args);
52
+ } else {
53
+ console.log(...args);
54
+ }
55
+ }
56
+
43
57
  /**
44
58
  * Check if logger is configured to use stderr
45
59
  * @returns {boolean}
@@ -50,6 +64,7 @@ function isStderrMode() {
50
64
 
51
65
  module.exports = {
52
66
  log,
67
+ debug,
53
68
  error,
54
69
  warn,
55
70
  isStderrMode,
@@ -29,7 +29,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
29
29
  this._lastConnectParams = null;
30
30
  this._teamId = null;
31
31
  this._sandboxId = null;
32
- this._disconnectedAt = null; // tracks when Ably connection dropped (for timeout extension on reconnect)
32
+ this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
33
33
 
34
34
  // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
35
35
  this._publishLastTime = 0;
@@ -49,6 +49,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
49
49
  );
50
50
  }
51
51
 
52
+ getPublishCount() {
53
+ return this._publishCount;
54
+ }
55
+
52
56
  async _initAbly(ablyToken, channelName) {
53
57
  if (this._ably) {
54
58
  try {
@@ -61,8 +65,23 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
61
65
  var self = this;
62
66
 
63
67
  this._ably = new Ably.Realtime({
64
- authCallback: function (tokenParams, callback) {
65
- 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
+ }
66
85
  },
67
86
  clientId: "sdk-" + this._sandboxId,
68
87
  echoMessages: false, // don't receive our own published messages
@@ -70,21 +89,21 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
70
89
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
71
90
  });
72
91
 
73
- logger.log(`[ably] Connecting as sdk-${this._sandboxId}...`);
92
+ logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
74
93
 
75
94
  await new Promise(function (resolve, reject) {
76
95
  self._ably.connection.on("connected", resolve);
77
96
  self._ably.connection.on("failed", function () {
78
- reject(new Error("Ably connection failed"));
97
+ reject(new Error("Realtime connection failed"));
79
98
  });
80
99
  setTimeout(function () {
81
- reject(new Error("Ably connection timeout"));
100
+ reject(new Error("Realtime connection timeout"));
82
101
  }, 30000);
83
102
  });
84
103
 
85
104
  this._sessionChannel = this._ably.channels.get(channelName);
86
105
 
87
- logger.log(`[ably] Channel initialized: ${channelName}`);
106
+ logger.debug(`[realtime] Channel initialized: ${channelName}`);
88
107
 
89
108
  // Enter presence on the session channel so the API can count connected SDK clients
90
109
  try {
@@ -92,7 +111,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
92
111
  sandboxId: this._sandboxId,
93
112
  connectedAt: Date.now(),
94
113
  });
95
- logger.log(`[ably] Entered presence on session channel (sandbox=${this._sandboxId})`);
114
+ logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
96
115
  } catch (e) {
97
116
  // Non-fatal — presence is used for concurrency counting, not critical path
98
117
  logger.warn("Failed to enter presence on session channel: " + (e.message || e));
@@ -103,7 +122,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
103
122
  var message = msg.data;
104
123
  if (!message) return;
105
124
 
106
- logger.log(`[ably] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
125
+ logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
107
126
 
108
127
  if (message.type === "sandbox.progress") {
109
128
  emitter.emit(events.sandbox.progress, {
@@ -162,8 +181,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
162
181
  return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
163
182
  }).join(', ')
164
183
  : 'none';
165
- logger.warn(
166
- '[ably] No pending promise for requestId=' + (message.requestId || 'null') +
184
+ logger.debug(
185
+ '[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
167
186
  ' | response type=' + (message.type || 'unknown') +
168
187
  ' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
169
188
  ' | currently pending: [' + pendingSummary + ']'
@@ -177,8 +196,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
177
196
  var pendingAge = pendingEntry && pendingEntry.startTime
178
197
  ? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
179
198
  : '?';
180
- logger.warn(
181
- '[ably] Promise REJECTED: requestId=' + message.requestId +
199
+ logger.debug(
200
+ '[realtime] Promise REJECTED: requestId=' + message.requestId +
182
201
  ' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
183
202
  ' | age=' + pendingAge +
184
203
  ' | error=' + (message.errorMessage || 'Sandbox error')
@@ -197,8 +216,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
197
216
  var resolveAge = resolveEntry.startTime
198
217
  ? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
199
218
  : '?';
200
- logger.log(
201
- '[ably] Promise RESOLVED: requestId=' + message.requestId +
219
+ logger.debug(
220
+ '[realtime] Promise RESOLVED: requestId=' + message.requestId +
202
221
  ' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
203
222
  ' | age=' + resolveAge
204
223
  );
@@ -226,7 +245,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
226
245
  this._onFileMsg = function (msg) {
227
246
  var message = msg.data;
228
247
  if (!message) return;
229
- logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
248
+ logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
230
249
  if (message.requestId && self.ps[message.requestId]) {
231
250
  emitter.emit(events.sandbox.received);
232
251
  self.ps[message.requestId].resolve(message);
@@ -245,7 +264,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
245
264
  const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
246
265
  const pendingIds = Object.keys(this.ps);
247
266
  const pending = pendingIds.length;
248
- logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
267
+ logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
249
268
  if (pending > 0) {
250
269
  const now = Date.now();
251
270
  for (const rid of pendingIds) {
@@ -253,20 +272,20 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
253
272
  if (!entry) continue;
254
273
  const type = entry.message ? entry.message.type : 'unknown';
255
274
  const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
256
- logger.log(`[ably][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
275
+ logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
257
276
  }
258
277
  }
259
278
  }, 10000);
260
279
  if (this._statsInterval.unref) this._statsInterval.unref();
261
280
 
262
281
  this._ably.connection.on("disconnected", function () {
263
- logger.log("[ably] Connection: disconnected - will auto-reconnect");
282
+ logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
264
283
  self._disconnectedAt = Date.now();
265
284
  });
266
285
 
267
286
  this._ably.connection.on("connected", function () {
268
287
  // Log reconnection so the user knows the blip was recovered
269
- logger.log("[ably] Connection: reconnected");
288
+ logger.debug("[realtime] Connection: reconnected");
270
289
  // Extend any pending command timeouts by the disconnection duration so
271
290
  // commands whose timer was counting down while the connection was down
272
291
  // don't get incorrectly timed out.
@@ -275,8 +294,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
275
294
  self._disconnectedAt = null;
276
295
  var pendingIds = Object.keys(self.ps);
277
296
  if (pendingIds.length > 0) {
278
- logger.log(
279
- '[ably] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
297
+ logger.debug(
298
+ '[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
280
299
  disconnectionDurationMs + 'ms after disconnection'
281
300
  );
282
301
  for (var i = 0; i < pendingIds.length; i++) {
@@ -290,14 +309,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
290
309
  });
291
310
 
292
311
  this._ably.connection.on("suspended", function () {
293
- logger.warn("[ably] Connection: suspended - connection lost for extended period, will keep retrying");
312
+ logger.debug("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
294
313
  });
295
314
 
296
315
  this._ably.connection.on("failed", function () {
297
- logger.error("[ably] Connection: failed");
316
+ logger.debug("[realtime] Connection: failed");
298
317
  self.apiSocketConnected = false;
299
318
  self.instanceSocketConnected = false;
300
- emitter.emit(events.error.sandbox, "Ably connection failed");
319
+ emitter.emit(events.error.sandbox, "Realtime connection failed");
301
320
  });
302
321
 
303
322
  // ─── Channel discontinuity detection ──────────────────────────────
@@ -310,8 +329,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
310
329
  var reason = stateChange.reason;
311
330
  var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
312
331
 
313
- if (current === 'attached' && stateChange.resumed === false && previous) {
314
- logger.warn('[ably] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
332
+ if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
333
+ logger.debug('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
315
334
  emitter.emit(events.sandbox.progress, {
316
335
  step: 'discontinuity',
317
336
  message: 'Recovering missed messages after connection interruption...',
@@ -338,7 +357,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
338
357
  var entry = subs[i];
339
358
  if (!entry.sub) continue;
340
359
  try {
341
- logger.log('[ably] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
360
+ logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
342
361
  var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
343
362
  var recovered = 0;
344
363
  while (page) {
@@ -348,25 +367,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
348
367
  recovered++;
349
368
  try {
350
369
  if (entry.handler) {
351
- logger.log('[ably] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
370
+ logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
352
371
  entry.handler(page.items[j]);
353
372
  }
354
373
  } catch (replayErr) {
355
- logger.error('[ably] Error replaying recovered message: ' + (replayErr.message || replayErr));
374
+ logger.debug('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
356
375
  }
357
376
  }
358
377
  page = page.hasNext() ? await page.next() : null;
359
378
  }
360
379
  totalRecovered += recovered;
361
- logger.log('[ably] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
380
+ logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
362
381
  } catch (err) {
363
- logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
382
+ logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
364
383
  }
365
384
  }
366
385
  if (totalRecovered > 0) {
367
- logger.warn('[ably] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
386
+ logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
368
387
  } else {
369
- logger.log('[ably] Discontinuity recovery: no missed messages found');
388
+ logger.debug('[realtime] Discontinuity recovery: no missed messages found');
370
389
  }
371
390
  }
372
391
 
@@ -474,6 +493,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
474
493
  body.ci = message.ci;
475
494
  if (message.ami) body.ami = message.ami;
476
495
  if (message.instanceType) body.instanceType = message.instanceType;
496
+ if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
477
497
  if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
478
498
  }
479
499
 
@@ -517,34 +537,40 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
517
537
  }
518
538
 
519
539
  if (message.type === "create") {
520
- // E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
521
- // No runner agent involved skip runner.ready wait.
522
- if (reply.url) {
523
- logger.log(`E2B sandbox ready — url=${reply.url}`);
524
- return {
525
- success: true,
526
- sandbox: {
527
- sandboxId: reply.sandboxId,
528
- instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
529
- os: body.os || 'linux',
530
- url: reply.url,
531
- },
532
- };
533
- }
534
-
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
+
535
544
  const runnerIp = reply.runner && reply.runner.ip;
536
545
  const noVncPort = reply.runner && reply.runner.noVncPort;
537
546
  const runnerVncUrl = reply.runner && reply.runner.vncUrl;
538
547
 
539
- 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
+ }
540
560
 
541
- // For cloud Windows sandboxes (no runner in reply), wait for the
542
- // agent to signal readiness before sending commands. Without this
543
- // 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.
544
568
  var self = this;
545
- if (!reply.runner && this._sessionChannel) {
569
+ const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
570
+ if (needsReadyWait) {
546
571
  logger.log('Waiting for runner agent to signal readiness...');
547
- 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
+ var readyTimeout = isE2B ? 300000 : 120000; // 5 min for E2B (S3+npm), 2 min for EC2
548
574
  await new Promise(function (resolve, reject) {
549
575
  var resolved = false;
550
576
  function finish(data) {
@@ -557,7 +583,18 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
557
583
  if (data && data.os && reply.runner) reply.runner.os = data.os;
558
584
  if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
559
585
  if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
586
+ // Persist version metadata for test result reporting
587
+ self._runnerVersionBefore = reply.imageVersion || null;
588
+ self._runnerVersionAfter = (data && data.runnerVersion) || reply.imageVersion || null;
589
+ self._wasUpdated = !!(data && data.runnerVersion && reply.imageVersion && data.runnerVersion !== reply.imageVersion);
560
590
  logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
591
+ // Show upgrade info: if the runner's npm version differs from the baked image version,
592
+ // the runner was upgraded during provisioning.
593
+ var runnerVer = data && data.runnerVersion;
594
+ var imageVer = reply.imageVersion;
595
+ if (runnerVer && imageVer && runnerVer !== imageVer) {
596
+ logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
597
+ }
561
598
  if (data && data.update) {
562
599
  var u = data.update;
563
600
  if (u.status === 'up-to-date') {
@@ -622,9 +659,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
622
659
  });
623
660
  }
624
661
  // Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
662
+ // For E2B sandboxes, use the url from the API reply.
625
663
  // Fall back to constructing from ip + noVncPort for older runners.
626
664
  let url;
627
- if (runnerVncUrl) {
665
+ if (isE2B && reply.url) {
666
+ url = reply.url;
667
+ logger.log(`E2B sandbox ready — url=${url}`);
668
+ } else if (runnerVncUrl) {
628
669
  url = runnerVncUrl;
629
670
  logger.log(`Using runner-provided vncUrl: ${url}`);
630
671
  } else if (runnerIp && noVncPort) {
@@ -646,6 +687,15 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
646
687
  url: url,
647
688
  vncPort: noVncPort || undefined,
648
689
  runner: reply.runner,
690
+ // Extra metadata for test result reporting
691
+ amiId: reply.amiId || null,
692
+ e2bTemplateId: reply.e2bTemplateId || null,
693
+ imageVersion: reply.imageVersion || null,
694
+ runnerVersionBefore: this._runnerVersionBefore || reply.imageVersion || null,
695
+ runnerVersionAfter: this._runnerVersionAfter || reply.runner?.version || null,
696
+ wasUpdated: this._wasUpdated || false,
697
+ vncUrl: url || null,
698
+ channelName: this._channelName || null,
649
699
  },
650
700
  };
651
701
  }
@@ -793,7 +843,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
793
843
  function onFailed() {
794
844
  clearTimeout(timer);
795
845
  self._ably.connection.off("connected", onConnected);
796
- reject(new Error("Ably connection failed while waiting to send"));
846
+ reject(new Error("Realtime connection failed while waiting to send"));
797
847
  }
798
848
  self._ably.connection.once("connected", onConnected);
799
849
  self._ably.connection.once("failed", onFailed);
@@ -865,7 +915,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
865
915
  return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
866
916
  }).join(', ');
867
917
  logger.error(
868
- '[ably] Promise TIMEOUT: requestId=' + requestId +
918
+ '[realtime] Promise TIMEOUT: requestId=' + requestId +
869
919
  ' | type=' + message.type +
870
920
  ' | timeout=' + timeout + 'ms' +
871
921
  ' | all pending: [' + pendingSummary + ']'
@@ -914,7 +964,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
914
964
  timeoutId = setTimeout(timeoutFn, newRemaining);
915
965
  if (timeoutId.unref) timeoutId.unref();
916
966
  logger.log(
917
- '[ably] Extended timeout for requestId=' + requestId +
967
+ '[realtime] Extended timeout for requestId=' + requestId +
918
968
  ' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
919
969
  );
920
970
  },
@@ -986,7 +1036,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
986
1036
  }
987
1037
 
988
1038
  return channel.publish(eventName, message).then(function () {
989
- logger.log(`[ably] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
1039
+ logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
990
1040
  });
991
1041
  }
992
1042
 
@@ -1116,7 +1166,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1116
1166
  // Send end-session control message to runner before disconnecting
1117
1167
  if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
1118
1168
  try {
1119
- logger.log('[ably] Publishing control: type=end-session');
1169
+ logger.debug('[realtime] Publishing control: type=end-session');
1120
1170
  await this._sessionChannel.publish('control', { type: 'end-session' });
1121
1171
  } catch (e) {
1122
1172
  // Ignore - best effort
@@ -1126,7 +1176,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1126
1176
  // Leave presence on session channel
1127
1177
  if (this._sessionChannel) {
1128
1178
  try {
1129
- logger.log('[ably] Leaving presence on session channel');
1179
+ logger.debug('[realtime] Leaving presence on session channel');
1130
1180
  await this._sessionChannel.presence.leave();
1131
1181
  } catch (e) {
1132
1182
  // ignore - best effort, Ably will auto-leave on disconnect
@@ -1134,7 +1184,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1134
1184
  }
1135
1185
 
1136
1186
  try {
1137
- logger.log('[ably] Detaching session channel');
1187
+ logger.debug('[realtime] Detaching session channel');
1138
1188
  if (this._sessionChannel) {
1139
1189
  await this._sessionChannel.detach();
1140
1190
  }
@@ -1144,7 +1194,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
1144
1194
 
1145
1195
  if (this._ably) {
1146
1196
  try {
1147
- logger.log('[ably] Closing Ably connection');
1197
+ logger.debug('[realtime] Closing Realtime connection');
1148
1198
  this._ably.close();
1149
1199
  } catch (e) {
1150
1200
  /* ignore */
@@ -49,8 +49,8 @@ const element = await testdriver.find(description, options)
49
49
  - `"any"` — No wrapping, uses the description as-is (default behavior)
50
50
  </ParamField>
51
51
 
52
- <ParamField path="zoom" type="boolean" default={false}>
53
- Enable two-phase zoom mode for better precision in crowded UIs with many similar elements.
52
+ <ParamField path="zoom" type="boolean" default={true}>
53
+ Two-phase zoom mode for better precision in crowded UIs with many similar elements. Enabled by default.
54
54
  </ParamField>
55
55
 
56
56
  <ParamField path="ai" type="object">
@@ -332,14 +332,19 @@ The `timeout` option:
332
332
  - Returns the element (check `element.found()` if not throwing on failure)
333
333
  - Set to `0` to disable polling and make a single attempt
334
334
 
335
- ## Zoom Mode for Crowded UIs
335
+ ## Zoom Mode
336
336
 
337
- When dealing with many similar icons or elements clustered together (like browser toolbars), enable `zoom` mode for better precision:
337
+ Zoom mode is **enabled by default**. It uses a two-phase approach for better precision when locating elements, especially in crowded UIs with many similar elements.
338
+
339
+ To disable zoom for a specific find call, pass `zoom: false`:
338
340
 
339
341
  ```javascript
340
- // Enable zoom for better precision in crowded UIs
341
- const extensionsBtn = await testdriver.find('extensions puzzle icon in Chrome toolbar', { zoom: true });
342
+ // Zoom is on by default no option needed
343
+ const extensionsBtn = await testdriver.find('extensions puzzle icon in Chrome toolbar');
342
344
  await extensionsBtn.click();
345
+
346
+ // Disable zoom for a specific call if needed
347
+ const largeButton = await testdriver.find('big hero button', { zoom: false });
343
348
  ```
344
349
 
345
350
  ### How Zoom Mode Works
@@ -352,22 +357,11 @@ await extensionsBtn.click();
352
357
  This two-phase approach gives the AI a higher-resolution view of the target area, improving accuracy when multiple similar elements are close together.
353
358
 
354
359
  <Tip>
355
- Use `zoom: true` when:
356
- - Clicking small icons in toolbars
357
- - Selecting from a grid of similar items
358
- - Targeting elements in dense UI areas
359
- - The default locate is clicking the wrong similar element
360
- - You get an AI verification rejection like "The crosshair is located in the empty space of the browser's tab bar/title bar area" — this means the initial locate was imprecise and zoom will help the AI pinpoint the correct element
360
+ You may want to disable zoom with `zoom: false` when:
361
+ - Targeting large, isolated elements where the extra precision isn't needed
362
+ - You want to speed up find calls in simple UIs
361
363
  </Tip>
362
364
 
363
- ```javascript
364
- // Without zoom - may click wrong icon in toolbar
365
- const icon = await testdriver.find('settings icon');
366
-
367
- // With zoom - better precision for crowded areas
368
- const icon = await testdriver.find('settings icon', { zoom: true });
369
- ```
370
-
371
365
  ## Cache Options
372
366
 
373
367
  Control caching behavior to optimize performance, especially when using dynamic variables in prompts.