testdriverai 7.2.56 → 7.2.57

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.
@@ -1,24 +1,24 @@
1
1
  /**
2
2
  * Vitest Hooks for TestDriver
3
- *
3
+ *
4
4
  * Provides lifecycle management for TestDriver in Vitest tests.
5
- *
5
+ *
6
6
  * @example
7
7
  * import { TestDriver } from 'testdriverai/vitest/hooks';
8
- *
8
+ *
9
9
  * test('my test', async (context) => {
10
10
  * const testdriver = TestDriver(context, { headless: true });
11
- *
11
+ *
12
12
  * await testdriver.provision.chrome({ url: 'https://example.com' });
13
13
  * await testdriver.find('button').click();
14
14
  * });
15
15
  */
16
16
 
17
- import chalk from 'chalk';
18
- import { createRequire } from 'module';
19
- import path from 'path';
20
- import { vi } from 'vitest';
21
- import TestDriverSDK from '../../sdk.js';
17
+ import chalk from "chalk";
18
+ import { createRequire } from "module";
19
+ import path from "path";
20
+ import { vi } from "vitest";
21
+ import TestDriverSDK from "../../sdk.js";
22
22
 
23
23
  // Use createRequire to import CommonJS modules
24
24
  const require = createRequire(import.meta.url);
@@ -34,21 +34,21 @@ const MINIMUM_VITEST_VERSION = 4;
34
34
  */
35
35
  function checkVitestVersion() {
36
36
  try {
37
- const vitestPkg = require('vitest/package.json');
37
+ const vitestPkg = require("vitest/package.json");
38
38
  const version = vitestPkg.version;
39
- const major = parseInt(version.split('.')[0], 10);
40
-
39
+ const major = parseInt(version.split(".")[0], 10);
40
+
41
41
  if (major < MINIMUM_VITEST_VERSION) {
42
42
  throw new Error(
43
43
  `TestDriver requires Vitest >= ${MINIMUM_VITEST_VERSION}.0.0, but found ${version}. ` +
44
- `Please upgrade Vitest: npm install vitest@latest`
44
+ `Please upgrade Vitest: npm install vitest@latest`,
45
45
  );
46
46
  }
47
47
  } catch (err) {
48
- if (err.code === 'MODULE_NOT_FOUND') {
48
+ if (err.code === "MODULE_NOT_FOUND") {
49
49
  throw new Error(
50
- 'TestDriver requires Vitest to be installed. ' +
51
- 'Please install it: npm install vitest@latest'
50
+ "TestDriver requires Vitest to be installed. " +
51
+ "Please install it: npm install vitest@latest",
52
52
  );
53
53
  }
54
54
  throw err;
@@ -66,14 +66,19 @@ checkVitestVersion();
66
66
  * @param {string} taskId - Unique task identifier for this test
67
67
  */
68
68
  function setupConsoleSpy(client, taskId) {
69
-
70
69
  // Debug logging for console spy setup
71
- const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === 'true';
70
+ const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === "true";
72
71
  if (debugConsoleSpy) {
73
72
  process.stdout.write(`[DEBUG setupConsoleSpy] taskId: ${taskId}\n`);
74
- process.stdout.write(`[DEBUG setupConsoleSpy] client.sandbox exists: ${!!client.sandbox}\n`);
75
- process.stdout.write(`[DEBUG setupConsoleSpy] client.sandbox?.instanceSocketConnected: ${client.sandbox?.instanceSocketConnected}\n`);
76
- process.stdout.write(`[DEBUG setupConsoleSpy] client.sandbox?.send: ${typeof client.sandbox?.send}\n`);
73
+ process.stdout.write(
74
+ `[DEBUG setupConsoleSpy] client.sandbox exists: ${!!client.sandbox}\n`,
75
+ );
76
+ process.stdout.write(
77
+ `[DEBUG setupConsoleSpy] client.sandbox?.instanceSocketConnected: ${client.sandbox?.instanceSocketConnected}\n`,
78
+ );
79
+ process.stdout.write(
80
+ `[DEBUG setupConsoleSpy] client.sandbox?.send: ${typeof client.sandbox?.send}\n`,
81
+ );
77
82
  }
78
83
 
79
84
  // Track forwarding stats
@@ -84,9 +89,7 @@ function setupConsoleSpy(client, taskId) {
84
89
  const forwardToSandbox = (args) => {
85
90
  const message = args
86
91
  .map((arg) =>
87
- typeof arg === "object"
88
- ? JSON.stringify(arg, null, 2)
89
- : String(arg),
92
+ typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg),
90
93
  )
91
94
  .join(" ");
92
95
 
@@ -99,17 +102,23 @@ function setupConsoleSpy(client, taskId) {
99
102
  });
100
103
  forwardedCount++;
101
104
  if (debugConsoleSpy && forwardedCount <= 3) {
102
- process.stdout.write(`[DEBUG forwardToSandbox] Forwarded message #${forwardedCount}: "${message.substring(0, 50)}..."\n`);
105
+ process.stdout.write(
106
+ `[DEBUG forwardToSandbox] Forwarded message #${forwardedCount}: "${message.substring(0, 50)}..."\n`,
107
+ );
103
108
  }
104
109
  } catch (err) {
105
110
  if (debugConsoleSpy) {
106
- process.stdout.write(`[DEBUG forwardToSandbox] Error sending: ${err.message}\n`);
111
+ process.stdout.write(
112
+ `[DEBUG forwardToSandbox] Error sending: ${err.message}\n`,
113
+ );
107
114
  }
108
115
  }
109
116
  } else {
110
117
  skippedCount++;
111
118
  if (debugConsoleSpy && skippedCount <= 3) {
112
- process.stdout.write(`[DEBUG forwardToSandbox] SKIPPED (sandbox not connected): "${message.substring(0, 50)}..."\n`);
119
+ process.stdout.write(
120
+ `[DEBUG forwardToSandbox] SKIPPED (sandbox not connected): "${message.substring(0, 50)}..."\n`,
121
+ );
113
122
  }
114
123
  }
115
124
  };
@@ -121,29 +130,28 @@ function setupConsoleSpy(client, taskId) {
121
130
  const originalInfo = console.info.bind(console);
122
131
 
123
132
  // Create spies for each console method
124
- const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
125
- originalLog(...args); // Call original (Vitest will capture this)
133
+ const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => {
134
+ originalLog(...args); // Call original (Vitest will capture this)
126
135
  forwardToSandbox(args);
127
136
  });
128
137
 
129
- const errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
138
+ const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => {
130
139
  originalError(...args);
131
140
  forwardToSandbox(args);
132
141
  });
