testdriverai 7.3.25 → 7.3.27

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [7.3.27](https://github.com/testdriverai/testdriverai/compare/v7.3.25...v7.3.27) (2026-02-20)
2
+
3
+
4
+
5
+ ## [7.3.26](https://github.com/testdriverai/testdriverai/compare/v7.3.25...v7.3.26) (2026-02-20)
6
+
7
+
8
+
1
9
  ## [7.3.25](https://github.com/testdriverai/testdriverai/compare/v7.3.24...v7.3.25) (2026-02-20)
2
10
 
3
11
 
package/agent/index.js CHANGED
@@ -1791,10 +1791,13 @@ ${regression}
1791
1791
  ip: this.ip,
1792
1792
  });
1793
1793
 
1794
- // Store sandboxId (for self-hosted, use the IP as identifier) so messages include it
1795
- // This enables the API to reconnect if the websocket connection is rerouted
1794
+ // Store connection params for reconnection
1795
+ // For direct IP connections, store as a direct type so reconnection
1796
+ // sends a 'direct' message instead of 'connect' with an IP as sandboxId
1796
1797
  this.sandbox._lastConnectParams = {
1797
- sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || this.ip,
1798
+ type: 'direct',
1799
+ ip: this.ip,
1800
+ sandboxId: instance?.instance?.instanceId || instance?.instance?.sandboxId || null,
1798
1801
  persist: true,
1799
1802
  keepAlive: this.keepAlive,
1800
1803
  };
@@ -97,8 +97,13 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
97
97
 
98
98
  // Add sandboxId to every message if we have a connected sandbox
99
99
  // This allows the API to reconnect if the connection was rerouted
100
+ // Don't inject IP addresses as sandboxId — only valid instance/sandbox IDs
100
101
  if (this._lastConnectParams?.sandboxId && !message.sandboxId) {
101
- message.sandboxId = this._lastConnectParams.sandboxId;
102
+ const id = this._lastConnectParams.sandboxId;
103
+ // Only inject if it looks like a valid ID (not an IP address)
104
+ if (id && !/^\d+\.\d+\.\d+\.\d+$/.test(id)) {
105
+ message.sandboxId = id;
106
+ }
102
107
  }
103
108
 
104
109
  let p = new Promise((resolve, reject) => {
@@ -122,6 +127,11 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
122
127
  );
123
128
  }
124
129
  }, timeout);
130
+ // Don't let pending message timeouts prevent Node process from exiting
131
+ // (unref is not available in browser/non-Node environments)
132
+ if (timeoutId.unref) {
133
+ timeoutId.unref();
134
+ }
125
135
 
126
136
  // Track timeout so close() can clear it
127
137
  this.pendingTimeouts.set(requestId, timeoutId);
@@ -210,6 +220,26 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
210
220
  }
211
221
  }
212
222
 
223
+ /**
224
+ * Reconnect to a direct IP-based sandbox after connection loss.
225
+ * Sends a 'direct' message instead of 'connect' to avoid the API
226
+ * treating the IP as an AWS instance ID.
227
+ */
228
+ async reconnectDirect(ip) {
229
+ let reply = await this.send({
230
+ type: "direct",
231
+ ip,
232
+ });
233
+
234
+ if (reply.success) {
235
+ this.instanceSocketConnected = true;
236
+ emitter.emit(events.sandbox.connected);
237
+ return reply;
238
+ } else {
239
+ throw new Error(reply.errorMessage || "Failed to reconnect to direct sandbox");
240
+ }
241
+ }
242
+
213
243
  async handleConnectionLoss() {
214
244
  if (this.intentionalDisconnect) return;
215
245
 
@@ -295,9 +325,16 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
295
325
  // Without this, the new API instance has no connection.desktop
296
326
  // and all Linux operations will fail with "sandbox not initialized"
297
327
  if (this._lastConnectParams) {
298
- const { sandboxId, persist, keepAlive } = this._lastConnectParams;
299
- console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
300
- await this.connect(sandboxId, persist, keepAlive);
328
+ if (this._lastConnectParams.type === 'direct') {
329
+ // Direct IP connections must reconnect via 'direct' message, not 'connect'
330
+ const { ip, persist, keepAlive } = this._lastConnectParams;
331
+ console.log(`[Sandbox] Re-establishing direct connection (${ip})...`);
332
+ await this.reconnectDirect(ip);
333
+ } else {
334
+ const { sandboxId, persist, keepAlive } = this._lastConnectParams;
335
+ console.log(`[Sandbox] Re-establishing sandbox connection (${sandboxId})...`);
336
+ await this.connect(sandboxId, persist, keepAlive);
337
+ }
301
338
  }
302
339
  console.log("[Sandbox] Reconnected successfully.");
303
340
 
@@ -309,6 +346,11 @@ const createSandbox = (emitter, analytics, sessionInstance) => {
309
346
  this.reconnecting = false;
310
347
  }
311
348
  }, delay);
