@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.
- package/agent/index.js +4 -0
- package/agent/lib/logger.js +15 -0
- package/agent/lib/sandbox.js +114 -64
- package/ai/skills/testdriver-find/SKILL.md +14 -20
- package/docs/_data/examples-manifest.json +46 -46
- package/docs/_scripts/extract-example-urls.js +67 -72
- package/docs/docs.json +2 -1
- package/docs/v7/examples/ai.mdx +1 -1
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +1 -1
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/exec-output.mdx +1 -1
- package/docs/v7/examples/exec-pwsh.mdx +1 -1
- package/docs/v7/examples/focus-window.mdx +1 -1
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/press-keys.mdx +1 -1
- package/docs/v7/examples/scroll-keyboard.mdx +1 -1
- package/docs/v7/examples/scroll-until-image.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/examples/windows-installer.mdx +1 -1
- package/docs/v7/find.mdx +14 -20
- package/docs/v7/test-results-json.mdx +258 -0
- package/examples/scroll-keyboard.test.mjs +1 -1
- package/interfaces/vitest-plugin.mjs +116 -7
- package/lib/vitest/hooks.mjs +60 -0
- package/package.json +1 -1
- package/sdk.d.ts +4 -0
- package/sdk.js +41 -11
- package/setup/aws/spawn-runner.sh +44 -13
- 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;
|
package/agent/lib/logger.js
CHANGED
|
@@ -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,
|
package/agent/lib/sandbox.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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("
|
|
97
|
+
reject(new Error("Realtime connection failed"));
|
|
79
98
|
});
|
|
80
99
|
setTimeout(function () {
|
|
81
|
-
reject(new Error("
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
166
|
-
'[
|
|
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.
|
|
181
|
-
'[
|
|
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.
|
|
201
|
-
'[
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
279
|
-
'[
|
|
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.
|
|
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.
|
|
316
|
+
logger.debug("[realtime] Connection: failed");
|
|
298
317
|
self.apiSocketConnected = false;
|
|
299
318
|
self.instanceSocketConnected = false;
|
|
300
|
-
emitter.emit(events.error.sandbox, "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
380
|
+
logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
362
381
|
} catch (err) {
|
|
363
|
-
logger.
|
|
382
|
+
logger.debug('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
364
383
|
}
|
|
365
384
|
}
|
|
366
385
|
if (totalRecovered > 0) {
|
|
367
|
-
logger.
|
|
386
|
+
logger.debug('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
368
387
|
} else {
|
|
369
|
-
logger.
|
|
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
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
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
|
-
|
|
569
|
+
const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
|
|
570
|
+
if (needsReadyWait) {
|
|
546
571
|
logger.log('Waiting for runner agent to signal readiness...');
|
|
547
|
-
|
|
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 (
|
|
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("
|
|
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
|
-
'[
|
|
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
|
-
'[
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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={
|
|
53
|
-
|
|
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
|
|
335
|
+
## Zoom Mode
|
|
336
336
|
|
|
337
|
-
|
|
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
|
-
//
|
|
341
|
-
const extensionsBtn = await testdriver.find('extensions puzzle icon in Chrome toolbar'
|
|
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
|
-
|
|
356
|
-
-
|
|
357
|
-
-
|
|
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.
|