133
142
 
134
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation((...args) => {
143
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation((...args) => {
135
144
  originalWarn(...args);
136
145
  forwardToSandbox(args);
137
146
  });
138
147
 
139
- const infoSpy = vi.spyOn(console, 'info').mockImplementation((...args) => {
148
+ const infoSpy = vi.spyOn(console, "info").mockImplementation((...args) => {
140
149
  originalInfo(...args);
141
150
  forwardToSandbox(args);
142
151
  });
143
152
 
144
153
  // Store spies on client for cleanup
145
154
  client._consoleSpies = { logSpy, errorSpy, warnSpy, infoSpy };
146
-
147
155
  }
148
156
 
149
157
  /**
@@ -167,26 +175,28 @@ const lifecycleHandlers = new WeakMap();
167
175
 
168
176
  /**
169
177
  * Create a TestDriver client in a Vitest test with automatic lifecycle management
170
- *
178
+ *
171
179
  * @param {import('vitest').TestContext} context - Vitest test context (from async (context) => {})
172
180
  * @param {import('../../sdk.js').TestDriverOptions} [options] - TestDriver options (passed directly to TestDriver constructor)
173
181
  * @returns {import('../../sdk.js').default} TestDriver client instance
174
- *
182
+ *
175
183
  * @example
176
184
  * test('my test', async (context) => {
177
185
  * const testdriver = TestDriver(context, { headless: true });
178
- *
186
+ *
179
187
  * // provision.chrome() automatically calls ready() and starts dashcam
180
188
  * await testdriver.provision.chrome({ url: 'https://example.com' });
181
- *
189
+ *
182
190
  * await testdriver.find('Login button').click();
183
191
  * });
184
192
  */
185
193
  export function TestDriver(context, options = {}) {
186
194
  if (!context || !context.task) {
187
- throw new Error('TestDriver() requires Vitest context. Pass the context parameter from your test function: test("name", async (context) => { ... })');
195
+ throw new Error(
196
+ 'TestDriver() requires Vitest context. Pass the context parameter from your test function: test("name", async (context) => { ... })',
197
+ );
188
198
  }
189
-
199
+
190
200
  // Return existing instance if already created for this test AND it's still connected
191
201
  // On retry, the previous instance will be disconnected, so we need to create a new one
192
202
  if (testDriverInstances.has(context.task)) {
@@ -198,106 +208,123 @@ export function TestDriver(context, options = {}) {
198
208
  testDriverInstances.delete(context.task);
199
209
  lifecycleHandlers.delete(context.task);
200
210
  }
201
-
211
+
202
212
  // Get global plugin options if available
203
- const pluginOptions = globalThis.__testdriverPlugin?.state?.testDriverOptions || {};
204
-
213
+ const pluginOptions =
214
+ globalThis.__testdriverPlugin?.state?.testDriverOptions || {};
215
+
205
216
  // Merge options: plugin global options < test-specific options
206
217
  const mergedOptions = { ...pluginOptions, ...options };
207
-
218
+
208
219
  // Support TD_OS environment variable for specifying target OS (linux, mac, windows)
209
220
  // Priority: test options > plugin options > environment variable > default (linux)
210
221
  if (!mergedOptions.os && process.env.TD_OS) {
211
222
  mergedOptions.os = process.env.TD_OS;
212
- console.log(`[testdriver] Set mergedOptions.os = ${mergedOptions.os} from TD_OS environment variable`);
223
+ console.log(
224
+ `[testdriver] Set mergedOptions.os = ${mergedOptions.os} from TD_OS environment variable`,
225
+ );
213
226
  }
214
-
227
+
215
228
  // Use IP from context if set by setup-aws.mjs (or other setup files)
216
229
  // Priority: test options > context.ip (from setup hooks)
217
230
  if (!mergedOptions.ip && context.ip) {
218
231
  mergedOptions.ip = context.ip;
219
- console.log(`[testdriver] Set mergedOptions.ip = ${mergedOptions.ip} from context.ip`);
232
+ console.log(
233
+ `[testdriver] Set mergedOptions.ip = ${mergedOptions.ip} from context.ip`,
234
+ );
220
235
  }
221
-
236
+
222
237
  // Extract TestDriver-specific options
223
238
  const apiKey = mergedOptions.apiKey || process.env.TD_API_KEY;
224
-
239
+
225
240
  // Build config for TestDriverSDK constructor
226
241
  const config = { ...mergedOptions };
227
242
  delete config.apiKey;
228
-
243
+
229
244
  // Use TD_API_ROOT from environment if not provided in config
230
245
  if (!config.apiRoot && process.env.TD_API_ROOT) {
231
246
  config.apiRoot = process.env.TD_API_ROOT;
232
247
  }
233
-
248
+
234
249
  const testdriver = new TestDriverSDK(apiKey, config);
235
250
  testdriver.__vitestContext = context.task;
236
251
  testDriverInstances.set(context.task, testdriver);
237
-
252
+
238
253
  // Set platform metadata early so the reporter can show the correct OS from the start
239
254
  if (!context.task.meta) {
240
255
  context.task.meta = {};
241
256
  }
242
- const platform = mergedOptions.os || 'linux';
243
- const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
257
+ const platform = mergedOptions.os || "linux";
258
+ const absolutePath =
259
+ context.task.file?.filepath || context.task.file?.name || "unknown";
244
260
  const projectRoot = process.cwd();
245
- const testFile = absolutePath !== 'unknown'
246
- ? path.relative(projectRoot, absolutePath)
247
- : absolutePath;
248
-
261
+ const testFile =
262
+ absolutePath !== "unknown"
263
+ ? path.relative(projectRoot, absolutePath)
264
+ : absolutePath;
265
+
249
266
  context.task.meta.platform = platform;
250
267
  context.task.meta.testFile = testFile;
251
268
  context.task.meta.testOrder = 0;
252
-
269
+
253
270
  // Pass test file name to SDK for debugger display
254
271
  testdriver.testFile = testFile;
255
-
256
- const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === 'true';
257
-
272
+
273
+ const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === "true";
274
+
258
275
  testdriver.__connectionPromise = (async () => {
259
- if (debugConsoleSpy) {
260
- console.log('[DEBUG] Before auth - sandbox.instanceSocketConnected:', testdriver.sandbox?.instanceSocketConnected);
261
- }
262
-
263
- await testdriver.auth();
264
- await testdriver.connect();
265
-
266
- // Clear the connection promise now that we're connected
267
- // This prevents deadlock when exec() is called below (exec() lazy-awaits __connectionPromise)
268
- testdriver.__connectionPromise = null;
269
-
270
- if (debugConsoleSpy) {
271
- console.log('[DEBUG] After connect - sandbox.instanceSocketConnected:', testdriver.sandbox?.instanceSocketConnected);
272
- console.log('[DEBUG] After connect - sandbox.send:', typeof testdriver.sandbox?.send);
273
- }
274
-
275
- // Set up console spy using vi.spyOn (test-isolated)
276
- setupConsoleSpy(testdriver, context.task.id);
277
-
278
- // Create the log file on the remote machine
279
- const shell = testdriver.os === "windows" ? "pwsh" : "sh";
280
- const logPath = testdriver.os === "windows"
276
+ if (debugConsoleSpy) {
277
+ console.log(
278
+ "[DEBUG] Before auth - sandbox.instanceSocketConnected:",
279
+ testdriver.sandbox?.instanceSocketConnected,
280
+ );
281
+ }
282
+
283
+ await testdriver.auth();
284
+ await testdriver.connect();
285
+
286
+ // Clear the connection promise now that we're connected
287
+ // This prevents deadlock when exec() is called below (exec() lazy-awaits __connectionPromise)
288
+ testdriver.__connectionPromise = null;
289
+
290
+ if (debugConsoleSpy) {
291
+ console.log(
292
+ "[DEBUG] After connect - sandbox.instanceSocketConnected:",
293
+ testdriver.sandbox?.instanceSocketConnected,
294
+ );
295
+ console.log(
296
+ "[DEBUG] After connect - sandbox.send:",
297
+ typeof testdriver.sandbox?.send,
298
+ );
299
+ }
300
+
301
+ // Set up console spy using vi.spyOn (test-isolated)
302
+ setupConsoleSpy(testdriver, context.task.id);
303
+
304
+ // Create the log file on the remote machine
305
+ const shell = testdriver.os === "windows" ? "pwsh" : "sh";
306
+ const logPath =
307
+ testdriver.os === "windows"
281
308
  ? "C:\\Users\\testdriver\\Documents\\testdriver.log"
282
309
  : "/tmp/testdriver.log";
283
-
284
- const createLogCmd = testdriver.os === "windows"
310
+
311
+ const createLogCmd =
312
+ testdriver.os === "windows"
285
313
  ? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
286
314
  : `touch ${logPath}`;
287
-
288
- await testdriver.exec(shell, createLogCmd, 10000, true);
289
-
290
- // Only set up dashcam if enabled (default: true)
291
- if (testdriver.dashcamEnabled) {
292
- // Add testdriver log to dashcam tracking
293
- await testdriver.dashcam.addFileLog(logPath, "TestDriver Log");
294
-
295
- // Start dashcam recording (always, regardless of provision method)
296
- await testdriver.dashcam.start();
297
- }
298
315
 
299
- })();
300
-
316
+ await testdriver.exec(shell, createLogCmd, 10000, true);
317
+
318
+ // Only set up dashcam if enabled (default: true)
319
+ if (testdriver.dashcamEnabled) {
320
+ // Add testdriver log to dashcam tracking
321
+ await testdriver.dashcam.addFileLog(logPath, "TestDriver Log");
322
+
323
+ // Start dashcam recording (always, regardless of provision method)
324
+ await testdriver.dashcam.start();
325
+ }
326
+ })();
327
+
301
328
  // Register cleanup handler with dashcam.stop()
302
329
  // We always register a new cleanup handler because on retry we need to clean up the new instance
303
330
  const cleanup = async () => {
@@ -307,50 +334,71 @@ export function TestDriver(context, options = {}) {
307
334
  if (!currentInstance) {
308
335
  return; // Already cleaned up
309
336
  }
310
-
337
+
311
338
  try {
312
339
  // Ensure meta object exists
313
340
  if (!context.task.meta) {
314
341
  context.task.meta = {};
315
342
  }
316
-
343
+
317
344
  // Always set test metadata, even if dashcam never started or fails to stop
318
345
  // This ensures the reporter can record test results even for early failures
319
- const platform = currentInstance.os || 'linux';
320
- const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
346
+ const platform = currentInstance.os || "linux";
347
+ const absolutePath =
348
+ context.task.file?.filepath || context.task.file?.name || "unknown";
321
349
  const projectRoot = process.cwd();
322
- const testFile = absolutePath !== 'unknown'
323
- ? path.relative(projectRoot, absolutePath)
324
- : absolutePath;
325
-
350
+ const testFile =
351
+ absolutePath !== "unknown"
352
+ ? path.relative(projectRoot, absolutePath)
353
+ : absolutePath;
354
+
326
355
  // Set basic metadata that's always available
327
356
  context.task.meta.platform = platform;
328
357
  context.task.meta.testFile = testFile;
329
358
  context.task.meta.testOrder = 0;
330
359
  context.task.meta.sessionId = currentInstance.getSessionId?.() || null;
331
-
360
+
332
361
  // Stop dashcam if it was started - with timeout to prevent hanging
333
362
  if (currentInstance._dashcam && currentInstance._dashcam.recording) {
334
363
  try {
335
364
  const dashcamUrl = await currentInstance.dashcam.stop();
336
- console.log('');
337
- console.log('đŸŽĨ' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
338
- console.log('');
339
-
340
365
  // Add dashcam URL to metadata
341
366
  context.task.meta.dashcamUrl = dashcamUrl || null;
342
-
367
+
343
368
  // Also register in memory if plugin is available (for cross-process scenarios)
344
369
  if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
345
- globalThis.__testdriverPlugin.registerDashcamUrl(context.task.id, dashcamUrl, platform);
370
+ globalThis.__testdriverPlugin.registerDashcamUrl(
371
+ context.task.id,
372
+ dashcamUrl,
373
+ platform,
374
+ );
375
+ }
376
+
377
+ const debugMode =
378
+ process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
379
+
380
+ if (debugMode) {
381
+ console.log("");
382
+ console.log(
383
+ "đŸŽĨ" + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`,
384
+ );
385
+ console.log("");
346
386
  }
347
387
  } catch (error) {
348
388
  // Log more detailed error information for debugging
349
- console.error('❌ Failed to stop dashcam:', error.name || error.constructor?.name || 'Error');
350
- if (error.message) console.error(' Message:', error.message);
389
+ console.error(
390
+ "❌ Failed to stop dashcam:",
391
+ error.name || error.constructor?.name || "Error",
392
+ );
393
+ if (error.message) console.error(" Message:", error.message);
351
394
  // NotFoundError during cleanup is expected if sandbox already terminated
352
- if (error.name === 'NotFoundError' || error.responseData?.error === 'NotFoundError') {
353
- console.log(' â„šī¸ Sandbox session already terminated - dashcam stop skipped');
395
+ if (
396
+ error.name === "NotFoundError" ||
397
+ error.responseData?.error === "NotFoundError"
398
+ ) {
399
+ console.log(
400
+ " â„šī¸ Sandbox session already terminated - dashcam stop skipped",
401
+ );
354
402
  }
355
403
  // Mark as not recording to prevent retries
356
404
  if (currentInstance._dashcam) {
@@ -363,34 +411,35 @@ export function TestDriver(context, options = {}) {
363
411
  // No dashcam recording, set URL to null explicitly
364
412
  context.task.meta.dashcamUrl = null;
365
413
  }
366
-
414
+
367
415
  // Clean up console spies
368
416
  cleanupConsoleSpy(currentInstance);
369
-
417
+
370
418
  // Wait for connection to finish if it was initiated
371
419
  if (currentInstance.__connectionPromise) {
372
420
  await currentInstance.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
373
421
  }
374
-
422
+
375
423
  // Disconnect with timeout
376
424
  await Promise.race([
377
425
  currentInstance.disconnect(),
378
- new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
426
+ new Promise((resolve) => setTimeout(resolve, 5000)), // 5s timeout for disconnect
379
427
  ]);
380
-
428
+ } catch (error) {
429
+ console.error("Error disconnecting client:", error);
430
+ } finally {
381
431
  // Terminate AWS instance if one was spawned for this test
382
432
  // This must happen AFTER dashcam.stop() to ensure recording is saved
433
+ // AND it must happen even if disconnect() fails
383
434
  if (globalThis.__testdriverAWS?.terminateInstance) {
384
435
  await globalThis.__testdriverAWS.terminateInstance(context.task.id);
385
436
  }
386
- } catch (error) {
387
- console.error('Error disconnecting client:', error);
388
437
  }
389
438
  };
390
439
  lifecycleHandlers.set(context.task, cleanup);
391
-
440
+
392
441
  // Vitest will call this automatically after the test (each retry attempt)
393
442
  context.onTestFinished?.(cleanup);
394
-
443
+
395
444
  return testdriver;
396
445
  }