@testdriverai/mcp 7.11.12-test → 7.11.13-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.
- package/agent/build-env.test.mjs +146 -0
- package/agent/index.js +35 -4
- package/mcp-server/dist/core/actions.js +20 -2
- package/mcp-server/src/core/actions.ts +20 -2
- package/package.json +2 -1
- package/sdk.d.ts +10 -0
- package/sdk.js +4 -0
- package/vitest.unit.config.mjs +15 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression cover for the silent sandbox substitution in `buildEnv`.
|
|
3
|
+
*
|
|
4
|
+
* `connect({ sandboxId })` used to treat "attach to this sandbox" as a hint: if
|
|
5
|
+
* the reconnect threw, it swallowed the error and provisioned a brand-new
|
|
6
|
+
* sandbox in its place, then resolved as success. That is right for the CLI
|
|
7
|
+
* (`reconnect: true` means "reattach to my last sandbox, else make one") and
|
|
8
|
+
* catastrophic for a durable adapter.
|
|
9
|
+
*
|
|
10
|
+
* The eve agent parks between every tool call, which drops the realtime socket;
|
|
11
|
+
* on resume it reconnects by sandbox id. When that reconnect quietly forked a
|
|
12
|
+
* fresh sandbox, the replacement had never been through `provision.*` — those
|
|
13
|
+
* only run at session start, never on a reconnect — so the agent got a blank
|
|
14
|
+
* desktop reported as "reconnected, state preserved". It could not recover,
|
|
15
|
+
* either: the durable handle still held the dead id, so the next park forked
|
|
16
|
+
* another orphan, each one pinning a team concurrency slot until reaped.
|
|
17
|
+
*
|
|
18
|
+
* So: `requireSandbox` callers must see the failure, and the CLI's forgiving
|
|
19
|
+
* behavior must not change.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const TestDriverAgent = require("./index.js");
|
|
27
|
+
|
|
28
|
+
const NEW_SANDBOX_ID = "sb-brand-new-blank";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A minimal `this` for `buildEnv` — it only touches the collaborators stubbed
|
|
32
|
+
* here. Returns the fake plus a record of which provisioning path it took.
|
|
33
|
+
*/
|
|
34
|
+
function fakeAgent({ sandboxId, connectFails = false, CI = false }) {
|
|
35
|
+
const calls = { connectToSandboxDirect: 0, createNewSandbox: 0 };
|
|
36
|
+
const self = {
|
|
37
|
+
instance: null,
|
|
38
|
+
sandboxId,
|
|
39
|
+
newSandbox: false,
|
|
40
|
+
keepAlive: 900_000,
|
|
41
|
+
ip: null,
|
|
42
|
+
config: { CI, TD_RESOLUTION: [1366, 768] },
|
|
43
|
+
emitter: { emit() {} },
|
|
44
|
+
sandbox: { setConnectionParams() {}, async send() {} },
|
|
45
|
+
async newSession() {},
|
|
46
|
+
async connectToSandboxService() {},
|
|
47
|
+
async renderSandbox() {},
|
|
48
|
+
async runLifecycle() {},
|
|
49
|
+
async connectToSandboxDirect(id) {
|
|
50
|
+
calls.connectToSandboxDirect++;
|
|
51
|
+
if (connectFails) throw new Error("sandbox is gone (reaped)");
|
|
52
|
+
return { instanceId: id, sandboxId: id, url: "https://sandbox.example" };
|
|
53
|
+
},
|
|
54
|
+
async createNewSandbox() {
|
|
55
|
+
calls.createNewSandbox++;
|
|
56
|
+
return { sandbox: { sandboxId: NEW_SANDBOX_ID, url: "https://new.example" } };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
return { self, calls };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const buildEnv = (self, options) =>
|
|
63
|
+
TestDriverAgent.prototype.buildEnv.call(self, options);
|
|
64
|
+
|
|
65
|
+
describe("buildEnv({ requireSandbox: true })", () => {
|
|
66
|
+
it("throws instead of substituting a fresh sandbox when the target is gone", async () => {
|
|
67
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-dead", connectFails: true });
|
|
68
|
+
|
|
69
|
+
await expect(buildEnv(self, { requireSandbox: true })).rejects.toThrow(
|
|
70
|
+
/Failed to reconnect to sandbox sb-dead/,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// The whole point: no blank replacement was provisioned behind the caller's back.
|
|
74
|
+
expect(calls.createNewSandbox).toBe(0);
|
|
75
|
+
expect(self.instance).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves the underlying failure as the error cause", async () => {
|
|
79
|
+
const { self } = fakeAgent({ sandboxId: "sb-dead", connectFails: true });
|
|
80
|
+
|
|
81
|
+
const err = await buildEnv(self, { requireSandbox: true }).catch((e) => e);
|
|
82
|
+
|
|
83
|
+
expect(err.cause).toBeInstanceOf(Error);
|
|
84
|
+
expect(err.cause.message).toBe("sandbox is gone (reaped)");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("attaches to the requested sandbox when it is alive", async () => {
|
|
88
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-live" });
|
|
89
|
+
|
|
90
|
+
await buildEnv(self, { requireSandbox: true });
|
|
91
|
+
|
|
92
|
+
expect(self.instance.sandboxId).toBe("sb-live");
|
|
93
|
+
expect(calls.createNewSandbox).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reconnects even under CI, which otherwise forces a new sandbox", async () => {
|
|
97
|
+
// CI normally sets createNew and nulls sandboxId, which would skip the
|
|
98
|
+
// reconnect entirely and hand back a blank sandbox — the same failure by a
|
|
99
|
+
// different route. An explicit reconnect request has to win.
|
|
100
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-live", CI: true });
|
|
101
|
+
|
|
102
|
+
await buildEnv(self, { requireSandbox: true });
|
|
103
|
+
|
|
104
|
+
expect(self.instance.sandboxId).toBe("sb-live");
|
|
105
|
+
expect(calls.createNewSandbox).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects a strict reconnect with no sandbox to reconnect to", async () => {
|
|
109
|
+
const { self, calls } = fakeAgent({ sandboxId: null });
|
|
110
|
+
|
|
111
|
+
await expect(buildEnv(self, { requireSandbox: true })).rejects.toThrow(
|
|
112
|
+
/requires a sandboxId/,
|
|
113
|
+
);
|
|
114
|
+
expect(calls.createNewSandbox).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("buildEnv default (CLI reconnect-or-create) is unchanged", () => {
|
|
119
|
+
it("falls through to a new sandbox when the last one is gone", async () => {
|
|
120
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-dead", connectFails: true });
|
|
121
|
+
|
|
122
|
+
await buildEnv(self, {});
|
|
123
|
+
|
|
124
|
+
expect(calls.connectToSandboxDirect).toBe(1);
|
|
125
|
+
expect(calls.createNewSandbox).toBe(1);
|
|
126
|
+
expect(self.instance.sandboxId).toBe(NEW_SANDBOX_ID);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("reconnects when the last sandbox is alive", async () => {
|
|
130
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-live" });
|
|
131
|
+
|
|
132
|
+
await buildEnv(self, {});
|
|
133
|
+
|
|
134
|
+
expect(self.instance.sandboxId).toBe("sb-live");
|
|
135
|
+
expect(calls.createNewSandbox).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("creates a new sandbox under CI", async () => {
|
|
139
|
+
const { self, calls } = fakeAgent({ sandboxId: "sb-live", CI: true });
|
|
140
|
+
|
|
141
|
+
await buildEnv(self, {});
|
|
142
|
+
|
|
143
|
+
expect(calls.connectToSandboxDirect).toBe(0);
|
|
144
|
+
expect(calls.createNewSandbox).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
package/agent/index.js
CHANGED
|
@@ -1676,13 +1676,23 @@ ${regression}
|
|
|
1676
1676
|
|
|
1677
1677
|
let { headless = false, heal, new: createNew = false } = options;
|
|
1678
1678
|
|
|
1679
|
+
// The caller named a specific sandbox and cannot accept a substitute: attach
|
|
1680
|
+
// to THAT sandbox or fail. Durable adapters (the eve agent) reconnect to a
|
|
1681
|
+
// sandbox they already provisioned and whose state they depend on — the
|
|
1682
|
+
// browser/app is only launched once, by `provision.*` at session_start, and
|
|
1683
|
+
// is never re-run on a reconnect. Silently handing back a fresh sandbox
|
|
1684
|
+
// therefore yields a blank desktop that no later step can repair, while the
|
|
1685
|
+
// adapter reports "reconnected, state preserved". It must see the failure so
|
|
1686
|
+
// it can drop the dead handle and provision properly.
|
|
1687
|
+
const requireSandbox = options.requireSandbox === true;
|
|
1688
|
+
|
|
1679
1689
|
// Prioritize this.newSandbox flag if it's set
|
|
1680
|
-
if (this.newSandbox) {
|
|
1690
|
+
if (this.newSandbox && !requireSandbox) {
|
|
1681
1691
|
createNew = true;
|
|
1682
1692
|
}
|
|
1683
1693
|
|
|
1684
1694
|
// If CI environment variable is true, always create a new sandbox
|
|
1685
|
-
if (this.config.CI) {
|
|
1695
|
+
if (this.config.CI && !requireSandbox) {
|
|
1686
1696
|
createNew = true;
|
|
1687
1697
|
this.emitter.emit(
|
|
1688
1698
|
events.log.log,
|
|
@@ -1693,7 +1703,7 @@ ${regression}
|
|
|
1693
1703
|
if (heal) this.healMode = heal;
|
|
1694
1704
|
|
|
1695
1705
|
// If createNew flag is set, clear sandboxId to prevent reconnection attempts
|
|
1696
|
-
if (createNew) {
|
|
1706
|
+
if (createNew && !requireSandbox) {
|
|
1697
1707
|
this.sandboxId = null;
|
|
1698
1708
|
if (!this.config.CI && !this.newSandbox) {
|
|
1699
1709
|
this.emitter.emit(events.log.log, theme.dim("Creating a new sandbox"));
|
|
@@ -1702,6 +1712,15 @@ ${regression}
|
|
|
1702
1712
|
}
|
|
1703
1713
|
}
|
|
1704
1714
|
|
|
1715
|
+
// A strict reconnect with nothing to reconnect to is a caller bug, not a cue
|
|
1716
|
+
// to provision: fail here rather than below, where the miss would look like a
|
|
1717
|
+
// connect failure.
|
|
1718
|
+
if (requireSandbox && !this.sandboxId) {
|
|
1719
|
+
throw new Error(
|
|
1720
|
+
"buildEnv({ requireSandbox: true }) requires a sandboxId to reconnect to, but none was set.",
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1705
1724
|
// Create session first so session ID is available for Sentry tracing in WebSocket connection
|
|
1706
1725
|
await this.newSession();
|
|
1707
1726
|
|
|
@@ -1738,7 +1757,10 @@ ${regression}
|
|
|
1738
1757
|
await this.runLifecycle("provision");
|
|
1739
1758
|
|
|
1740
1759
|
return;
|
|
1741
|
-
} else if (
|
|
1760
|
+
} else if (
|
|
1761
|
+
this.sandboxId &&
|
|
1762
|
+
(requireSandbox || (!createNew && !this.config.CI))
|
|
1763
|
+
) {
|
|
1742
1764
|
// Only attempt to connect to existing sandbox if not in CI mode and not creating new
|
|
1743
1765
|
// Attempt to connect to known instance
|
|
1744
1766
|
this.emitter.emit(
|
|
@@ -1758,6 +1780,15 @@ ${regression}
|
|
|
1758
1780
|
await this.renderSandbox(instance, headless);
|
|
1759
1781
|
return;
|
|
1760
1782
|
} catch (error) {
|
|
1783
|
+
// Strict callers get the truth. Falling through here would give them a
|
|
1784
|
+
// brand-new sandbox that has never been through `provision.*` — a blank
|
|
1785
|
+
// desktop reported as a successful reconnect (see `requireSandbox`).
|
|
1786
|
+
if (requireSandbox) {
|
|
1787
|
+
throw new Error(
|
|
1788
|
+
`Failed to reconnect to sandbox ${this.sandboxId}: ${error?.message ?? error}`,
|
|
1789
|
+
{ cause: error },
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1761
1792
|
// If connection fails, fall through to creating a new sandbox
|
|
1762
1793
|
this.emitter.emit(
|
|
1763
1794
|
events.log.narration,
|
|
@@ -448,7 +448,14 @@ export async function sessionStart(params, resolved, hooks = {}) {
|
|
|
448
448
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
449
449
|
if (params.sandboxId) {
|
|
450
450
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
451
|
-
|
|
451
|
+
// Debug mode attaches to one specific sandbox in its current state, so a
|
|
452
|
+
// substitute is never acceptable here either — the whole point is to inspect
|
|
453
|
+
// THAT sandbox. Fail loudly if it's gone.
|
|
454
|
+
await sdk.connect({
|
|
455
|
+
sandboxId: params.sandboxId,
|
|
456
|
+
keepAlive: params.keepAlive,
|
|
457
|
+
requireSandbox: true,
|
|
458
|
+
});
|
|
452
459
|
if (!(await publishSdk(c, sdk, generation)))
|
|
453
460
|
return supersededResult();
|
|
454
461
|
const instance = sdk.getInstance();
|
|
@@ -564,7 +571,18 @@ export async function reconnectSession(params) {
|
|
|
564
571
|
ip: params.ip,
|
|
565
572
|
e2bTemplateId: params.e2bTemplateId,
|
|
566
573
|
});
|
|
567
|
-
|
|
574
|
+
// `requireSandbox` makes the SDK throw instead of quietly provisioning a
|
|
575
|
+
// replacement when this sandbox is gone. The replacement would never have been
|
|
576
|
+
// through `provision.*` (only `sessionStart` runs that, never this path), so a
|
|
577
|
+
// "successful" reconnect would hand the agent a blank desktop and durable
|
|
578
|
+
// callers would keep reconnecting to the same dead id, forking a new orphan —
|
|
579
|
+
// each one holding a concurrency slot — on every park. A throw here surfaces as
|
|
580
|
+
// SESSION_EXPIRED, which is what actually makes the agent re-provision.
|
|
581
|
+
await sdk.connect({
|
|
582
|
+
sandboxId: params.sandboxId,
|
|
583
|
+
keepAlive: params.keepAlive,
|
|
584
|
+
requireSandbox: true,
|
|
585
|
+
});
|
|
568
586
|
// Superseded: a newer rebuild already published a live SDK. Release the sandbox
|
|
569
587
|
// we just attached (`publishSdk` closes it) and hand back the winner's session
|
|
570
588
|
// rather than clobbering it. The session is only created on the winning path, so
|
|
@@ -608,7 +608,14 @@ export async function sessionStart(
|
|
|
608
608
|
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
609
609
|
if (params.sandboxId) {
|
|
610
610
|
progress(`Connecting to existing sandbox ${params.sandboxId}...`);
|
|
611
|
-
|
|
611
|
+
// Debug mode attaches to one specific sandbox in its current state, so a
|
|
612
|
+
// substitute is never acceptable here either — the whole point is to inspect
|
|
613
|
+
// THAT sandbox. Fail loudly if it's gone.
|
|
614
|
+
await sdk.connect({
|
|
615
|
+
sandboxId: params.sandboxId,
|
|
616
|
+
keepAlive: params.keepAlive,
|
|
617
|
+
requireSandbox: true,
|
|
618
|
+
});
|
|
612
619
|
if (!(await publishSdk(c, sdk, generation))) return supersededResult();
|
|
613
620
|
|
|
614
621
|
const instance = sdk.getInstance();
|
|
@@ -755,7 +762,18 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
|
|
|
755
762
|
e2bTemplateId: params.e2bTemplateId,
|
|
756
763
|
});
|
|
757
764
|
|
|
758
|
-
|
|
765
|
+
// `requireSandbox` makes the SDK throw instead of quietly provisioning a
|
|
766
|
+
// replacement when this sandbox is gone. The replacement would never have been
|
|
767
|
+
// through `provision.*` (only `sessionStart` runs that, never this path), so a
|
|
768
|
+
// "successful" reconnect would hand the agent a blank desktop and durable
|
|
769
|
+
// callers would keep reconnecting to the same dead id, forking a new orphan —
|
|
770
|
+
// each one holding a concurrency slot — on every park. A throw here surfaces as
|
|
771
|
+
// SESSION_EXPIRED, which is what actually makes the agent re-provision.
|
|
772
|
+
await sdk.connect({
|
|
773
|
+
sandboxId: params.sandboxId,
|
|
774
|
+
keepAlive: params.keepAlive,
|
|
775
|
+
requireSandbox: true,
|
|
776
|
+
});
|
|
759
777
|
|
|
760
778
|
// Superseded: a newer rebuild already published a live SDK. Release the sandbox
|
|
761
779
|
// we just attached (`publishSdk` closes it) and hand back the winner's session
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testdriverai/mcp",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.13-test",
|
|
4
4
|
"description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
|
|
5
5
|
"main": "sdk.js",
|
|
6
6
|
"types": "sdk.d.ts",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"docs:extract-urls": "node docs/_scripts/extract-example-urls.js",
|
|
58
58
|
"bundle": "node build.mjs",
|
|
59
59
|
"test": "mocha test/*",
|
|
60
|
+
"test:unit": "vitest run --config vitest.unit.config.mjs",
|
|
60
61
|
"test:sdk": "vitest run",
|
|
61
62
|
"test:sdk:dev": "vitest run --project dev",
|
|
62
63
|
"test:sdk:staging": "vitest run --project staging",
|
package/sdk.d.ts
CHANGED
|
@@ -321,6 +321,16 @@ export interface TestDriverOptions {
|
|
|
321
321
|
export interface ConnectOptions {
|
|
322
322
|
/** Existing sandbox ID to reconnect to */
|
|
323
323
|
sandboxId?: string;
|
|
324
|
+
/**
|
|
325
|
+
* Require `sandboxId` to be attached exactly: throw if that sandbox is gone
|
|
326
|
+
* rather than silently provisioning a fresh one in its place (default: false).
|
|
327
|
+
*
|
|
328
|
+
* The replacement would be blank — provision methods (chrome, vscode, …) only
|
|
329
|
+
* run at session start and are never re-applied on a reconnect — so callers
|
|
330
|
+
* that depend on the sandbox's existing state should set this and handle the
|
|
331
|
+
* error by provisioning a new session explicitly.
|
|
332
|
+
*/
|
|
333
|
+
requireSandbox?: boolean;
|
|
324
334
|
/** Force creation of a new sandbox */
|
|
325
335
|
newSandbox?: boolean;
|
|
326
336
|
/** Reconnect to the last used sandbox instead of creating a new one. When true, provision methods (chrome, vscode, installer, etc.) will be skipped since the application is already running. Throws error if no previous sandbox exists. */
|
package/sdk.js
CHANGED
|
@@ -2090,6 +2090,10 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2090
2090
|
connectOptions.newSandbox !== undefined
|
|
2091
2091
|
? connectOptions.newSandbox
|
|
2092
2092
|
: this.newSandbox,
|
|
2093
|
+
// Attach to `sandboxId` or throw — never silently substitute a fresh
|
|
2094
|
+
// sandbox. Off by default so the CLI keeps its forgiving
|
|
2095
|
+
// reconnect-or-create behavior; see buildEnv's `requireSandbox`.
|
|
2096
|
+
requireSandbox: connectOptions.requireSandbox === true,
|
|
2093
2097
|
};
|
|
2094
2098
|
|
|
2095
2099
|
// Set agent properties for buildEnv to use
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
// Unit tests for SDK internals — pure, no sandbox, no network.
|
|
4
|
+
//
|
|
5
|
+
// Kept separate from vitest.config.mjs, which drives the real end-to-end
|
|
6
|
+
// examples: that config provisions live sandboxes, resolves per-channel API
|
|
7
|
+
// keys, and runs with an 8-minute timeout, so it can't host fast unit tests.
|
|
8
|
+
// Run with `npm run test:unit`.
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
test: {
|
|
11
|
+
include: ["agent/**/*.test.mjs", "lib/**/*.test.mjs"],
|
|
12
|
+
// Don't let the e2e suites' fixtures/setup leak in.
|
|
13
|
+
setupFiles: [],
|
|
14
|
+
},
|
|
15
|
+
});
|