@testdriverai/mcp 7.11.12-test → 7.11.13-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.
@@ -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 (!createNew && this.sandboxId && !this.config.CI) {
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
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
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
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
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
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
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
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
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.12-test",
3
+ "version": "7.11.13-canary",
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
+ });