349
+ // Don't let the reconnect timer prevent Node process from exiting
350
+ // (unref is not available in browser/non-Node environments)
351
+ if (this.reconnectTimer.unref) {
352
+ this.reconnectTimer.unref();
353
+ }
312
354
  }
313
355
 
314
356
  /**
@@ -12,8 +12,8 @@ describe("Parse Test", () => {
12
12
  const testdriver = TestDriver(context, { ...getDefaults(context) });
13
13
  await testdriver.provision.chrome({ url: "https://www.airbnb.com" });
14
14
 
15
+ // The SDK automatically outputs elements as a formatted table
15
16
  const result = await testdriver.parse();
16
17
  console.log(`Found ${result.elements?.length || 0} elements`);
17
- console.log(JSON.stringify(result, null, 2));
18
18
  });
19
19
  });
@@ -173,6 +173,33 @@ function cleanupConsoleSpy(client) {
173
173
  const testDriverInstances = new WeakMap();
174
174
  const lifecycleHandlers = new WeakMap();
175
175
 
176
+ // Set to track all active TestDriver instances for signal-based cleanup
177
+ const activeInstances = new Set();
178
+
179
+ // Register signal handlers once to clean up all active instances on forced exit
180
+ let signalHandlersRegistered = false;
181
+ function registerSignalHandlers() {
182
+ if (signalHandlersRegistered) return;
183
+ signalHandlersRegistered = true;
184
+
185
+ const cleanup = async () => {
186
+ const instances = Array.from(activeInstances);
187
+ activeInstances.clear();
188
+ await Promise.race([
189
+ Promise.all(instances.map((inst) => inst.disconnect().catch(() => {}))),
190
+ new Promise((resolve) => setTimeout(resolve, 5000)), // 5s max for cleanup
191
+ ]);
192
+ };
193
+
194
+ process.on("SIGINT", () => {
195
+ cleanup().finally(() => process.exit(130));
196
+ });
197
+
198
+ process.on("SIGTERM", () => {
199
+ cleanup().finally(() => process.exit(143));
200
+ });
201
+ }
202
+
176
203
  /**
177
204
  * Create a TestDriver client in a Vitest test with automatic lifecycle management
178
205
  *
@@ -253,6 +280,8 @@ export function TestDriver(context, options = {}) {
253
280
  testdriver.__vitestContext = context.task;
254
281
  testdriver._debugOnFailure = mergedOptions.debugOnFailure || false;
255
282
  testDriverInstances.set(context.task, testdriver);
283
+ activeInstances.add(testdriver);
284
+ registerSignalHandlers();
256
285
 
257
286
  // Set platform metadata early so the reporter can show the correct OS from the start
258
287
  if (!context.task.meta) {
@@ -388,6 +417,9 @@ export function TestDriver(context, options = {}) {
388
417
  // Clean up console spies
389
418
  cleanupConsoleSpy(currentInstance);
390
419
 
420
+ // Remove from active instances tracking (even in debug mode we clean up tracking)
421
+ activeInstances.delete(currentInstance);
422
+
391
423
  // DO NOT disconnect or terminate - keep sandbox alive for debugging
392
424
  return;
393
425
  }
@@ -514,6 +546,8 @@ export function TestDriver(context, options = {}) {
514
546
  } catch (error) {
515
547
  console.error("Error disconnecting client:", error);
516
548
  } finally {
549
+ // Remove from active instances tracking
550
+ activeInstances.delete(currentInstance);
517
551
  // Terminate AWS instance if one was spawned for this test
518
552
  // This must happen AFTER dashcam.stop() to ensure recording is saved
519
553
  // AND it must happen even if disconnect() fails
@@ -115,16 +115,16 @@ function cleanupAllInstances() {
115
115
  process.on("exit", cleanupAllInstances);
116
116
  process.on("SIGINT", () => {
117
117
  cleanupAllInstances();
118
- // Don't call process.exit here - let the signal handler do its job
118
+ process.exit(130); // Restore default SIGINT exit behavior (128 + signal 2)
119
119
  });
120
120
  process.on("SIGTERM", () => {
121
121
  cleanupAllInstances();
122
- // Don't call process.exit here - let the signal handler do its job
122
+ process.exit(143); // Restore default SIGTERM exit behavior (128 + signal 15)
123
123
  });
124
124
  process.on("uncaughtException", (error) => {
125
125
  console.error("[TestDriver] Uncaught exception:", error);
126
126
  cleanupAllInstances();
127
- // Don't call process.exit here - let Node.js handle the exception
127
+ process.exit(1); // Exit after uncaught exception cleanup
128
128
  });
129
129
 
130
130
  beforeEach(async (context) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdriverai",
3
- "version": "7.3.25",
3
+ "version": "7.3.27",
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",
@@ -1022,6 +1022,130 @@ class SDKLogFormatter {
1022
1022
 
1023
1023
  return parts.join(" ");
1024
1024
  }
1025
+
1026
+ /**
1027
+ * Format parse() elements as a formatted table for console output 📋
1028
+ * @param {Array} elements - Array of parsed elements from parse()
1029
+ * @param {Object} options - Formatting options
1030
+ * @param {number} options.maxContentLength - Max length for content column (default: 30)
1031
+ * @param {number} options.maxRows - Max number of rows to display (default: 50)
1032
+ * @returns {string} Formatted table string
1033
+ */
1034
+ formatParseElements(elements, options = {}) {
1035
+ if (!elements || elements.length === 0) {
1036
+ return chalk.dim(" No elements found");
1037
+ }
1038
+
1039
+ const maxContentLength = options.maxContentLength || 30;
1040
+ const maxRows = options.maxRows || 50;
1041
+
1042
+ // Column widths
1043
+ const idxWidth = 5;
1044
+ const typeWidth = 10;
1045
+ const contentWidth = maxContentLength + 2;
1046
+ const interactWidth = 14;
1047
+ const posWidth = 18;
1048
+
1049
+ const lines = [];
1050
+
1051
+ // Header
1052
+ const headerLine = [
1053
+ chalk.bold.cyan(this._padRight("Idx", idxWidth)),
1054
+ chalk.bold.cyan(this._padRight("Type", typeWidth)),
1055
+ chalk.bold.cyan(this._padRight("Content", contentWidth)),
1056
+ chalk.bold.cyan(this._padRight("Interactive", interactWidth)),
1057
+ chalk.bold.cyan("Position"),
1058
+ ].join(chalk.dim(" │ "));
1059
+
1060
+ lines.push(" " + headerLine);
1061
+
1062
+ // Separator line
1063
+ const separatorLine = [
1064
+ chalk.dim("─".repeat(idxWidth)),
1065
+ chalk.dim("─".repeat(typeWidth)),
1066
+ chalk.dim("─".repeat(contentWidth)),
1067
+ chalk.dim("─".repeat(interactWidth)),
1068
+ chalk.dim("─".repeat(posWidth)),
1069
+ ].join(chalk.dim("─┼─"));
1070
+
1071
+ lines.push(" " + separatorLine);
1072
+
1073
+ // Data rows
1074
+ const displayElements = elements.slice(0, maxRows);
1075
+ for (const el of displayElements) {
1076
+ const idx = String(el.index ?? "?");
1077
+ const type = el.type || "unknown";
1078
+ const content = this._truncate(el.content || "", maxContentLength);
1079
+
1080
+ // Format interactivity with color
1081
+ // Note: interactivity can be boolean (true/false) or string ("clickable", "non-interactive")
1082
+ let interactivity = el.interactivity || "-";
1083
+ let interactivityDisplay;
1084
+ if (interactivity === "clickable" || interactivity === true) {
1085
+ interactivityDisplay = chalk.green("✓ clickable");
1086
+ } else if (interactivity === false || interactivity === "non-interactive") {
1087
+ interactivityDisplay = chalk.dim("-");
1088
+ } else {
1089
+ interactivityDisplay = chalk.dim(String(interactivity));
1090
+ }
1091
+
1092
+ // Format position from bbox
1093
+ let position = "-";
1094
+ if (el.bbox) {
1095
+ position = `(${el.bbox.x0}, ${el.bbox.y0})`;
1096
+ }
1097
+
1098
+ // For interactivity column, we need to pad based on visible text, not chalk codes
1099
+ // "✓ clickable" = 11 chars, "-" = 1 char, so we pad manually after getting visible length
1100
+ const interactPadded = this._padRight(interactivityDisplay, interactWidth);
1101
+
1102
+ const dataLine = [
1103
+ chalk.yellow(this._padRight(idx, idxWidth)),
1104
+ chalk.white(this._padRight(type, typeWidth)),
1105
+ chalk.gray(this._padRight(content, contentWidth)),
1106
+ interactPadded,
1107
+ chalk.dim(position),
1108
+ ].join(chalk.dim(" │ "));
1109
+
1110
+ lines.push(" " + dataLine);
1111
+ }
1112
+
1113
+ // Show truncation message if needed
1114
+ if (elements.length > maxRows) {
1115
+ lines.push(chalk.dim(` ... and ${elements.length - maxRows} more elements (showing first ${maxRows})`));
1116
+ }
1117
+
1118
+ return lines.join("\n");
1119
+ }
1120
+
1121
+ /**
1122
+ * Truncate a string to a maximum length with ellipsis
1123
+ * @private
1124
+ * @param {string} str - String to truncate
1125
+ * @param {number} maxLength - Maximum length
1126
+ * @returns {string} Truncated string
1127
+ */
1128
+ _truncate(str, maxLength) {
1129
+ if (!str) return "";
1130
+ // Remove newlines and extra whitespace
1131
+ const cleaned = str.replace(/\s+/g, " ").trim();
1132
+ if (cleaned.length <= maxLength) return cleaned;
1133
+ return cleaned.substring(0, maxLength - 1) + "…";
1134
+ }
1135
+
1136
+ /**
1137
+ * Pad a string to the right to a fixed width
1138
+ * @private
1139
+ * @param {string} str - String to pad
1140
+ * @param {number} width - Target width
1141
+ * @returns {string} Padded string
1142
+ */
1143
+ _padRight(str, width) {
1144
+ // Handle chalk strings by getting visible length
1145
+ const visibleStr = String(str).replace(/\x1b\[[0-9;]*m/g, "");
1146
+ const padding = Math.max(0, width - visibleStr.length);
1147
+ return str + " ".repeat(padding);
1148
+ }
1025
1149
  }
1026
1150
 
1027
1151
  // Export singleton instance
package/sdk.js CHANGED
@@ -3678,6 +3678,12 @@ CAPTCHA_SOLVER_EOF`,
3678
3678
  `✅ Parse complete: ${response.elements?.length || 0} elements detected`,
3679
3679
  );
3680
3680
 
3681
+ // Output elements as a formatted table
3682
+ if (response.elements && response.elements.length > 0) {
3683
+ const tableOutput = formatter.formatParseElements(response.elements);
3684
+ this.emitter.emit(events.log.log, tableOutput);
3685
+ }
3686
+
3681
3687
  return response;
3682
3688
  }
3683
3689