@testdriverai/mcp 7.11.10-test → 7.11.11-test
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.
|
@@ -58,7 +58,29 @@ export declare class NoActiveSessionError extends Error {
|
|
|
58
58
|
* per async call via {@link als}.
|
|
59
59
|
*/
|
|
60
60
|
export interface CoreContext {
|
|
61
|
+
/**
|
|
62
|
+
* The live SDK handle. Only ever assigned once its `connect()` has resolved —
|
|
63
|
+
* see {@link sdkGeneration}. Readers may assume `sdk.connected === true`.
|
|
64
|
+
*/
|
|
61
65
|
sdk: any;
|
|
66
|
+
/**
|
|
67
|
+
* Monotonic epoch, bumped by every flow that starts building a new SDK
|
|
68
|
+
* (`sessionStart` / `reconnectSession`).
|
|
69
|
+
*
|
|
70
|
+
* Why: a flow can be abandoned while its `connect()` is still in flight — eve's
|
|
71
|
+
* per-action deadline explicitly stops waiting on the SDK promise but cannot
|
|
72
|
+
* cancel it, so the abandoned action keeps running and races the retry that
|
|
73
|
+
* replaced it. Both then reach for `c.sdk`. Without an epoch the loser lands
|
|
74
|
+
* last and installs its (older, or half-built) SDK over the winner's, and every
|
|
75
|
+
* subsequent action fails against a connection nobody is maintaining.
|
|
76
|
+
*
|
|
77
|
+
* Each flow captures the epoch before it builds, and publishes only if the
|
|
78
|
+
* epoch is still its own. The loser closes the sandbox it built instead of
|
|
79
|
+
* leaking it — an orphaned sandbox pins a team concurrency slot for the whole
|
|
80
|
+
* keepAlive window (15 min for eve), which is what eventually starves the team
|
|
81
|
+
* and turns every later `connect()` into a slot-denial poll.
|
|
82
|
+
*/
|
|
83
|
+
sdkGeneration: number;
|
|
62
84
|
lastScreenshotBase64: string | null;
|
|
63
85
|
reconnectResolver: ReconnectResolver | null;
|
|
64
86
|
/** In-flight reconnect, so concurrent actions in one context share one rebuild. */
|
|
@@ -144,13 +166,41 @@ export declare function getElementRef(ref: string): {
|
|
|
144
166
|
};
|
|
145
167
|
} | undefined;
|
|
146
168
|
/**
|
|
147
|
-
* Cheap, side-effect-free check for a usable in-process session: a
|
|
148
|
-
* a current session that hasn't expired. Durable adapters
|
|
149
|
-
* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
150
|
-
* common warm-process path stays free. Note: unlike
|
|
151
|
-
* does NOT refresh the keepAlive window — it only
|
|
169
|
+
* Cheap, side-effect-free check for a usable in-process session: a *connected*
|
|
170
|
+
* SDK and a current session that is active and hasn't expired. Durable adapters
|
|
171
|
+
* call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
172
|
+
* key), so the common warm-process path stays free. Note: unlike
|
|
173
|
+
* `requireActiveSession`, this does NOT refresh the keepAlive window — it only
|
|
174
|
+
* reports liveness.
|
|
175
|
+
*
|
|
176
|
+
* Both extra conditions matter, and neither used to be checked:
|
|
177
|
+
*
|
|
178
|
+
* - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
|
|
179
|
+
* is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
|
|
180
|
+
* accepted an SDK whose `connect()` was still in flight, so a caller could be
|
|
181
|
+
* told the session was live and then have the very next action throw "SDK is
|
|
182
|
+
* not connected. Call connect() first."
|
|
183
|
+
* - `status === "active"` — `isSessionValid()` deliberately accepts
|
|
184
|
+
* `"initializing"` (it only rejects `expired`/`error`), so a session that was
|
|
185
|
+
* created but never finished connecting counted as usable.
|
|
152
186
|
*/
|
|
153
187
|
export declare function hasLiveSession(): boolean;
|
|
188
|
+
/**
|
|
189
|
+
* Drop the in-process session and SDK handle after the caller has released the
|
|
190
|
+
* sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
|
|
191
|
+
*
|
|
192
|
+
* Without this, ending a session left the context claiming a live one: `close()`
|
|
193
|
+
* tears down the sandbox's Ably client but never clears the SDK's own `connected`
|
|
194
|
+
* flag, and the session stayed `active` in the registry pointing at a sandbox that
|
|
195
|
+
* no longer exists — so `hasLiveSession()` answered true and `session_status`
|
|
196
|
+
* reported an active session for a dead VM.
|
|
197
|
+
*
|
|
198
|
+
* Bumping the epoch is the important part: any build still in flight (an abandoned
|
|
199
|
+
* action's reconnect) now loses its publish and closes the sandbox it opened,
|
|
200
|
+
* instead of resurrecting a session the caller explicitly ended — which would
|
|
201
|
+
* silently re-take a team concurrency slot.
|
|
202
|
+
*/
|
|
203
|
+
export declare function clearSession(): void;
|
|
154
204
|
export interface SessionStartHooks {
|
|
155
205
|
/** Called before a long await; return a stop fn. Lets adapters heartbeat. */
|
|
156
206
|
onProgress?: (message: string) => void;
|
|
@@ -39,6 +39,7 @@ export class NoActiveSessionError extends Error {
|
|
|
39
39
|
export function createCoreContext() {
|
|
40
40
|
return {
|
|
41
41
|
sdk: null,
|
|
42
|
+
sdkGeneration: 0,
|
|
42
43
|
lastScreenshotBase64: null,
|
|
43
44
|
reconnectResolver: null,
|
|
44
45
|
reconnecting: null,
|
|
@@ -110,16 +111,55 @@ export function getElementRef(ref) {
|
|
|
110
111
|
return ctx().elementRefs.get(ref);
|
|
111
112
|
}
|
|
112
113
|
/**
|
|
113
|
-
* Cheap, side-effect-free check for a usable in-process session: a
|
|
114
|
-
* a current session that hasn't expired. Durable adapters
|
|
115
|
-
* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
116
|
-
* common warm-process path stays free. Note: unlike
|
|
117
|
-
* does NOT refresh the keepAlive window — it only
|
|
114
|
+
* Cheap, side-effect-free check for a usable in-process session: a *connected*
|
|
115
|
+
* SDK and a current session that is active and hasn't expired. Durable adapters
|
|
116
|
+
* call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
117
|
+
* key), so the common warm-process path stays free. Note: unlike
|
|
118
|
+
* `requireActiveSession`, this does NOT refresh the keepAlive window — it only
|
|
119
|
+
* reports liveness.
|
|
120
|
+
*
|
|
121
|
+
* Both extra conditions matter, and neither used to be checked:
|
|
122
|
+
*
|
|
123
|
+
* - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
|
|
124
|
+
* is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
|
|
125
|
+
* accepted an SDK whose `connect()` was still in flight, so a caller could be
|
|
126
|
+
* told the session was live and then have the very next action throw "SDK is
|
|
127
|
+
* not connected. Call connect() first."
|
|
128
|
+
* - `status === "active"` — `isSessionValid()` deliberately accepts
|
|
129
|
+
* `"initializing"` (it only rejects `expired`/`error`), so a session that was
|
|
130
|
+
* created but never finished connecting counted as usable.
|
|
118
131
|
*/
|
|
119
132
|
export function hasLiveSession() {
|
|
120
133
|
const c = ctx();
|
|
121
134
|
const session = c.sessions.getCurrentSession();
|
|
122
|
-
|
|
135
|
+
if (!sdkIsConnected(c.sdk) || !session)
|
|
136
|
+
return false;
|
|
137
|
+
return session.status === "active" && c.sessions.isSessionValid(session.sessionId);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Drop the in-process session and SDK handle after the caller has released the
|
|
141
|
+
* sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
|
|
142
|
+
*
|
|
143
|
+
* Without this, ending a session left the context claiming a live one: `close()`
|
|
144
|
+
* tears down the sandbox's Ably client but never clears the SDK's own `connected`
|
|
145
|
+
* flag, and the session stayed `active` in the registry pointing at a sandbox that
|
|
146
|
+
* no longer exists — so `hasLiveSession()` answered true and `session_status`
|
|
147
|
+
* reported an active session for a dead VM.
|
|
148
|
+
*
|
|
149
|
+
* Bumping the epoch is the important part: any build still in flight (an abandoned
|
|
150
|
+
* action's reconnect) now loses its publish and closes the sandbox it opened,
|
|
151
|
+
* instead of resurrecting a session the caller explicitly ended — which would
|
|
152
|
+
* silently re-take a team concurrency slot.
|
|
153
|
+
*/
|
|
154
|
+
export function clearSession() {
|
|
155
|
+
const c = ctx();
|
|
156
|
+
const current = c.sessions.getCurrentSession();
|
|
157
|
+
if (current)
|
|
158
|
+
c.sessions.endSession(current.sessionId);
|
|
159
|
+
c.sdk = null;
|
|
160
|
+
c.lastScreenshotBase64 = null;
|
|
161
|
+
c.elementRefs.clear();
|
|
162
|
+
c.sdkGeneration++;
|
|
123
163
|
}
|
|
124
164
|
// =============================================================================
|
|
125
165
|
// Helpers
|
|
@@ -177,12 +217,68 @@ function bareBase64(s) {
|
|
|
177
217
|
*/
|
|
178
218
|
function realtimeIsHealthy(s) {
|
|
179
219
|
try {
|
|
180
|
-
return s?.sandbox?._ably?.connection?.state === "connected";
|
|
220
|
+
return sdkIsConnected(s) && s?.sandbox?._ably?.connection?.state === "connected";
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether `s` is an SDK that has finished connecting.
|
|
228
|
+
*
|
|
229
|
+
* `connected` flips to true only at the end of `connect()` and is exactly what
|
|
230
|
+
* the SDK's own `_ensureConnected()` guards every command on, so it is the one
|
|
231
|
+
* honest answer to "can I use this handle". Checking only for the *object* let a
|
|
232
|
+
* still-connecting SDK pass as usable. Optional-chained and defensive: an
|
|
233
|
+
* unusable handle must read as not-connected rather than throw.
|
|
234
|
+
*/
|
|
235
|
+
function sdkIsConnected(s) {
|
|
236
|
+
try {
|
|
237
|
+
return s?.connected === true;
|
|
181
238
|
}
|
|
182
239
|
catch {
|
|
183
240
|
return false;
|
|
184
241
|
}
|
|
185
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Close a sandbox we built but are not going to publish (we lost the epoch race).
|
|
245
|
+
* Best-effort, but worth awaiting: `close()` publishes `end-session` and leaves
|
|
246
|
+
* presence, which releases the team concurrency slot immediately instead of
|
|
247
|
+
* letting the orphan pin it for the full keepAlive window.
|
|
248
|
+
*/
|
|
249
|
+
async function discardSdk(sdk) {
|
|
250
|
+
try {
|
|
251
|
+
await sdk?.sandbox?.close?.();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* best effort — the server reaps the orphan on lease expiry regardless */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Publish `sdk` as the context's handle, unless a newer flow superseded us while
|
|
259
|
+
* we were connecting. Returns false when superseded, having closed the sandbox we
|
|
260
|
+
* built so it can't linger as an orphan holding a concurrency slot.
|
|
261
|
+
*
|
|
262
|
+
* Call this immediately after `connect()` resolves and before touching `c.sdk`.
|
|
263
|
+
*/
|
|
264
|
+
async function publishSdk(c, sdk, generation) {
|
|
265
|
+
if (c.sdkGeneration !== generation) {
|
|
266
|
+
await discardSdk(sdk);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
c.sdk = sdk;
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
/** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
|
|
273
|
+
function supersededResult() {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
text: "This session_start was superseded by a newer one that is already connected. " +
|
|
277
|
+
"The sandbox this call provisioned has been released. Use the active session, " +
|
|
278
|
+
"or call session_end and then session_start if you need a fresh sandbox.",
|
|
279
|
+
data: { action: "session_start", error: "SESSION_SUPERSEDED" },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
186
282
|
/**
|
|
187
283
|
* Attempt to rebuild the singleton from the adapter-registered recovery hook.
|
|
188
284
|
* Returns true if a usable session now exists, false if there was nothing to
|
|
@@ -272,10 +368,17 @@ async function requireActiveSession() {
|
|
|
272
368
|
// Reset the keepAlive timer on each command so active use doesn't expire.
|
|
273
369
|
c.sessions.refreshSession(current.sessionId);
|
|
274
370
|
}
|
|
275
|
-
/**
|
|
276
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Capture a fresh full-screen screenshot as bare base64 (or null on failure).
|
|
373
|
+
*
|
|
374
|
+
* Pass an explicit `sdk` from any flow that owns a local handle (`sessionStart`):
|
|
375
|
+
* reading `ctx().sdk` there would let a concurrent rebuild swap the instance out
|
|
376
|
+
* between provisioning and the screenshot. Actions that have already been through
|
|
377
|
+
* `requireActiveSession()` can omit it — the context's SDK is the right one.
|
|
378
|
+
*/
|
|
379
|
+
async function captureScreen(sdk = ctx().sdk) {
|
|
277
380
|
try {
|
|
278
|
-
const b64 = await
|
|
381
|
+
const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
279
382
|
return b64 || null;
|
|
280
383
|
}
|
|
281
384
|
catch {
|
|
@@ -299,11 +402,13 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
299
402
|
return { ok: false, text: "electron type requires 'appPath' parameter", data: { error: "Missing required parameter: appPath" } };
|
|
300
403
|
}
|
|
301
404
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
405
|
+
// NOTE: the session is deliberately NOT created here. `createSession()` marks
|
|
406
|
+
// the new session *current* immediately, with status "initializing" — and
|
|
407
|
+
// `isSessionValid()` accepts that status — so creating it before `connect()`
|
|
408
|
+
// published a half-real session to every concurrent reader, and a `connect()`
|
|
409
|
+
// that threw or stalled (e.g. polling for a free concurrency slot) left it
|
|
410
|
+
// current forever. That is the `session_status: initializing` that never
|
|
411
|
+
// resolves. We create and activate it below, only once we have a live sandbox.
|
|
307
412
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
308
413
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
309
414
|
const instanceIp = params.ip || process.env.TD_IP;
|
|
@@ -323,8 +428,16 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
323
428
|
},
|
|
324
429
|
};
|
|
325
430
|
}
|
|
431
|
+
// Build into a LOCAL handle and publish to `c.sdk` only once `connect()` has
|
|
432
|
+
// resolved. Assigning `c.sdk` up front exposed an SDK whose `connected` flag is
|
|
433
|
+
// still false to every concurrent reader, and — because each step below used to
|
|
434
|
+
// re-read `c.sdk` — let a concurrent rebuild swap the instance out *between*
|
|
435
|
+
// connect and provision, so provisioning ran against a handle that had never
|
|
436
|
+
// been connected and threw "SDK is not connected. Call connect() first."
|
|
437
|
+
// Everything below uses `sdk`; `c.sdk` is written exactly once, at publish.
|
|
438
|
+
const generation = ++c.sdkGeneration;
|
|
326
439
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
327
|
-
|
|
440
|
+
const sdk = new TestDriverSDK(apiKey, {
|
|
328
441
|
os: resolved.os,
|
|
329
442
|
logging: false,
|
|
330
443
|
apiRoot,
|
|
@@ -335,11 +448,18 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
335
448
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
336
449
|
if (params.sandboxId) {
|
|
337
450
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
338
|
-
await
|
|
339
|
-
|
|
451
|
+
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
452
|
+
if (!(await publishSdk(c, sdk, generation)))
|
|
453
|
+
return supersededResult();
|
|
454
|
+
const instance = sdk.getInstance();
|
|
455
|
+
const newSession = c.sessions.createSession({
|
|
456
|
+
os: resolved.os,
|
|
457
|
+
keepAlive: params.keepAlive,
|
|
458
|
+
testFile: params.testFile,
|
|
459
|
+
});
|
|
340
460
|
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
|
|
341
461
|
progress("Capturing screenshot...");
|
|
342
|
-
const shot = await captureScreen();
|
|
462
|
+
const shot = await captureScreen(sdk);
|
|
343
463
|
if (shot)
|
|
344
464
|
c.lastScreenshotBase64 = shot;
|
|
345
465
|
return {
|
|
@@ -351,36 +471,43 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
351
471
|
};
|
|
352
472
|
}
|
|
353
473
|
progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
|
|
354
|
-
await
|
|
355
|
-
|
|
474
|
+
await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
475
|
+
if (!(await publishSdk(c, sdk, generation)))
|
|
476
|
+
return supersededResult();
|
|
477
|
+
const instance = sdk.getInstance();
|
|
478
|
+
const newSession = c.sessions.createSession({
|
|
479
|
+
os: resolved.os,
|
|
480
|
+
keepAlive: params.keepAlive,
|
|
481
|
+
testFile: params.testFile,
|
|
482
|
+
});
|
|
356
483
|
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
|
|
357
484
|
const provisionOptions = getProvisionOptions(params);
|
|
358
485
|
let provisionCmd = "";
|
|
359
486
|
progress(`Provisioning ${params.type}...`);
|
|
360
487
|
switch (params.type) {
|
|
361
488
|
case "chrome":
|
|
362
|
-
await
|
|
489
|
+
await sdk.provision.chrome(provisionOptions);
|
|
363
490
|
provisionCmd = "provision.chrome";
|
|
364
491
|
break;
|
|
365
492
|
case "chromeExtension":
|
|
366
|
-
await
|
|
493
|
+
await sdk.provision.chromeExtension(provisionOptions);
|
|
367
494
|
provisionCmd = "provision.chromeExtension";
|
|
368
495
|
break;
|
|
369
496
|
case "vscode":
|
|
370
|
-
await
|
|
497
|
+
await sdk.provision.vscode(provisionOptions);
|
|
371
498
|
provisionCmd = "provision.vscode";
|
|
372
499
|
break;
|
|
373
500
|
case "installer":
|
|
374
|
-
await
|
|
501
|
+
await sdk.provision.installer(provisionOptions);
|
|
375
502
|
provisionCmd = "provision.installer";
|
|
376
503
|
break;
|
|
377
504
|
case "electron":
|
|
378
|
-
await
|
|
505
|
+
await sdk.provision.electron(provisionOptions);
|
|
379
506
|
provisionCmd = "provision.electron";
|
|
380
507
|
break;
|
|
381
508
|
}
|
|
382
509
|
progress("Capturing screenshot...");
|
|
383
|
-
const shot = await captureScreen();
|
|
510
|
+
const shot = await captureScreen(sdk);
|
|
384
511
|
if (shot)
|
|
385
512
|
c.lastScreenshotBase64 = shot;
|
|
386
513
|
const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
|
|
@@ -422,13 +549,14 @@ export async function reconnectSession(params) {
|
|
|
422
549
|
const c = ctx();
|
|
423
550
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
424
551
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
552
|
+
// Local handle + epoch, published only after connect() — see sessionStart and
|
|
553
|
+
// CoreContext.sdkGeneration. This path is the one an abandoned action reaches
|
|
554
|
+
// (eve stops waiting on a slow action but cannot cancel it, so the zombie keeps
|
|
555
|
+
// running and calls in here), which is exactly how a stale rebuild used to land
|
|
556
|
+
// on top of a newer, healthy SDK.
|
|
557
|
+
const generation = ++c.sdkGeneration;
|
|
430
558
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
431
|
-
|
|
559
|
+
const sdk = new TestDriverSDK(params.apiKey, {
|
|
432
560
|
os: params.os,
|
|
433
561
|
logging: false,
|
|
434
562
|
apiRoot,
|
|
@@ -436,8 +564,23 @@ export async function reconnectSession(params) {
|
|
|
436
564
|
ip: params.ip,
|
|
437
565
|
e2bTemplateId: params.e2bTemplateId,
|
|
438
566
|
});
|
|
439
|
-
await
|
|
440
|
-
|
|
567
|
+
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
568
|
+
// Superseded: a newer rebuild already published a live SDK. Release the sandbox
|
|
569
|
+
// we just attached (`publishSdk` closes it) and hand back the winner's session
|
|
570
|
+
// rather than clobbering it. The session is only created on the winning path, so
|
|
571
|
+
// a loser never leaves a stray "initializing" session current.
|
|
572
|
+
if (!(await publishSdk(c, sdk, generation))) {
|
|
573
|
+
const current = c.sessions.getCurrentSession();
|
|
574
|
+
if (current && c.sessions.isSessionValid(current.sessionId))
|
|
575
|
+
return current.sessionId;
|
|
576
|
+
throw new NoActiveSessionError("SESSION_EXPIRED", "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session.");
|
|
577
|
+
}
|
|
578
|
+
const session = c.sessions.createSession({
|
|
579
|
+
os: params.os,
|
|
580
|
+
keepAlive: params.keepAlive,
|
|
581
|
+
testFile: params.testFile,
|
|
582
|
+
});
|
|
583
|
+
const instance = sdk.getInstance();
|
|
441
584
|
c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
|
|
442
585
|
return session.sessionId;
|
|
443
586
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the session-liveness predicate.
|
|
3
|
+
*
|
|
4
|
+
* Regression cover for the "SDK is not connected. Call connect() first." cascade:
|
|
5
|
+
* `hasLiveSession()` used to answer `!!c.sdk && session valid`, which accepted
|
|
6
|
+
* - an SDK whose `connect()` was still in flight (`connected === false`), and
|
|
7
|
+
* - a session still in its `initializing` status (`isSessionValid()` only
|
|
8
|
+
* rejects `expired`/`error`).
|
|
9
|
+
*
|
|
10
|
+
* Both are reachable whenever a rebuild is racing an in-flight action, and either
|
|
11
|
+
* one made eve's `session_start` take its warm no-op path over a connection that
|
|
12
|
+
* could not serve a single command.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import { clearSession, createIsolatedContext, hasLiveSession, runInContext, type CoreContext } from "./actions.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** A context holding `sdk`, with a session that is active unless told otherwise. */
|
|
23
|
+
function contextWith(sdk: unknown, opts: { activate?: boolean } = {}): CoreContext {
|
|
24
|
+
const c = createIsolatedContext();
|
|
25
|
+
c.sdk = sdk;
|
|
26
|
+
const session = c.sessions.createSession({ os: "linux", keepAlive: 60_000 });
|
|
27
|
+
if (opts.activate !== false) c.sessions.activateSession(session.sessionId, "sb-test");
|
|
28
|
+
return c;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const live = (c: CoreContext) => runInContext(c, () => hasLiveSession());
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe("hasLiveSession", () => {
|
|
38
|
+
it("accepts a connected SDK on an active session", () => {
|
|
39
|
+
expect(live(contextWith({ connected: true }))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects an SDK whose connect() has not resolved yet", () => {
|
|
43
|
+
// The half-built handle: the object exists, so the old `!!c.sdk` said "live",
|
|
44
|
+
// but every command would throw from _ensureConnected().
|
|
45
|
+
expect(live(contextWith({ connected: false }))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects a session that was created but never activated", () => {
|
|
49
|
+
// status stays "initializing" — which isSessionValid() deliberately accepts.
|
|
50
|
+
expect(live(contextWith({ connected: true }, { activate: false }))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects when there is no SDK at all", () => {
|
|
54
|
+
expect(live(contextWith(null))).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects an expired session even with a connected SDK", () => {
|
|
58
|
+
const c = createIsolatedContext();
|
|
59
|
+
c.sdk = { connected: true };
|
|
60
|
+
const session = c.sessions.createSession({ os: "linux", keepAlive: -1 });
|
|
61
|
+
c.sessions.activateSession(session.sessionId, "sb-test");
|
|
62
|
+
expect(live(c)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not throw on a hostile/exotic sdk handle", () => {
|
|
66
|
+
// The predicate must fail safe (unusable → "not live"), never propagate.
|
|
67
|
+
const hostile = {
|
|
68
|
+
get connected(): boolean {
|
|
69
|
+
throw new Error("boom");
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
expect(() => live(contextWith(hostile))).not.toThrow();
|
|
73
|
+
expect(live(contextWith(hostile))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("clearSession", () => {
|
|
78
|
+
it("stops reporting a live session once the sandbox is released", () => {
|
|
79
|
+
// session_end's shape: sandbox.close() leaves sdk.connected true and the
|
|
80
|
+
// session 'active', so only clearSession() can make the context honest.
|
|
81
|
+
const c = contextWith({ connected: true });
|
|
82
|
+
expect(live(c)).toBe(true);
|
|
83
|
+
|
|
84
|
+
runInContext(c, () => clearSession());
|
|
85
|
+
|
|
86
|
+
expect(live(c)).toBe(false);
|
|
87
|
+
expect(c.sdk).toBeNull();
|
|
88
|
+
expect(c.sessions.getCurrentSession()).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("bumps the epoch so an in-flight build can no longer publish", () => {
|
|
92
|
+
const c = contextWith({ connected: true });
|
|
93
|
+
const before = c.sdkGeneration;
|
|
94
|
+
runInContext(c, () => clearSession());
|
|
95
|
+
expect(c.sdkGeneration).toBeGreaterThan(before);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is safe to call with no session at all", () => {
|
|
99
|
+
const c = createIsolatedContext();
|
|
100
|
+
expect(() => runInContext(c, () => clearSession())).not.toThrow();
|
|
101
|
+
expect(live(c)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("createIsolatedContext", () => {
|
|
106
|
+
it("starts at generation 0 so the first build wins its epoch", () => {
|
|
107
|
+
expect(createIsolatedContext().sdkGeneration).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("gives each context its own SessionManager", () => {
|
|
111
|
+
const a = createIsolatedContext();
|
|
112
|
+
const b = createIsolatedContext();
|
|
113
|
+
a.sessions.createSession({ os: "linux", keepAlive: 60_000 });
|
|
114
|
+
expect(a.sessions.getCurrentSession()).not.toBeNull();
|
|
115
|
+
expect(b.sessions.getCurrentSession()).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -81,7 +81,29 @@ export class NoActiveSessionError extends Error {
|
|
|
81
81
|
* per async call via {@link als}.
|
|
82
82
|
*/
|
|
83
83
|
export interface CoreContext {
|
|
84
|
+
/**
|
|
85
|
+
* The live SDK handle. Only ever assigned once its `connect()` has resolved —
|
|
86
|
+
* see {@link sdkGeneration}. Readers may assume `sdk.connected === true`.
|
|
87
|
+
*/
|
|
84
88
|
sdk: any;
|
|
89
|
+
/**
|
|
90
|
+
* Monotonic epoch, bumped by every flow that starts building a new SDK
|
|
91
|
+
* (`sessionStart` / `reconnectSession`).
|
|
92
|
+
*
|
|
93
|
+
* Why: a flow can be abandoned while its `connect()` is still in flight — eve's
|
|
94
|
+
* per-action deadline explicitly stops waiting on the SDK promise but cannot
|
|
95
|
+
* cancel it, so the abandoned action keeps running and races the retry that
|
|
96
|
+
* replaced it. Both then reach for `c.sdk`. Without an epoch the loser lands
|
|
97
|
+
* last and installs its (older, or half-built) SDK over the winner's, and every
|
|
98
|
+
* subsequent action fails against a connection nobody is maintaining.
|
|
99
|
+
*
|
|
100
|
+
* Each flow captures the epoch before it builds, and publishes only if the
|
|
101
|
+
* epoch is still its own. The loser closes the sandbox it built instead of
|
|
102
|
+
* leaking it — an orphaned sandbox pins a team concurrency slot for the whole
|
|
103
|
+
* keepAlive window (15 min for eve), which is what eventually starves the team
|
|
104
|
+
* and turns every later `connect()` into a slot-denial poll.
|
|
105
|
+
*/
|
|
106
|
+
sdkGeneration: number;
|
|
85
107
|
lastScreenshotBase64: string | null;
|
|
86
108
|
reconnectResolver: ReconnectResolver | null;
|
|
87
109
|
/** In-flight reconnect, so concurrent actions in one context share one rebuild. */
|
|
@@ -106,6 +128,7 @@ export interface CoreContext {
|
|
|
106
128
|
export function createCoreContext(): CoreContext {
|
|
107
129
|
return {
|
|
108
130
|
sdk: null,
|
|
131
|
+
sdkGeneration: 0,
|
|
109
132
|
lastScreenshotBase64: null,
|
|
110
133
|
reconnectResolver: null,
|
|
111
134
|
reconnecting: null,
|
|
@@ -210,16 +233,54 @@ export function getElementRef(ref: string) {
|
|
|
210
233
|
}
|
|
211
234
|
|
|
212
235
|
/**
|
|
213
|
-
* Cheap, side-effect-free check for a usable in-process session: a
|
|
214
|
-
* a current session that hasn't expired. Durable adapters
|
|
215
|
-
* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
216
|
-
* common warm-process path stays free. Note: unlike
|
|
217
|
-
* does NOT refresh the keepAlive window — it only
|
|
236
|
+
* Cheap, side-effect-free check for a usable in-process session: a *connected*
|
|
237
|
+
* SDK and a current session that is active and hasn't expired. Durable adapters
|
|
238
|
+
* call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
|
|
239
|
+
* key), so the common warm-process path stays free. Note: unlike
|
|
240
|
+
* `requireActiveSession`, this does NOT refresh the keepAlive window — it only
|
|
241
|
+
* reports liveness.
|
|
242
|
+
*
|
|
243
|
+
* Both extra conditions matter, and neither used to be checked:
|
|
244
|
+
*
|
|
245
|
+
* - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
|
|
246
|
+
* is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
|
|
247
|
+
* accepted an SDK whose `connect()` was still in flight, so a caller could be
|
|
248
|
+
* told the session was live and then have the very next action throw "SDK is
|
|
249
|
+
* not connected. Call connect() first."
|
|
250
|
+
* - `status === "active"` — `isSessionValid()` deliberately accepts
|
|
251
|
+
* `"initializing"` (it only rejects `expired`/`error`), so a session that was
|
|
252
|
+
* created but never finished connecting counted as usable.
|
|
218
253
|
*/
|
|
219
254
|
export function hasLiveSession(): boolean {
|
|
220
255
|
const c = ctx();
|
|
221
256
|
const session = c.sessions.getCurrentSession();
|
|
222
|
-
|
|
257
|
+
if (!sdkIsConnected(c.sdk) || !session) return false;
|
|
258
|
+
return session.status === "active" && c.sessions.isSessionValid(session.sessionId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Drop the in-process session and SDK handle after the caller has released the
|
|
263
|
+
* sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
|
|
264
|
+
*
|
|
265
|
+
* Without this, ending a session left the context claiming a live one: `close()`
|
|
266
|
+
* tears down the sandbox's Ably client but never clears the SDK's own `connected`
|
|
267
|
+
* flag, and the session stayed `active` in the registry pointing at a sandbox that
|
|
268
|
+
* no longer exists — so `hasLiveSession()` answered true and `session_status`
|
|
269
|
+
* reported an active session for a dead VM.
|
|
270
|
+
*
|
|
271
|
+
* Bumping the epoch is the important part: any build still in flight (an abandoned
|
|
272
|
+
* action's reconnect) now loses its publish and closes the sandbox it opened,
|
|
273
|
+
* instead of resurrecting a session the caller explicitly ended — which would
|
|
274
|
+
* silently re-take a team concurrency slot.
|
|
275
|
+
*/
|
|
276
|
+
export function clearSession(): void {
|
|
277
|
+
const c = ctx();
|
|
278
|
+
const current = c.sessions.getCurrentSession();
|
|
279
|
+
if (current) c.sessions.endSession(current.sessionId);
|
|
280
|
+
c.sdk = null;
|
|
281
|
+
c.lastScreenshotBase64 = null;
|
|
282
|
+
c.elementRefs.clear();
|
|
283
|
+
c.sdkGeneration++;
|
|
223
284
|
}
|
|
224
285
|
|
|
225
286
|
// =============================================================================
|
|
@@ -281,12 +342,71 @@ function bareBase64(s: string): string {
|
|
|
281
342
|
*/
|
|
282
343
|
function realtimeIsHealthy(s: any): boolean {
|
|
283
344
|
try {
|
|
284
|
-
return s?.sandbox?._ably?.connection?.state === "connected";
|
|
345
|
+
return sdkIsConnected(s) && s?.sandbox?._ably?.connection?.state === "connected";
|
|
285
346
|
} catch {
|
|
286
347
|
return false;
|
|
287
348
|
}
|
|
288
349
|
}
|
|
289
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Whether `s` is an SDK that has finished connecting.
|
|
353
|
+
*
|
|
354
|
+
* `connected` flips to true only at the end of `connect()` and is exactly what
|
|
355
|
+
* the SDK's own `_ensureConnected()` guards every command on, so it is the one
|
|
356
|
+
* honest answer to "can I use this handle". Checking only for the *object* let a
|
|
357
|
+
* still-connecting SDK pass as usable. Optional-chained and defensive: an
|
|
358
|
+
* unusable handle must read as not-connected rather than throw.
|
|
359
|
+
*/
|
|
360
|
+
function sdkIsConnected(s: any): boolean {
|
|
361
|
+
try {
|
|
362
|
+
return s?.connected === true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Close a sandbox we built but are not going to publish (we lost the epoch race).
|
|
370
|
+
* Best-effort, but worth awaiting: `close()` publishes `end-session` and leaves
|
|
371
|
+
* presence, which releases the team concurrency slot immediately instead of
|
|
372
|
+
* letting the orphan pin it for the full keepAlive window.
|
|
373
|
+
*/
|
|
374
|
+
async function discardSdk(sdk: any): Promise<void> {
|
|
375
|
+
try {
|
|
376
|
+
await sdk?.sandbox?.close?.();
|
|
377
|
+
} catch {
|
|
378
|
+
/* best effort — the server reaps the orphan on lease expiry regardless */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Publish `sdk` as the context's handle, unless a newer flow superseded us while
|
|
384
|
+
* we were connecting. Returns false when superseded, having closed the sandbox we
|
|
385
|
+
* built so it can't linger as an orphan holding a concurrency slot.
|
|
386
|
+
*
|
|
387
|
+
* Call this immediately after `connect()` resolves and before touching `c.sdk`.
|
|
388
|
+
*/
|
|
389
|
+
async function publishSdk(c: CoreContext, sdk: any, generation: number): Promise<boolean> {
|
|
390
|
+
if (c.sdkGeneration !== generation) {
|
|
391
|
+
await discardSdk(sdk);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
c.sdk = sdk;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
|
|
399
|
+
function supersededResult(): ActionResult {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
text:
|
|
403
|
+
"This session_start was superseded by a newer one that is already connected. " +
|
|
404
|
+
"The sandbox this call provisioned has been released. Use the active session, " +
|
|
405
|
+
"or call session_end and then session_start if you need a fresh sandbox.",
|
|
406
|
+
data: { action: "session_start", error: "SESSION_SUPERSEDED" },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
290
410
|
/**
|
|
291
411
|
* Attempt to rebuild the singleton from the adapter-registered recovery hook.
|
|
292
412
|
* Returns true if a usable session now exists, false if there was nothing to
|
|
@@ -390,10 +510,17 @@ async function requireActiveSession(): Promise<void> {
|
|
|
390
510
|
c.sessions.refreshSession(current.sessionId);
|
|
391
511
|
}
|
|
392
512
|
|
|
393
|
-
/**
|
|
394
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Capture a fresh full-screen screenshot as bare base64 (or null on failure).
|
|
515
|
+
*
|
|
516
|
+
* Pass an explicit `sdk` from any flow that owns a local handle (`sessionStart`):
|
|
517
|
+
* reading `ctx().sdk` there would let a concurrent rebuild swap the instance out
|
|
518
|
+
* between provisioning and the screenshot. Actions that have already been through
|
|
519
|
+
* `requireActiveSession()` can omit it — the context's SDK is the right one.
|
|
520
|
+
*/
|
|
521
|
+
async function captureScreen(sdk: any = ctx().sdk): Promise<string | null> {
|
|
395
522
|
try {
|
|
396
|
-
const b64 = await
|
|
523
|
+
const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
397
524
|
return b64 || null;
|
|
398
525
|
} catch {
|
|
399
526
|
return null;
|
|
@@ -432,12 +559,13 @@ export async function sessionStart(
|
|
|
432
559
|
}
|
|
433
560
|
}
|
|
434
561
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
562
|
+
// NOTE: the session is deliberately NOT created here. `createSession()` marks
|
|
563
|
+
// the new session *current* immediately, with status "initializing" — and
|
|
564
|
+
// `isSessionValid()` accepts that status — so creating it before `connect()`
|
|
565
|
+
// published a half-real session to every concurrent reader, and a `connect()`
|
|
566
|
+
// that threw or stalled (e.g. polling for a free concurrency slot) left it
|
|
567
|
+
// current forever. That is the `session_status: initializing` that never
|
|
568
|
+
// resolves. We create and activate it below, only once we have a live sandbox.
|
|
441
569
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
442
570
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
443
571
|
const instanceIp = params.ip || process.env.TD_IP;
|
|
@@ -459,8 +587,16 @@ export async function sessionStart(
|
|
|
459
587
|
};
|
|
460
588
|
}
|
|
461
589
|
|
|
590
|
+
// Build into a LOCAL handle and publish to `c.sdk` only once `connect()` has
|
|
591
|
+
// resolved. Assigning `c.sdk` up front exposed an SDK whose `connected` flag is
|
|
592
|
+
// still false to every concurrent reader, and — because each step below used to
|
|
593
|
+
// re-read `c.sdk` — let a concurrent rebuild swap the instance out *between*
|
|
594
|
+
// connect and provision, so provisioning ran against a handle that had never
|
|
595
|
+
// been connected and threw "SDK is not connected. Call connect() first."
|
|
596
|
+
// Everything below uses `sdk`; `c.sdk` is written exactly once, at publish.
|
|
597
|
+
const generation = ++c.sdkGeneration;
|
|
462
598
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
463
|
-
|
|
599
|
+
const sdk = new TestDriverSDK(apiKey, {
|
|
464
600
|
os: resolved.os,
|
|
465
601
|
logging: false,
|
|
466
602
|
apiRoot,
|
|
@@ -472,12 +608,19 @@ export async function sessionStart(
|
|
|
472
608
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
473
609
|
if (params.sandboxId) {
|
|
474
610
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
475
|
-
await
|
|
476
|
-
|
|
611
|
+
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
612
|
+
if (!(await publishSdk(c, sdk, generation))) return supersededResult();
|
|
613
|
+
|
|
614
|
+
const instance = sdk.getInstance();
|
|
615
|
+
const newSession = c.sessions.createSession({
|
|
616
|
+
os: resolved.os,
|
|
617
|
+
keepAlive: params.keepAlive,
|
|
618
|
+
testFile: params.testFile,
|
|
619
|
+
});
|
|
477
620
|
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
|
|
478
621
|
|
|
479
622
|
progress("Capturing screenshot...");
|
|
480
|
-
const shot = await captureScreen();
|
|
623
|
+
const shot = await captureScreen(sdk);
|
|
481
624
|
if (shot) c.lastScreenshotBase64 = shot;
|
|
482
625
|
|
|
483
626
|
return {
|
|
@@ -490,9 +633,15 @@ export async function sessionStart(
|
|
|
490
633
|
}
|
|
491
634
|
|
|
492
635
|
progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
|
|
493
|
-
await
|
|
636
|
+
await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
637
|
+
if (!(await publishSdk(c, sdk, generation))) return supersededResult();
|
|
494
638
|
|
|
495
|
-
const instance =
|
|
639
|
+
const instance = sdk.getInstance();
|
|
640
|
+
const newSession = c.sessions.createSession({
|
|
641
|
+
os: resolved.os,
|
|
642
|
+
keepAlive: params.keepAlive,
|
|
643
|
+
testFile: params.testFile,
|
|
644
|
+
});
|
|
496
645
|
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
|
|
497
646
|
|
|
498
647
|
const provisionOptions = getProvisionOptions(params);
|
|
@@ -501,29 +650,29 @@ export async function sessionStart(
|
|
|
501
650
|
progress(`Provisioning ${params.type}...`);
|
|
502
651
|
switch (params.type) {
|
|
503
652
|
case "chrome":
|
|
504
|
-
await
|
|
653
|
+
await sdk.provision.chrome(provisionOptions);
|
|
505
654
|
provisionCmd = "provision.chrome";
|
|
506
655
|
break;
|
|
507
656
|
case "chromeExtension":
|
|
508
|
-
await
|
|
657
|
+
await sdk.provision.chromeExtension(provisionOptions);
|
|
509
658
|
provisionCmd = "provision.chromeExtension";
|
|
510
659
|
break;
|
|
511
660
|
case "vscode":
|
|
512
|
-
await
|
|
661
|
+
await sdk.provision.vscode(provisionOptions);
|
|
513
662
|
provisionCmd = "provision.vscode";
|
|
514
663
|
break;
|
|
515
664
|
case "installer":
|
|
516
|
-
await
|
|
665
|
+
await sdk.provision.installer(provisionOptions);
|
|
517
666
|
provisionCmd = "provision.installer";
|
|
518
667
|
break;
|
|
519
668
|
case "electron":
|
|
520
|
-
await
|
|
669
|
+
await sdk.provision.electron(provisionOptions);
|
|
521
670
|
provisionCmd = "provision.electron";
|
|
522
671
|
break;
|
|
523
672
|
}
|
|
524
673
|
|
|
525
674
|
progress("Capturing screenshot...");
|
|
526
|
-
const shot = await captureScreen();
|
|
675
|
+
const shot = await captureScreen(sdk);
|
|
527
676
|
if (shot) c.lastScreenshotBase64 = shot;
|
|
528
677
|
|
|
529
678
|
const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
|
|
@@ -590,14 +739,14 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
|
|
|
590
739
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
591
740
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
592
741
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
742
|
+
// Local handle + epoch, published only after connect() — see sessionStart and
|
|
743
|
+
// CoreContext.sdkGeneration. This path is the one an abandoned action reaches
|
|
744
|
+
// (eve stops waiting on a slow action but cannot cancel it, so the zombie keeps
|
|
745
|
+
// running and calls in here), which is exactly how a stale rebuild used to land
|
|
746
|
+
// on top of a newer, healthy SDK.
|
|
747
|
+
const generation = ++c.sdkGeneration;
|
|
599
748
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
600
|
-
|
|
749
|
+
const sdk = new TestDriverSDK(params.apiKey, {
|
|
601
750
|
os: params.os,
|
|
602
751
|
logging: false,
|
|
603
752
|
apiRoot,
|
|
@@ -606,8 +755,27 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
|
|
|
606
755
|
e2bTemplateId: params.e2bTemplateId,
|
|
607
756
|
});
|
|
608
757
|
|
|
609
|
-
await
|
|
610
|
-
|
|
758
|
+
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
759
|
+
|
|
760
|
+
// Superseded: a newer rebuild already published a live SDK. Release the sandbox
|
|
761
|
+
// we just attached (`publishSdk` closes it) and hand back the winner's session
|
|
762
|
+
// rather than clobbering it. The session is only created on the winning path, so
|
|
763
|
+
// a loser never leaves a stray "initializing" session current.
|
|
764
|
+
if (!(await publishSdk(c, sdk, generation))) {
|
|
765
|
+
const current = c.sessions.getCurrentSession();
|
|
766
|
+
if (current && c.sessions.isSessionValid(current.sessionId)) return current.sessionId;
|
|
767
|
+
throw new NoActiveSessionError(
|
|
768
|
+
"SESSION_EXPIRED",
|
|
769
|
+
"The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session."
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const session = c.sessions.createSession({
|
|
774
|
+
os: params.os,
|
|
775
|
+
keepAlive: params.keepAlive,
|
|
776
|
+
testFile: params.testFile,
|
|
777
|
+
});
|
|
778
|
+
const instance = sdk.getInstance();
|
|
611
779
|
c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
|
|
612
780
|
return session.sessionId;
|
|
613
781
|
}
|