@testdriverai/mcp 7.9.96-canary → 7.9.97-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.
@@ -54,7 +54,11 @@ const testdriver = new TestDriver(apiKey, options)
54
54
  </ParamField>
55
55
 
56
56
  <ParamField path="reconnect" type="boolean" default="false">
57
- 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.
57
+ Reattach to the last used sandbox instead of creating a new one. When `true`, the SDK reads the sandbox id from `.testdriver/last-sandbox` (written automatically on every successful connect) and rejoins that VM. Provision methods (`chrome`, `vscode`, `installer`, etc.) are skipped because the application is already running. The previous sandbox must still be alive — see [`keepAlive`](#keepalive) and the [Machine Setup guide](/v7/machine-setup#keeping-machines-alive-between-runs).
58
+ </ParamField>
59
+
60
+ <ParamField path="sandboxId" type="string">
61
+ Reattach to a specific sandbox id instead of the one recorded in `.testdriver/last-sandbox`. Use this for CI matrices or to pin a chain of tests to a known VM. Implies `reconnect: true` behavior (provision calls are skipped).
58
62
  </ParamField>
59
63
 
60
64
  <ParamField path="preview" type="string" default="browser">
@@ -87,6 +87,16 @@ const testdriver = TestDriver(context, {
87
87
 
88
88
  Windows (and Linux) cold starts can be expensive if you're iterating quickly. Use `keepAlive` + `reconnect` to reuse the same VM across multiple test runs.
89
89
 
90
+ ### How it works
91
+
92
+ Every time the SDK successfully connects to a sandbox, it records the sandbox id in `.testdriver/last-sandbox` inside your project directory. The next test that opts in with `reconnect: true` reads that file and reattaches automatically — no manual id tracking required.
93
+
94
+ Provision calls (`testdriver.provision.chrome(...)`, `vscode(...)`, etc.) are **skipped** when reconnecting, because the application is already running inside the sandbox from the previous run.
95
+
96
+ <Note>
97
+ `.testdriver/last-sandbox` is already covered by the default TestDriver `.gitignore`. Don't commit it.
98
+ </Note>
99
+
90
100
  ### Step 1 — Start the machine with a long `keepAlive`
91
101
 
92
102
  ```javascript
@@ -102,30 +112,87 @@ await testdriver.provision.chrome({ url: "https://example.com" });
102
112
 
103
113
  When this test finishes, the sandbox stays running for 30 minutes instead of being terminated immediately.
104
114
 
105
- ### Step 2 — Connect in subsequent runs
115
+ ### Step 2 — Reattach automatically with `reconnect: true`
106
116
 
107
117
  ```javascript
108
118
  // second.test.mjs
109
119
  const testdriver = TestDriver(context, {
110
120
  os: "windows",
121
+ reconnect: true, // ← reads .testdriver/last-sandbox
111
122
  keepAlive: 30 * 60 * 1000,
112
123
  });
113
124
 
114
- await testdriver.connect({ sandboxId: "sandbox-abc123" });
115
-
116
- // provision.chrome() is automatically skipped — Chrome is already open
125
+ // No provision call — Chrome is already open from the previous run.
117
126
  await testdriver.find("Sign In button").click();
118
127
  ```
119
128
 
120
- When connecting to an existing sandbox ID:
129
+ ### Step 2 (alternative) — Reattach to an explicit id
130
+
131
+ If you need to pin to a specific sandbox (CI matrix, multiple chains in parallel, etc.) pass the id directly:
132
+
133
+ ```javascript
134
+ await testdriver.connect({ sandboxId: "sandbox-abc123" });
135
+ ```
136
+
137
+ When reattaching to a sandbox:
121
138
  - You reuse a specific running machine directly
122
- - You can continue from the app state created in an earlier test run
123
- - You should run within the previous test's `keepAlive` window
139
+ - You continue from the app state created in the earlier run
140
+ - You must run within the previous test's `keepAlive` window
124
141
 
125
142
  <Tip>
126
- You can also supply a sandbox ID directly: `await testdriver.connect({ sandboxId: "sandbox-abc123" })`. Use `testdriver.getLastSandboxId()` to retrieve the ID of the last sandbox for scripting purposes.
143
+ Use `testdriver.getLastSandboxId()` to read the recorded sandbox id (and optional metadata) for scripting purposes.
127
144
  </Tip>
128
145
 
146
+ ### Chaining describe blocks within one test file
147
+
148
+ A common pattern is to break a long flow into focused `describe` blocks that share one sandbox — the first block provisions and signs in, later blocks reconnect and continue:
149
+
150
+ ```javascript
151
+ import { describe, expect, it } from "vitest";
152
+ import { TestDriver } from "testdriverai/vitest/hooks";
153
+
154
+ const KEEP_ALIVE_MS = 5 * 60 * 1000;
155
+
156
+ describe("step 1 — log in", () => {
157
+ it("signs in and lands on the dashboard", async (context) => {
158
+ const testdriver = TestDriver(context, { keepAlive: KEEP_ALIVE_MS });
159
+ await testdriver.provision.chrome({ url: "https://example.com/login" });
160
+ await testdriver.find("username input").click();
161
+ await testdriver.type("standard_user");
162
+ await testdriver.pressKeys(["tab"]);
163
+ await testdriver.type("secret_sauce", { secret: true });
164
+ await testdriver.pressKeys(["enter"]);
165
+ expect(await testdriver.assert("the dashboard is visible")).toBeTruthy();
166
+ });
167
+ });
168
+
169
+ describe("step 2 — add to cart", () => {
170
+ it("reuses the logged-in sandbox", async (context) => {
171
+ const testdriver = TestDriver(context, {
172
+ reconnect: true, // ← skip provisioning, reattach
173
+ keepAlive: KEEP_ALIVE_MS,
174
+ });
175
+ await testdriver.find("Add to cart").click();
176
+ await testdriver.find("cart icon").click();
177
+ expect(await testdriver.assert("the cart has an item")).toBeTruthy();
178
+ });
179
+ });
180
+
181
+ describe("step 3 — check out", () => {
182
+ it("continues from the cart state", async (context) => {
183
+ const testdriver = TestDriver(context, { reconnect: true, keepAlive: 30_000 });
184
+ await testdriver.find("Checkout").click();
185
+ expect(await testdriver.assert("the checkout form is visible")).toBeTruthy();
186
+ });
187
+ });
188
+ ```
189
+
190
+ A runnable copy of this pattern lives at [`examples/reconnect-sequential.test.mjs`](https://github.com/testdriverai/mono/blob/main/sdk/examples/reconnect-sequential.test.mjs).
191
+
192
+ <Warning>
193
+ Vitest runs **test files** in parallel by default. Within a single file, `describe`/`it` blocks run in source order, so reconnect chaining works as written. To chain across multiple files, run them sequentially (e.g. `vitest run --sequence.concurrent=false` or place them in a single project pool with workers set to 1).
194
+ </Warning>
195
+
129
196
  ### How `keepAlive` works
130
197
 
131
198
  `keepAlive` is a duration in milliseconds. After the SDK disconnects, the server keeps the VM running for that long before terminating it. The default is `60000` (1 minute). Note: `keepAlive: 0` currently falls back to the default disconnect grace period rather than terminating immediately, so use a positive duration when you want to control the grace window explicitly.
@@ -1,4 +1,5 @@
1
1
  export const getDefaults = (context) => ({
2
2
  ip: context.ip || process.env.TD_IP,
3
3
  preview: 'web',
4
+ cache: 'false'
4
5
  });
@@ -150,6 +150,34 @@ class Dashcam {
150
150
 
151
151
  }
152
152
 
153
+ /**
154
+ * Workaround for dashcam <1.5.0: webLogsDaemon.js writes its state to a
155
+ * `.dashcam` directory relative to process.cwd(). When launched by the
156
+ * runner, cwd is /usr/lib/node_modules/@testdriverai/runner (root-owned),
157
+ * so the daemon can't mkdir its state dir as user `user` and silently
158
+ * dies — recordings never start (or `dashcam logs --add` fails with EACCES).
159
+ * Pre-create world-writable .dashcam dirs at both the current cwd and the
160
+ * known global-install path so the daemon survives regardless of which path
161
+ * dashcam ends up using. Must run before ANY dashcam invocation, not just
162
+ * auth(), because addWebLog() is called before start()/auth() in provision.
163
+ * TODO: remove once dashcam >=1.5.0 (which uses $HOME) is bundled.
164
+ * @private
165
+ */
166
+ async _ensureDashcamStateDirs() {
167
+ if (this.client.os === "windows") return;
168
+ if (!this._dashcamDirsReady) {
169
+ this._dashcamDirsReady = this.client.exec(
170
+ this._getShell(),
171
+ `sudo mkdir -p "$(pwd)/.dashcam" /usr/lib/node_modules/@testdriverai/runner/.dashcam 2>/dev/null; ` +
172
+ `sudo chmod 0777 "$(pwd)/.dashcam" /usr/lib/node_modules/@testdriverai/runner/.dashcam 2>/dev/null; ` +
173
+ `true`,
174
+ 10000,
175
+ process.env.TD_DEBUG == "true" ? false : true,
176
+ );
177
+ }
178
+ await this._dashcamDirsReady;
179
+ }
180
+
153
181
  /**
154
182
  * Authenticate dashcam with API key
155
183
  * @param {string} [apiKey] - Override API key
@@ -174,23 +202,7 @@ class Dashcam {
174
202
  this._log("debug", "Auth output:", authOutput);
175
203
  } else {
176
204
  // Linux/Mac authentication with TD_API_ROOT
177
-
178
- // Workaround for dashcam <1.5.0: webLogsDaemon.js writes its state to a
179
- // `.dashcam` directory relative to process.cwd(). When launched by the
180
- // runner, cwd is /usr/lib/node_modules/@testdriverai/runner (root-owned),
181
- // so the daemon can't mkdir its state dir as user `user` and silently
182
- // dies — recordings never start. Pre-create world-writable .dashcam dirs
183
- // at both the current cwd and the known global-install path so the
184
- // daemon survives regardless of which path dashcam ends up using.
185
- // TODO: remove once dashcam >=1.5.0 (which uses $HOME) is bundled.
186
- await this.client.exec(
187
- shell,
188
- `sudo mkdir -p "$(pwd)/.dashcam" /usr/lib/node_modules/@testdriverai/runner/.dashcam 2>/dev/null; ` +
189
- `sudo chmod 0777 "$(pwd)/.dashcam" /usr/lib/node_modules/@testdriverai/runner/.dashcam 2>/dev/null; ` +
190
- `true`,
191
- 10000,
192
- process.env.TD_DEBUG == "true" ? false : true,
193
- );
205
+ await this._ensureDashcamStateDirs();
194
206
 
195
207
  const authOutput = await this.client.exec(
196
208
  shell,
@@ -233,6 +245,7 @@ class Dashcam {
233
245
  );
234
246
  this._log("debug", "Add log tracking output:", addLogOutput);
235
247
  } else {
248
+ await this._ensureDashcamStateDirs();
236
249
  // Create log file
237
250
  await this.client.exec(
238
251
  shell,
@@ -272,6 +285,7 @@ class Dashcam {
272
285
  );
273
286
  this._log("debug", "Add application log tracking output:", addLogOutput);
274
287
  } else {
288
+ await this._ensureDashcamStateDirs();
275
289
  const addLogOutput = await this.client.exec(
276
290
  shell,
277
291
  `TD_API_ROOT="${apiRoot}" dashcam logs --add --type=application --application="${application}" --name="${name}"`,
@@ -306,6 +320,7 @@ class Dashcam {
306
320
  this._log("warn", "Add web log tracking failed:", err.message);
307
321
  }
308
322
  } else {
323
+ await this._ensureDashcamStateDirs();
309
324
  const addLogOutput = await this.client.exec(
310
325
  shell,
311
326
  `TD_API_ROOT="${apiRoot}" dashcam logs --add --type=web --pattern="${pattern}" --name="${name}"`,
@@ -386,6 +401,7 @@ class Dashcam {
386
401
  this._log("debug", "Dashcam recording started");
387
402
  } else {
388
403
  // Linux/Mac with TD_API_ROOT
404
+ await this._ensureDashcamStateDirs();
389
405
  this._log("debug", "Starting dashcam recording on Linux/Mac...");
390
406
  const dashcamPath = await this._getDashcamPath();
391
407
  const titleArg = this.title
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/mcp",
3
- "version": "7.9.96-canary",
3
+ "version": "7.9.97-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",
package/sdk.d.ts CHANGED
@@ -277,8 +277,10 @@ export interface TestDriverOptions {
277
277
  e2bTemplateId?: string;
278
278
  /** Cache key for element finding operations. If provided, enables caching tied to this key */
279
279
  cacheKey?: string;
280
- /** 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. */
280
+ /** 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. Looks up the sandbox id from `.testdriver/last-sandbox` if `sandboxId` is not also provided. */
281
281
  reconnect?: boolean;
282
+ /** Explicit sandbox id to reconnect to. Implies `newSandbox: false` unless explicitly overridden. */
283
+ sandboxId?: string;
282
284
  /** Enable/disable Dashcam video recording (default: true) */
283
285
  dashcam?: boolean;
284
286
  /**
@@ -1042,15 +1044,15 @@ export default class TestDriverSDK {
1042
1044
  disconnect(): Promise<void>;
1043
1045
 
1044
1046
  /**
1045
- * Get the last sandbox info from the stored file
1047
+ * Get info about the most recently provisioned sandbox (from this SDK instance
1048
+ * if connected, otherwise read from `.testdriver/last-sandbox`).
1046
1049
  * @returns Last sandbox info or null if not found
1047
1050
  */
1048
1051
  getLastSandboxId(): {
1049
- sandboxId: string | null;
1050
- os: "windows" | "linux";
1051
- ami: string | null;
1052
- instanceType: string | null;
1053
- timestamp: string | null;
1052
+ sandboxId: string;
1053
+ os?: "windows" | "linux";
1054
+ e2bTemplateId?: string | null;
1055
+ createdAt?: number;
1054
1056
  } | null;
1055
1057
 
1056
1058
  // Element Finding API
package/sdk.js CHANGED
@@ -1510,6 +1510,21 @@ class TestDriverSDK {
1510
1510
  this.reconnect =
1511
1511
  options.reconnect !== undefined ? options.reconnect : false;
1512
1512
 
1513
+ // Explicit sandbox id to reconnect to (overrides last-sandbox file)
1514
+ this.sandboxId = options.sandboxId || null;
1515
+
1516
+ // When reconnect is requested, an explicit sandboxId implies newSandbox=false.
1517
+ // If reconnect:true but no sandboxId given, try to load from .testdriver/last-sandbox.
1518
+ if (this.reconnect && !this.sandboxId) {
1519
+ const last = TestDriverSDK._readLastSandbox();
1520
+ if (last && last.sandboxId) {
1521
+ this.sandboxId = last.sandboxId;
1522
+ }
1523
+ }
1524
+ if (this.sandboxId && options.newSandbox === undefined) {
1525
+ this.newSandbox = false;
1526
+ }
1527
+
1513
1528
  // Store keepAlive preference from options
1514
1529
  this.keepAlive =
1515
1530
  options.keepAlive !== undefined ? options.keepAlive : undefined;
@@ -2070,6 +2085,9 @@ CAPTCHA_SOLVER_EOF`,
2070
2085
  // Set agent properties for buildEnv to use
2071
2086
  if (connectOptions.sandboxId) {
2072
2087
  this.agent.sandboxId = connectOptions.sandboxId;
2088
+ } else if (this.sandboxId) {
2089
+ // Constructor-provided sandboxId (explicit or loaded from .testdriver/last-sandbox)
2090
+ this.agent.sandboxId = this.sandboxId;
2073
2091
  }
2074
2092
  // Use IP from connectOptions if provided, otherwise fall back to constructor IP
2075
2093
  if (connectOptions.ip !== undefined) {
@@ -2168,6 +2186,20 @@ CAPTCHA_SOLVER_EOF`,
2168
2186
  sandboxId: this.instance?.instanceId,
2169
2187
  });
2170
2188
 
2189
+ // Persist the active sandbox id so a later run with `reconnect: true`
2190
+ // can reattach without the caller having to thread the id through.
2191
+ const activeSandboxId =
2192
+ this.instance?.sandboxId || this.instance?.instanceId || null;
2193
+ if (activeSandboxId) {
2194
+ this.sandboxId = activeSandboxId;
2195
+ TestDriverSDK._writeLastSandbox({
2196
+ sandboxId: activeSandboxId,
2197
+ os: this.os,
2198
+ e2bTemplateId: this.e2bTemplateId || null,
2199
+ createdAt: Date.now(),
2200
+ });
2201
+ }
2202
+
2171
2203
  return this.instance;
2172
2204
  }
2173
2205
 
@@ -3645,6 +3677,52 @@ CAPTCHA_SOLVER_EOF`,
3645
3677
  clearLogs() {
3646
3678
  this._logBuffer = [];
3647
3679
  }
3680
+
3681
+ /**
3682
+ * Return info about the most recently provisioned sandbox (from this process
3683
+ * or persisted from a previous run via .testdriver/last-sandbox).
3684
+ * @returns {{ sandboxId: string, os?: string, e2bTemplateId?: string|null, createdAt?: number } | null}
3685
+ */
3686
+ getLastSandboxId() {
3687
+ if (this.sandboxId) {
3688
+ return {
3689
+ sandboxId: this.sandboxId,
3690
+ os: this.os,
3691
+ e2bTemplateId: this.e2bTemplateId || null,
3692
+ };
3693
+ }
3694
+ return TestDriverSDK._readLastSandbox();
3695
+ }
3696
+
3697
+ /** @private */
3698
+ static _lastSandboxPath() {
3699
+ return path.join(process.cwd(), ".testdriver", "last-sandbox");
3700
+ }
3701
+
3702
+ /** @private */
3703
+ static _readLastSandbox() {
3704
+ try {
3705
+ const p = TestDriverSDK._lastSandboxPath();
3706
+ if (!fs.existsSync(p)) return null;
3707
+ const raw = fs.readFileSync(p, "utf8");
3708
+ const data = JSON.parse(raw);
3709
+ if (!data || !data.sandboxId) return null;
3710
+ return data;
3711
+ } catch (_) {
3712
+ return null;
3713
+ }
3714
+ }
3715
+
3716
+ /** @private */
3717
+ static _writeLastSandbox(data) {
3718
+ try {
3719
+ const p = TestDriverSDK._lastSandboxPath();
3720
+ fs.mkdirSync(path.dirname(p), { recursive: true });
3721
+ fs.writeFileSync(p, JSON.stringify(data, null, 2));
3722
+ } catch (_) {
3723
+ // Best-effort — don't fail the test if we can't persist.
3724
+ }
3725
+ }
3648
3726
  }
3649
3727
 
3650
3728
  // Expose SDK version as a static property for use by vitest hooks/plugins