@testdriverai/mcp 7.11.6-test → 7.11.7-canary
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/mcp-server/dist/core/actions.d.ts +68 -7
- package/mcp-server/dist/core/actions.js +153 -91
- package/mcp-server/dist/server.mjs +1102 -993
- package/mcp-server/src/core/actions.ts +204 -100
- package/mcp-server/src/server.ts +206 -63
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* mirroring how the stdio MCP server has always behaved.
|
|
19
19
|
*/
|
|
20
20
|
import { SessionStartInputSchema, type SessionStartInput } from "../provision-types.js";
|
|
21
|
-
import { type SessionState } from "../session.js";
|
|
21
|
+
import { SessionManager, type SessionState } from "../session.js";
|
|
22
22
|
export { SessionStartInputSchema, type SessionStartInput };
|
|
23
23
|
export interface ActionImage {
|
|
24
24
|
kind: "screenshot" | "cropped";
|
|
@@ -44,14 +44,75 @@ export declare class NoActiveSessionError extends Error {
|
|
|
44
44
|
constructor(code: "NO_SESSION" | "SESSION_EXPIRED", message: string, expiredSessionId?: string);
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* The mutable state a single sandbox session needs: the live SDK handle, the
|
|
48
|
+
* element-ref map from `find`/`findall`, the "last screenshot" `check` diffs
|
|
49
|
+
* against, the adapter recovery hook, the in-flight reconnect promise, and the
|
|
50
|
+
* session-lifecycle manager.
|
|
51
|
+
*
|
|
52
|
+
* This used to be a bag of module-level singletons ("single sandbox per
|
|
53
|
+
* process"). That is correct for hosts that ARE one-session-per-process — eve
|
|
54
|
+
* (each durable turn is its own recycled process) and the stdio MCP server — but
|
|
55
|
+
* the Streamable-HTTP MCP server is one long-lived process serving *concurrent*
|
|
56
|
+
* clients, where shared singletons let one client's tool call read another
|
|
57
|
+
* client's sandbox. So the state moves into {@link CoreContext} and is resolved
|
|
58
|
+
* per async call via {@link als}.
|
|
59
|
+
*/
|
|
60
|
+
export interface CoreContext {
|
|
61
|
+
sdk: any;
|
|
62
|
+
lastScreenshotBase64: string | null;
|
|
63
|
+
reconnectResolver: ReconnectResolver | null;
|
|
64
|
+
/** In-flight reconnect, so concurrent actions in one context share one rebuild. */
|
|
65
|
+
reconnecting: Promise<void> | null;
|
|
66
|
+
/** Stored element instances from `find`/`findall`, addressable by ref. */
|
|
67
|
+
elementRefs: Map<string, {
|
|
68
|
+
element: any;
|
|
69
|
+
description: string;
|
|
70
|
+
coords: {
|
|
71
|
+
x: number;
|
|
72
|
+
y: number;
|
|
73
|
+
centerX: number;
|
|
74
|
+
centerY: number;
|
|
75
|
+
};
|
|
76
|
+
}>;
|
|
77
|
+
/** Session lifecycle for THIS context. */
|
|
78
|
+
sessions: SessionManager;
|
|
79
|
+
/**
|
|
80
|
+
* Opaque per-connection scratch space for adapters (e.g. the MCP server's
|
|
81
|
+
* image store). mcp-core doesn't read this; it just guarantees each isolated
|
|
82
|
+
* context gets its own object, so adapter-side per-connection state rides along
|
|
83
|
+
* with the same AsyncLocalStorage isolation instead of needing a second one.
|
|
84
|
+
*/
|
|
85
|
+
adapter: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
/** Build a fresh, empty context. Callers that want isolation mint one per connection. */
|
|
88
|
+
export declare function createCoreContext(): CoreContext;
|
|
89
|
+
/**
|
|
90
|
+
* Run `fn` with `context` as the active {@link CoreContext}, isolating every
|
|
91
|
+
* action's state (sdk, element refs, session, recovery hook) for the duration.
|
|
92
|
+
* The HTTP MCP server wraps each connection's request handling in this so
|
|
93
|
+
* concurrent clients never share a sandbox. Hosts that don't call this keep
|
|
94
|
+
* using {@link globalContext} exactly as before.
|
|
95
|
+
*/
|
|
96
|
+
export declare function runInContext<T>(context: CoreContext, fn: () => T): T;
|
|
97
|
+
/** Build an isolated context whose SessionManager is its own (not the global). */
|
|
98
|
+
export declare function createIsolatedContext(): CoreContext;
|
|
99
|
+
/** The active context's adapter scratch space (see {@link CoreContext.adapter}). */
|
|
100
|
+
export declare function getAdapterState(): Record<string, unknown>;
|
|
101
|
+
/** The active context's current session (null when none). Adapters use this for
|
|
102
|
+
* read-only lookups (e.g. the MCP server's testFile/expiry) that must resolve
|
|
103
|
+
* the caller's own session, not a process-global one. */
|
|
104
|
+
export declare function getCurrentSession(): SessionState | null;
|
|
105
|
+
/** Time remaining (ms) on a session in the active context. */
|
|
106
|
+
export declare function getSessionTimeRemaining(sessionId: string): number;
|
|
107
|
+
/**
|
|
108
|
+
* Optional adapter-supplied recovery hook. When the SDK is missing or stale at
|
|
109
|
+
* action time, {@link requireActiveSession} calls this to obtain the params
|
|
110
|
+
* needed to rebuild the connection from the still-alive sandbox, then reconnects
|
|
111
|
+
* before throwing NO_SESSION.
|
|
51
112
|
*
|
|
52
113
|
* Why a hook instead of mcp-core owning the handle: the durable facts (sandbox
|
|
53
114
|
* id, config) and the API key must survive a host process recycle, but this
|
|
54
|
-
*
|
|
115
|
+
* context's state does NOT — it resets on the very recycle we're recovering
|
|
55
116
|
* from. So the *durable owner* (eve's per-session state) registers a resolver
|
|
56
117
|
* that reads its own durable store and re-resolves the key per call. Hosts with
|
|
57
118
|
* one long-lived process (stdio MCP server, CLI) register nothing: the resolver
|
|
@@ -60,7 +121,7 @@ export declare class NoActiveSessionError extends Error {
|
|
|
60
121
|
*
|
|
61
122
|
* The resolver returns `null` when it has nothing to recover from (no sandbox
|
|
62
123
|
* provisioned this session), in which case we fall through to the normal throw.
|
|
63
|
-
* It is re-registered each step by the adapter (
|
|
124
|
+
* It is re-registered each step by the adapter (context state resets on recycle),
|
|
64
125
|
* so a stale closure can't outlive the process it was bound to.
|
|
65
126
|
*/
|
|
66
127
|
export type ReconnectResolver = () => Promise<ReconnectParams | null>;
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* State is process-global and single-session by design: one sandbox at a time,
|
|
18
18
|
* mirroring how the stdio MCP server has always behaved.
|
|
19
19
|
*/
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
21
|
import { generateActionCode } from "../codegen.js";
|
|
21
22
|
import { getProvisionOptions, SessionStartInputSchema } from "../provision-types.js";
|
|
22
|
-
import { sessionManager } from "../session.js";
|
|
23
|
+
import { SessionManager, sessionManager as globalSessionManager } from "../session.js";
|
|
23
24
|
// Re-export the input contract so adapters (MCP server, eve tools) share one
|
|
24
25
|
// source of truth for the session_start schema instead of redeclaring it.
|
|
25
26
|
export { SessionStartInputSchema };
|
|
@@ -34,32 +35,79 @@ export class NoActiveSessionError extends Error {
|
|
|
34
35
|
this.expiredSessionId = expiredSessionId;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
/** Build a fresh, empty context. Callers that want isolation mint one per connection. */
|
|
39
|
+
export function createCoreContext() {
|
|
40
|
+
return {
|
|
41
|
+
sdk: null,
|
|
42
|
+
lastScreenshotBase64: null,
|
|
43
|
+
reconnectResolver: null,
|
|
44
|
+
reconnecting: null,
|
|
45
|
+
elementRefs: new Map(),
|
|
46
|
+
// Reuse the shared SessionManager for the global context so that consumers
|
|
47
|
+
// still importing `sessionManager` from ../session observe the same state;
|
|
48
|
+
// isolated contexts get their own instance.
|
|
49
|
+
sessions: globalSessionManager,
|
|
50
|
+
adapter: {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const als = new AsyncLocalStorage();
|
|
54
|
+
/**
|
|
55
|
+
* The fallback context used when no isolated context is active — i.e. for eve
|
|
56
|
+
* and the stdio MCP server, which are one-session-per-process and never call
|
|
57
|
+
* {@link runInContext}. Behavior for those hosts is byte-identical to the old
|
|
58
|
+
* module-global singletons.
|
|
59
|
+
*/
|
|
60
|
+
const globalContext = createCoreContext();
|
|
61
|
+
/** Resolve the active context: the ALS store if one is running, else the global. */
|
|
62
|
+
function ctx() {
|
|
63
|
+
return als.getStore() ?? globalContext;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run `fn` with `context` as the active {@link CoreContext}, isolating every
|
|
67
|
+
* action's state (sdk, element refs, session, recovery hook) for the duration.
|
|
68
|
+
* The HTTP MCP server wraps each connection's request handling in this so
|
|
69
|
+
* concurrent clients never share a sandbox. Hosts that don't call this keep
|
|
70
|
+
* using {@link globalContext} exactly as before.
|
|
71
|
+
*/
|
|
72
|
+
export function runInContext(context, fn) {
|
|
73
|
+
return als.run(context, fn);
|
|
74
|
+
}
|
|
75
|
+
/** Build an isolated context whose SessionManager is its own (not the global). */
|
|
76
|
+
export function createIsolatedContext() {
|
|
77
|
+
const c = createCoreContext();
|
|
78
|
+
c.sessions = new SessionManager();
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
/** The active context's adapter scratch space (see {@link CoreContext.adapter}). */
|
|
82
|
+
export function getAdapterState() {
|
|
83
|
+
return ctx().adapter;
|
|
84
|
+
}
|
|
85
|
+
/** The active context's current session (null when none). Adapters use this for
|
|
86
|
+
* read-only lookups (e.g. the MCP server's testFile/expiry) that must resolve
|
|
87
|
+
* the caller's own session, not a process-global one. */
|
|
88
|
+
export function getCurrentSession() {
|
|
89
|
+
return ctx().sessions.getCurrentSession();
|
|
90
|
+
}
|
|
91
|
+
/** Time remaining (ms) on a session in the active context. */
|
|
92
|
+
export function getSessionTimeRemaining(sessionId) {
|
|
93
|
+
return ctx().sessions.getTimeRemaining(sessionId);
|
|
94
|
+
}
|
|
43
95
|
/**
|
|
44
96
|
* Register (or clear, with `null`) the recovery hook. Durable adapters call this
|
|
45
97
|
* at the start of each step with a resolver bound to the current tool context.
|
|
46
98
|
*/
|
|
47
99
|
export function setReconnectResolver(resolver) {
|
|
48
|
-
reconnectResolver = resolver;
|
|
100
|
+
ctx().reconnectResolver = resolver;
|
|
49
101
|
}
|
|
50
|
-
/** In-flight reconnect, so concurrent actions in one step share one rebuild. */
|
|
51
|
-
let reconnecting = null;
|
|
52
|
-
/** Stored element instances from `find`/`findall`, addressable by ref. */
|
|
53
|
-
const elementRefs = new Map();
|
|
54
102
|
/** Expose internals the adapters legitimately need (read-only intent). */
|
|
55
103
|
export function getSdk() {
|
|
56
|
-
return sdk;
|
|
104
|
+
return ctx().sdk;
|
|
57
105
|
}
|
|
58
106
|
export function getLastScreenshotBase64() {
|
|
59
|
-
return lastScreenshotBase64;
|
|
107
|
+
return ctx().lastScreenshotBase64;
|
|
60
108
|
}
|
|
61
109
|
export function getElementRef(ref) {
|
|
62
|
-
return elementRefs.get(ref);
|
|
110
|
+
return ctx().elementRefs.get(ref);
|
|
63
111
|
}
|
|
64
112
|
/**
|
|
65
113
|
* Cheap, side-effect-free check for a usable in-process session: a live SDK and
|
|
@@ -69,8 +117,9 @@ export function getElementRef(ref) {
|
|
|
69
117
|
* does NOT refresh the keepAlive window — it only reports liveness.
|
|
70
118
|
*/
|
|
71
119
|
export function hasLiveSession() {
|
|
72
|
-
const
|
|
73
|
-
|
|
120
|
+
const c = ctx();
|
|
121
|
+
const session = c.sessions.getCurrentSession();
|
|
122
|
+
return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
|
|
74
123
|
}
|
|
75
124
|
// =============================================================================
|
|
76
125
|
// Helpers
|
|
@@ -144,38 +193,39 @@ function realtimeIsHealthy(s) {
|
|
|
144
193
|
* {@link reconnecting} so a parked socket isn't reconnected N times in parallel.
|
|
145
194
|
*/
|
|
146
195
|
async function tryRecoverSession() {
|
|
147
|
-
|
|
196
|
+
const c = ctx();
|
|
197
|
+
if (!c.reconnectResolver)
|
|
148
198
|
return false;
|
|
149
|
-
if (!reconnecting) {
|
|
150
|
-
const resolver = reconnectResolver;
|
|
151
|
-
reconnecting = (async () => {
|
|
199
|
+
if (!c.reconnecting) {
|
|
200
|
+
const resolver = c.reconnectResolver;
|
|
201
|
+
c.reconnecting = (async () => {
|
|
152
202
|
const params = await resolver();
|
|
153
203
|
if (!params?.sandboxId)
|
|
154
204
|
return; // nothing provisioned this session
|
|
155
205
|
// Close any half-dead socket on the outgoing SDK before replacing it, so a
|
|
156
206
|
// parked-then-rebuilt session doesn't leak an orphaned Ably connection.
|
|
157
207
|
try {
|
|
158
|
-
sdk?.sandbox?._ably?.close?.();
|
|
208
|
+
c.sdk?.sandbox?._ably?.close?.();
|
|
159
209
|
}
|
|
160
210
|
catch {
|
|
161
211
|
/* best effort — reconnectSession installs a fresh SDK regardless */
|
|
162
212
|
}
|
|
163
213
|
await reconnectSession(params);
|
|
164
214
|
})().finally(() => {
|
|
165
|
-
reconnecting = null;
|
|
215
|
+
c.reconnecting = null;
|
|
166
216
|
});
|
|
167
217
|
}
|
|
168
218
|
try {
|
|
169
|
-
await reconnecting;
|
|
219
|
+
await c.reconnecting;
|
|
170
220
|
}
|
|
171
221
|
catch (err) {
|
|
172
|
-
sdk = null;
|
|
222
|
+
c.sdk = null;
|
|
173
223
|
if (err instanceof NoActiveSessionError)
|
|
174
224
|
throw err;
|
|
175
225
|
throw new NoActiveSessionError("SESSION_EXPIRED", `Could not reconnect to the sandbox: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`);
|
|
176
226
|
}
|
|
177
|
-
const session =
|
|
178
|
-
return !!sdk && !!session &&
|
|
227
|
+
const session = c.sessions.getCurrentSession();
|
|
228
|
+
return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
|
|
179
229
|
}
|
|
180
230
|
/**
|
|
181
231
|
* Validate the active session and auto-extend it (active use keeps it alive),
|
|
@@ -188,17 +238,18 @@ async function tryRecoverSession() {
|
|
|
188
238
|
* to recover from.
|
|
189
239
|
*/
|
|
190
240
|
async function requireActiveSession() {
|
|
191
|
-
const
|
|
241
|
+
const c = ctx();
|
|
242
|
+
const session = c.sessions.getCurrentSession();
|
|
192
243
|
// Recoverable conditions:
|
|
193
|
-
// -
|
|
244
|
+
// - context state wiped by a host recycle (`!sdk || !session`), or
|
|
194
245
|
// - the SDK exists but its realtime socket died during a park
|
|
195
246
|
// (`!realtimeIsHealthy`). We only treat a dead socket as recoverable when a
|
|
196
247
|
// recovery hook is registered — i.e. a durable host (eve) that actually
|
|
197
248
|
// parks. Long-lived hosts (stdio MCP, CLI) register no resolver, so a
|
|
198
249
|
// transient in-process Ably blip is left to Ably's own auto-reconnect rather
|
|
199
|
-
// than forcing a full
|
|
200
|
-
const socketDead = !!sdk && !!session && !!reconnectResolver && !realtimeIsHealthy(sdk);
|
|
201
|
-
const needsRecovery = !sdk || !session || socketDead;
|
|
250
|
+
// than forcing a full rebuild — preserving their prior behavior.
|
|
251
|
+
const socketDead = !!c.sdk && !!session && !!c.reconnectResolver && !realtimeIsHealthy(c.sdk);
|
|
252
|
+
const needsRecovery = !c.sdk || !session || socketDead;
|
|
202
253
|
if (needsRecovery) {
|
|
203
254
|
const recovered = await tryRecoverSession();
|
|
204
255
|
if (!recovered) {
|
|
@@ -206,25 +257,25 @@ async function requireActiveSession() {
|
|
|
206
257
|
// If the socket was dead, don't send the action into the void — surface
|
|
207
258
|
// SESSION_EXPIRED so the agent re-provisions instead of hanging.
|
|
208
259
|
if (socketDead) {
|
|
209
|
-
sdk = null;
|
|
260
|
+
c.sdk = null;
|
|
210
261
|
throw new NoActiveSessionError("SESSION_EXPIRED", "The sandbox connection was lost and could not be restored. Call session_start again to create a new sandbox session.", session?.sessionId);
|
|
211
262
|
}
|
|
212
|
-
// Otherwise the
|
|
263
|
+
// Otherwise the SDK was simply absent — the standard NO_SESSION.
|
|
213
264
|
throw new NoActiveSessionError("NO_SESSION", "No active session. Call session_start first to create a sandbox before using any other tools.");
|
|
214
265
|
}
|
|
215
266
|
}
|
|
216
|
-
const current =
|
|
217
|
-
if (!current || !
|
|
218
|
-
sdk = null;
|
|
267
|
+
const current = c.sessions.getCurrentSession();
|
|
268
|
+
if (!current || !c.sessions.isSessionValid(current.sessionId)) {
|
|
269
|
+
c.sdk = null;
|
|
219
270
|
throw new NoActiveSessionError("SESSION_EXPIRED", "Session has expired or timed out. Call session_start again to create a new sandbox session.", current?.sessionId);
|
|
220
271
|
}
|
|
221
272
|
// Reset the keepAlive timer on each command so active use doesn't expire.
|
|
222
|
-
|
|
273
|
+
c.sessions.refreshSession(current.sessionId);
|
|
223
274
|
}
|
|
224
275
|
/** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
|
|
225
276
|
async function captureScreen() {
|
|
226
277
|
try {
|
|
227
|
-
const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
278
|
+
const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
|
|
228
279
|
return b64 || null;
|
|
229
280
|
}
|
|
230
281
|
catch {
|
|
@@ -237,6 +288,7 @@ async function captureScreen() {
|
|
|
237
288
|
* neutral result (including the initial screenshot and provision code).
|
|
238
289
|
*/
|
|
239
290
|
export async function sessionStart(params, resolved, hooks = {}) {
|
|
291
|
+
const c = ctx();
|
|
240
292
|
const progress = hooks.onProgress ?? (() => { });
|
|
241
293
|
// Validate required fields for specific provision types (unless reconnecting).
|
|
242
294
|
if (!params.sandboxId) {
|
|
@@ -247,7 +299,7 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
247
299
|
return { ok: false, text: "electron type requires 'appPath' parameter", data: { error: "Missing required parameter: appPath" } };
|
|
248
300
|
}
|
|
249
301
|
}
|
|
250
|
-
const newSession =
|
|
302
|
+
const newSession = c.sessions.createSession({
|
|
251
303
|
os: resolved.os,
|
|
252
304
|
keepAlive: params.keepAlive,
|
|
253
305
|
testFile: params.testFile,
|
|
@@ -272,7 +324,7 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
272
324
|
};
|
|
273
325
|
}
|
|
274
326
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
275
|
-
sdk = new TestDriverSDK(apiKey, {
|
|
327
|
+
c.sdk = new TestDriverSDK(apiKey, {
|
|
276
328
|
os: resolved.os,
|
|
277
329
|
logging: false,
|
|
278
330
|
apiRoot,
|
|
@@ -283,13 +335,13 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
283
335
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
284
336
|
if (params.sandboxId) {
|
|
285
337
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
286
|
-
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
287
|
-
const instance = sdk.getInstance();
|
|
288
|
-
|
|
338
|
+
await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
339
|
+
const instance = c.sdk.getInstance();
|
|
340
|
+
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
|
|
289
341
|
progress("Capturing screenshot...");
|
|
290
342
|
const shot = await captureScreen();
|
|
291
343
|
if (shot)
|
|
292
|
-
lastScreenshotBase64 = shot;
|
|
344
|
+
c.lastScreenshotBase64 = shot;
|
|
293
345
|
return {
|
|
294
346
|
ok: true,
|
|
295
347
|
text: `Connected to existing sandbox (debug mode)\nSession: ${newSession.sessionId}\nSandbox: ${params.sandboxId}\n\nUse find, click, type, etc. to interact.`,
|
|
@@ -299,38 +351,38 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
299
351
|
};
|
|
300
352
|
}
|
|
301
353
|
progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
|
|
302
|
-
await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
303
|
-
const instance = sdk.getInstance();
|
|
304
|
-
|
|
354
|
+
await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
355
|
+
const instance = c.sdk.getInstance();
|
|
356
|
+
c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
|
|
305
357
|
const provisionOptions = getProvisionOptions(params);
|
|
306
358
|
let provisionCmd = "";
|
|
307
359
|
progress(`Provisioning ${params.type}...`);
|
|
308
360
|
switch (params.type) {
|
|
309
361
|
case "chrome":
|
|
310
|
-
await sdk.provision.chrome(provisionOptions);
|
|
362
|
+
await c.sdk.provision.chrome(provisionOptions);
|
|
311
363
|
provisionCmd = "provision.chrome";
|
|
312
364
|
break;
|
|
313
365
|
case "chromeExtension":
|
|
314
|
-
await sdk.provision.chromeExtension(provisionOptions);
|
|
366
|
+
await c.sdk.provision.chromeExtension(provisionOptions);
|
|
315
367
|
provisionCmd = "provision.chromeExtension";
|
|
316
368
|
break;
|
|
317
369
|
case "vscode":
|
|
318
|
-
await sdk.provision.vscode(provisionOptions);
|
|
370
|
+
await c.sdk.provision.vscode(provisionOptions);
|
|
319
371
|
provisionCmd = "provision.vscode";
|
|
320
372
|
break;
|
|
321
373
|
case "installer":
|
|
322
|
-
await sdk.provision.installer(provisionOptions);
|
|
374
|
+
await c.sdk.provision.installer(provisionOptions);
|
|
323
375
|
provisionCmd = "provision.installer";
|
|
324
376
|
break;
|
|
325
377
|
case "electron":
|
|
326
|
-
await sdk.provision.electron(provisionOptions);
|
|
378
|
+
await c.sdk.provision.electron(provisionOptions);
|
|
327
379
|
provisionCmd = "provision.electron";
|
|
328
380
|
break;
|
|
329
381
|
}
|
|
330
382
|
progress("Capturing screenshot...");
|
|
331
383
|
const shot = await captureScreen();
|
|
332
384
|
if (shot)
|
|
333
|
-
lastScreenshotBase64 = shot;
|
|
385
|
+
c.lastScreenshotBase64 = shot;
|
|
334
386
|
const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
|
|
335
387
|
const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
|
|
336
388
|
return {
|
|
@@ -367,15 +419,16 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
367
419
|
* knows to call session_start again.
|
|
368
420
|
*/
|
|
369
421
|
export async function reconnectSession(params) {
|
|
422
|
+
const c = ctx();
|
|
370
423
|
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
371
424
|
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
372
|
-
const session =
|
|
425
|
+
const session = c.sessions.createSession({
|
|
373
426
|
os: params.os,
|
|
374
427
|
keepAlive: params.keepAlive,
|
|
375
428
|
testFile: params.testFile,
|
|
376
429
|
});
|
|
377
430
|
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
378
|
-
sdk = new TestDriverSDK(params.apiKey, {
|
|
431
|
+
c.sdk = new TestDriverSDK(params.apiKey, {
|
|
379
432
|
os: params.os,
|
|
380
433
|
logging: false,
|
|
381
434
|
apiRoot,
|
|
@@ -383,9 +436,9 @@ export async function reconnectSession(params) {
|
|
|
383
436
|
ip: params.ip,
|
|
384
437
|
e2bTemplateId: params.e2bTemplateId,
|
|
385
438
|
});
|
|
386
|
-
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
387
|
-
const instance = sdk.getInstance();
|
|
388
|
-
|
|
439
|
+
await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
440
|
+
const instance = c.sdk.getInstance();
|
|
441
|
+
c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
|
|
389
442
|
return session.sessionId;
|
|
390
443
|
}
|
|
391
444
|
/**
|
|
@@ -401,9 +454,10 @@ export async function reconnectSession(params) {
|
|
|
401
454
|
* them exactly as before.
|
|
402
455
|
*/
|
|
403
456
|
export async function ensureActiveSession(params) {
|
|
404
|
-
const
|
|
457
|
+
const c = ctx();
|
|
458
|
+
const session = c.sessions.getCurrentSession();
|
|
405
459
|
// Already have a live SDK + valid session — nothing to do.
|
|
406
|
-
if (sdk && session &&
|
|
460
|
+
if (c.sdk && session && c.sessions.isSessionValid(session.sessionId)) {
|
|
407
461
|
return false;
|
|
408
462
|
}
|
|
409
463
|
// No durable handle to recover from — leave the (missing/expired) state as-is
|
|
@@ -420,25 +474,26 @@ export async function ensureActiveSession(params) {
|
|
|
420
474
|
// The sandbox is gone (server killed it after grace/disconnect cap, or the
|
|
421
475
|
// id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
|
|
422
476
|
// agent to call session_start again, instead of a raw connect error.
|
|
423
|
-
sdk = null;
|
|
477
|
+
c.sdk = null;
|
|
424
478
|
throw new NoActiveSessionError("SESSION_EXPIRED", `Could not reconnect to sandbox ${params.sandboxId}: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`, params.sandboxId);
|
|
425
479
|
}
|
|
426
480
|
}
|
|
427
481
|
/** Disconnect the SDK (best-effort); used by adapters on cancel/teardown. */
|
|
428
482
|
export async function disconnect() {
|
|
429
483
|
try {
|
|
430
|
-
await sdk?.disconnect?.();
|
|
484
|
+
await ctx().sdk?.disconnect?.();
|
|
431
485
|
}
|
|
432
486
|
catch {
|
|
433
487
|
/* best effort */
|
|
434
488
|
}
|
|
435
489
|
}
|
|
436
490
|
export function sessionStatus() {
|
|
437
|
-
const
|
|
491
|
+
const c = ctx();
|
|
492
|
+
const session = c.sessions.getCurrentSession();
|
|
438
493
|
if (!session) {
|
|
439
494
|
return { ok: false, text: "No active session", data: { error: "No active session. Call session_start first." } };
|
|
440
495
|
}
|
|
441
|
-
const summary =
|
|
496
|
+
const summary = c.sessions.getSessionSummary(session.sessionId);
|
|
442
497
|
return {
|
|
443
498
|
ok: true,
|
|
444
499
|
text: `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`,
|
|
@@ -446,11 +501,12 @@ export function sessionStatus() {
|
|
|
446
501
|
};
|
|
447
502
|
}
|
|
448
503
|
export function sessionExtend(additionalMs) {
|
|
449
|
-
const
|
|
504
|
+
const c = ctx();
|
|
505
|
+
const session = c.sessions.getCurrentSession();
|
|
450
506
|
if (!session)
|
|
451
507
|
return { ok: false, text: "No active session", data: { action: "session_extend" } };
|
|
452
|
-
|
|
453
|
-
const newExpiry =
|
|
508
|
+
c.sessions.extendSession(session.sessionId, additionalMs);
|
|
509
|
+
const newExpiry = c.sessions.getTimeRemaining(session.sessionId);
|
|
454
510
|
return {
|
|
455
511
|
ok: true,
|
|
456
512
|
text: `Session extended by ${additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
|
|
@@ -462,12 +518,13 @@ export function sessionExtend(additionalMs) {
|
|
|
462
518
|
// =============================================================================
|
|
463
519
|
export async function find(description, timeout) {
|
|
464
520
|
await requireActiveSession();
|
|
465
|
-
const
|
|
521
|
+
const c = ctx();
|
|
522
|
+
const element = await c.sdk.find(description, timeout ? { timeout } : undefined);
|
|
466
523
|
const found = element.found();
|
|
467
524
|
const coords = element.getCoordinates();
|
|
468
525
|
const ref = `el-${Date.now()}`;
|
|
469
526
|
if (found && coords) {
|
|
470
|
-
elementRefs.set(ref, {
|
|
527
|
+
c.elementRefs.set(ref, {
|
|
471
528
|
element,
|
|
472
529
|
description,
|
|
473
530
|
coords: { x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY },
|
|
@@ -501,7 +558,8 @@ export async function find(description, timeout) {
|
|
|
501
558
|
}
|
|
502
559
|
export async function findAll(description, timeout) {
|
|
503
560
|
await requireActiveSession();
|
|
504
|
-
const
|
|
561
|
+
const c = ctx();
|
|
562
|
+
const elements = await c.sdk.findAll(description, timeout ? { timeout } : undefined);
|
|
505
563
|
const count = elements.length;
|
|
506
564
|
const refs = [];
|
|
507
565
|
const elementInfos = [];
|
|
@@ -511,7 +569,7 @@ export async function findAll(description, timeout) {
|
|
|
511
569
|
if (!coords)
|
|
512
570
|
continue;
|
|
513
571
|
const ref = `el-${Date.now()}-${i}`;
|
|
514
|
-
elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
|
|
572
|
+
c.elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
|
|
515
573
|
refs.push(ref);
|
|
516
574
|
elementInfos.push({ ref, x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY, confidence: el.confidence });
|
|
517
575
|
}
|
|
@@ -541,7 +599,8 @@ export async function findAll(description, timeout) {
|
|
|
541
599
|
// =============================================================================
|
|
542
600
|
async function actOnRef(ref, verb, action) {
|
|
543
601
|
await requireActiveSession();
|
|
544
|
-
const
|
|
602
|
+
const c = ctx();
|
|
603
|
+
const stored = c.elementRefs.get(ref);
|
|
545
604
|
if (!stored) {
|
|
546
605
|
return { ok: false, text: `Element reference "${ref}" not found. Use 'find' first to locate the element.`, data: { error: "Element reference not found" } };
|
|
547
606
|
}
|
|
@@ -556,7 +615,7 @@ async function actOnRef(ref, verb, action) {
|
|
|
556
615
|
await element.hover();
|
|
557
616
|
const shot = await captureScreen();
|
|
558
617
|
if (shot)
|
|
559
|
-
lastScreenshotBase64 = shot;
|
|
618
|
+
c.lastScreenshotBase64 = shot;
|
|
560
619
|
const data = leanResponse(element._response);
|
|
561
620
|
if (action === "hover") {
|
|
562
621
|
return {
|
|
@@ -583,7 +642,8 @@ export function hover(ref) {
|
|
|
583
642
|
}
|
|
584
643
|
export async function findAndClick(description, action = "click") {
|
|
585
644
|
await requireActiveSession();
|
|
586
|
-
const
|
|
645
|
+
const c = ctx();
|
|
646
|
+
const element = await c.sdk.find(description);
|
|
587
647
|
const found = element.found();
|
|
588
648
|
if (!found) {
|
|
589
649
|
const raw = element._response || {};
|
|
@@ -605,7 +665,7 @@ export async function findAndClick(description, action = "click") {
|
|
|
605
665
|
const coords = element.getCoordinates();
|
|
606
666
|
const ref = `el-${Date.now()}`;
|
|
607
667
|
if (coords)
|
|
608
|
-
elementRefs.set(ref, { element, description, coords });
|
|
668
|
+
c.elementRefs.set(ref, { element, description, coords });
|
|
609
669
|
if (action === "click")
|
|
610
670
|
await element.click();
|
|
611
671
|
else if (action === "double-click")
|
|
@@ -637,7 +697,7 @@ export async function findAndClick(description, action = "click") {
|
|
|
637
697
|
// =============================================================================
|
|
638
698
|
export async function type(text, secret = false, delay) {
|
|
639
699
|
await requireActiveSession();
|
|
640
|
-
await sdk.type(text, { secret, delay });
|
|
700
|
+
await ctx().sdk.type(text, { secret, delay });
|
|
641
701
|
return {
|
|
642
702
|
ok: true,
|
|
643
703
|
text: `Typed: ${secret ? "[secret text]" : `"${text}"`}`,
|
|
@@ -647,7 +707,7 @@ export async function type(text, secret = false, delay) {
|
|
|
647
707
|
}
|
|
648
708
|
export async function pressKeys(keys) {
|
|
649
709
|
await requireActiveSession();
|
|
650
|
-
await sdk.pressKeys(keys);
|
|
710
|
+
await ctx().sdk.pressKeys(keys);
|
|
651
711
|
return {
|
|
652
712
|
ok: true,
|
|
653
713
|
text: `Pressed keys: ${keys.join(" + ")}`,
|
|
@@ -657,7 +717,7 @@ export async function pressKeys(keys) {
|
|
|
657
717
|
}
|
|
658
718
|
export async function scroll(direction = "down", amount) {
|
|
659
719
|
await requireActiveSession();
|
|
660
|
-
await sdk.scroll(direction, amount ? { amount } : undefined);
|
|
720
|
+
await ctx().sdk.scroll(direction, amount ? { amount } : undefined);
|
|
661
721
|
return {
|
|
662
722
|
ok: true,
|
|
663
723
|
text: `Scrolled ${direction}${amount ? ` by ${amount}px` : ""}`,
|
|
@@ -667,7 +727,7 @@ export async function scroll(direction = "down", amount) {
|
|
|
667
727
|
}
|
|
668
728
|
export async function focusApplication(name) {
|
|
669
729
|
await requireActiveSession();
|
|
670
|
-
await sdk.focusApplication(name);
|
|
730
|
+
await ctx().sdk.focusApplication(name);
|
|
671
731
|
return {
|
|
672
732
|
ok: true,
|
|
673
733
|
text: `Focused application: "${name}"`,
|
|
@@ -677,7 +737,7 @@ export async function focusApplication(name) {
|
|
|
677
737
|
}
|
|
678
738
|
export async function wait(timeout) {
|
|
679
739
|
await requireActiveSession();
|
|
680
|
-
await sdk.wait(timeout);
|
|
740
|
+
await ctx().sdk.wait(timeout);
|
|
681
741
|
return {
|
|
682
742
|
ok: true,
|
|
683
743
|
text: `Waited for ${timeout}ms`,
|
|
@@ -687,7 +747,7 @@ export async function wait(timeout) {
|
|
|
687
747
|
}
|
|
688
748
|
export async function exec(language, code, timeout = 30000) {
|
|
689
749
|
await requireActiveSession();
|
|
690
|
-
const output = await sdk.exec(language, code, timeout);
|
|
750
|
+
const output = await ctx().sdk.exec(language, code, timeout);
|
|
691
751
|
return {
|
|
692
752
|
ok: true,
|
|
693
753
|
text: `Executed ${language} code:\n${output || "(no output)"}`,
|
|
@@ -700,7 +760,7 @@ export async function exec(language, code, timeout = 30000) {
|
|
|
700
760
|
// =============================================================================
|
|
701
761
|
export async function assert(assertion) {
|
|
702
762
|
await requireActiveSession();
|
|
703
|
-
const result = await sdk.assert(assertion);
|
|
763
|
+
const result = await ctx().sdk.assert(assertion);
|
|
704
764
|
return {
|
|
705
765
|
ok: result,
|
|
706
766
|
text: result ? `✓ Assertion passed: "${assertion}"` : `✗ Assertion failed: "${assertion}"`,
|
|
@@ -715,12 +775,13 @@ export async function assert(assertion) {
|
|
|
715
775
|
*/
|
|
716
776
|
export async function check(task, referenceImage) {
|
|
717
777
|
await requireActiveSession();
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
const
|
|
778
|
+
const c = ctx();
|
|
779
|
+
const currentScreenshot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
|
|
780
|
+
const beforeScreenshot = referenceImage || c.lastScreenshotBase64 || currentScreenshot;
|
|
781
|
+
c.lastScreenshotBase64 = currentScreenshot;
|
|
782
|
+
const mousePosition = await c.sdk.agent.system.getMousePosition();
|
|
783
|
+
const activeWindow = await c.sdk.agent.system.activeWin();
|
|
784
|
+
const response = await c.sdk.agent.sdk.req("check", {
|
|
724
785
|
tasks: [task],
|
|
725
786
|
images: [beforeScreenshot, currentScreenshot],
|
|
726
787
|
mousePosition,
|
|
@@ -741,9 +802,10 @@ export async function check(task, referenceImage) {
|
|
|
741
802
|
/** Capture a screenshot for the user (cursor visible). */
|
|
742
803
|
export async function screenshot() {
|
|
743
804
|
await requireActiveSession();
|
|
744
|
-
const
|
|
805
|
+
const c = ctx();
|
|
806
|
+
const shot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
|
|
745
807
|
if (shot)
|
|
746
|
-
lastScreenshotBase64 = shot;
|
|
808
|
+
c.lastScreenshotBase64 = shot;
|
|
747
809
|
return {
|
|
748
810
|
ok: !!shot,
|
|
749
811
|
text: shot ? "Captured screenshot" : "Failed to capture screenshot",
|