testdriverai 7.2.9 → 7.2.11
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/.github/workflows/testdriver.yml +127 -0
- package/.testdriver/last-sandbox +7 -0
- package/agent/events.js +1 -0
- package/agent/index.js +71 -54
- package/agent/lib/sandbox.js +11 -1
- package/agents.md +393 -0
- package/debug/01-table-initial.png +0 -0
- package/debug/02-after-ai-explore.png +0 -0
- package/debug/02-after-scroll.png +0 -0
- package/docs/docs.json +87 -126
- package/docs/v7/_drafts/caching.mdx +2 -2
- package/docs/v7/{getting-started → _drafts}/installation.mdx +0 -66
- package/docs/v7/{features/coverage.mdx → _drafts/powerful.mdx} +1 -90
- package/docs/v7/{features → _drafts}/scalable.mdx +126 -4
- package/docs/v7/_drafts/screenshot.mdx +155 -0
- package/docs/v7/_drafts/writing-tests.mdx +25 -0
- package/docs/v7/{api/act.mdx → ai.mdx} +27 -27
- package/docs/v7/{api/assert.mdx → assert.mdx} +3 -3
- package/docs/v7/aws-setup.mdx +338 -0
- package/docs/v7/caching.mdx +128 -0
- package/docs/v7/ci-cd.mdx +605 -0
- package/docs/v7/{api/click.mdx → click.mdx} +4 -4
- package/docs/v7/cloud.mdx +120 -0
- package/docs/v7/customizing-devices.mdx +129 -0
- package/docs/v7/{api/dashcam.mdx → dashcam.mdx} +0 -78
- package/docs/v7/{api/doubleClick.mdx → double-click.mdx} +5 -5
- package/docs/v7/{api/elements.mdx → elements.mdx} +1 -54
- package/docs/v7/enterprise.mdx +116 -0
- package/docs/v7/examples.mdx +5 -0
- package/docs/v7/{api/exec.mdx → exec.mdx} +3 -3
- package/docs/v7/{api/find.mdx → find.mdx} +17 -21
- package/docs/v7/{api/focusApplication.mdx → focus-application.mdx} +3 -3
- package/docs/v7/generating-tests.mdx +36 -0
- package/docs/v7/{api/hover.mdx → hover.mdx} +3 -3
- package/docs/v7/locating-elements.mdx +71 -0
- package/docs/v7/making-assertions.mdx +32 -0
- package/docs/v7/{api/mouseDown.mdx → mouse-down.mdx} +7 -7
- package/docs/v7/{api/mouseUp.mdx → mouse-up.mdx} +8 -8
- package/docs/v7/performing-actions.mdx +51 -0
- package/docs/v7/{api/pressKeys.mdx → press-keys.mdx} +3 -3
- package/docs/v7/quickstart.mdx +162 -0
- package/docs/v7/reusable-code.mdx +240 -0
- package/docs/v7/{api/rightClick.mdx → right-click.mdx} +5 -5
- package/docs/v7/running-tests.mdx +181 -0
- package/docs/v7/{api/scroll.mdx → scroll.mdx} +3 -3
- package/docs/v7/secrets.mdx +115 -0
- package/docs/v7/self-hosted.mdx +66 -0
- package/docs/v7/{api/type.mdx → type.mdx} +3 -3
- package/docs/v7/variables.mdx +111 -0
- package/docs/v7/waiting-for-elements.mdx +66 -0
- package/docs/v7/what-is-testdriver.mdx +54 -0
- package/lib/vitest/hooks.mjs +80 -68
- package/package.json +1 -1
- package/sdk.d.ts +22 -9
- package/sdk.js +177 -44
- package/test/manual/reconnect-provision.test.mjs +49 -0
- package/test/manual/reconnect-signin.test.mjs +41 -0
- package/test/testdriver/ai.test.mjs +30 -0
- package/test/testdriver/setup/testHelpers.mjs +0 -1
- package/test/testdriver/windows-installer.test.mjs +61 -0
- package/tests/table-sort-enrollments.test.mjs +72 -0
- package/tests/table-sort-experiment.test.mjs +42 -0
- package/tests/table-sort-setup.test.mjs +59 -0
- package/vitest.config.mjs +1 -0
- package/docs/v7/api/assertions.mdx +0 -403
- package/docs/v7/api/sandbox.mdx +0 -404
- package/docs/v7/features/ai-native.mdx +0 -413
- package/docs/v7/features/application-logs.mdx +0 -353
- package/docs/v7/features/browser-logs.mdx +0 -414
- package/docs/v7/features/cache-management.mdx +0 -402
- package/docs/v7/features/continuous-testing.mdx +0 -346
- package/docs/v7/features/data-driven-testing.mdx +0 -441
- package/docs/v7/features/easy-to-write.mdx +0 -280
- package/docs/v7/features/enterprise.mdx +0 -656
- package/docs/v7/features/fast.mdx +0 -406
- package/docs/v7/features/managed-sandboxes.mdx +0 -384
- package/docs/v7/features/network-monitoring.mdx +0 -568
- package/docs/v7/features/parallel-execution.mdx +0 -381
- package/docs/v7/features/powerful.mdx +0 -531
- package/docs/v7/features/sandbox-customization.mdx +0 -229
- package/docs/v7/features/stable.mdx +0 -473
- package/docs/v7/features/system-performance.mdx +0 -616
- package/docs/v7/features/test-analytics.mdx +0 -373
- package/docs/v7/features/test-cases.mdx +0 -393
- package/docs/v7/features/test-replays.mdx +0 -408
- package/docs/v7/features/test-reports.mdx +0 -308
- package/docs/v7/getting-started/debugging-tests.mdx +0 -382
- package/docs/v7/getting-started/quickstart.mdx +0 -90
- package/docs/v7/getting-started/running-tests.mdx +0 -173
- package/docs/v7/getting-started/setting-up-in-ci.mdx +0 -612
- package/docs/v7/getting-started/writing-tests.mdx +0 -534
- package/docs/v7/overview/what-is-testdriver.mdx +0 -386
- package/docs/v7/presets/chrome-extension.mdx +0 -248
- package/docs/v7/presets/chrome.mdx +0 -300
- package/docs/v7/presets/electron.mdx +0 -460
- package/docs/v7/presets/vscode.mdx +0 -417
- package/docs/v7/presets/webapp.mdx +0 -393
- package/vitest.config.js +0 -18
- /package/docs/v7/{commands → _drafts/commands}/assert.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/exec.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/focus-application.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/hover-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/hover-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/if.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/match-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/press-keys.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/remember.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/run.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll-until-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll-until-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/scroll.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/type.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait-for-image.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait-for-text.mdx +0 -0
- /package/docs/v7/{commands → _drafts/commands}/wait.mdx +0 -0
- /package/docs/v7/{getting-started → _drafts}/configuration.mdx +0 -0
- /package/docs/v7/{features → _drafts}/observable.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/linux.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/macos.mdx +0 -0
- /package/docs/v7/{platforms → _drafts/platforms}/windows.mdx +0 -0
- /package/docs/v7/{playwright.mdx → _drafts/playwright.mdx} +0 -0
- /package/docs/v7/{overview → _drafts}/readme.mdx +0 -0
- /package/docs/v7/{features → _drafts}/reports.mdx +0 -0
- /package/docs/v7/{api/client.mdx → client.mdx} +0 -0
package/lib/vitest/hooks.mjs
CHANGED
|
@@ -154,9 +154,16 @@ export function TestDriver(context, options = {}) {
|
|
|
154
154
|
throw new Error('TestDriver() requires Vitest context. Pass the context parameter from your test function: test("name", async (context) => { ... })');
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// Return existing instance if already created for this test
|
|
157
|
+
// Return existing instance if already created for this test AND it's still connected
|
|
158
|
+
// On retry, the previous instance will be disconnected, so we need to create a new one
|
|
158
159
|
if (testDriverInstances.has(context.task)) {
|
|
159
|
-
|
|
160
|
+
const existingInstance = testDriverInstances.get(context.task);
|
|
161
|
+
if (existingInstance.connected) {
|
|
162
|
+
return existingInstance;
|
|
163
|
+
}
|
|
164
|
+
// Instance exists but is disconnected (likely a retry) - remove it and create fresh
|
|
165
|
+
testDriverInstances.delete(context.task);
|
|
166
|
+
lifecycleHandlers.delete(context.task);
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
// Get global plugin options if available
|
|
@@ -194,7 +201,6 @@ export function TestDriver(context, options = {}) {
|
|
|
194
201
|
|
|
195
202
|
if (autoConnect) {
|
|
196
203
|
testdriver.__connectionPromise = (async () => {
|
|
197
|
-
console.log('[testdriver] Connecting to sandbox...');
|
|
198
204
|
if (debugConsoleSpy) {
|
|
199
205
|
console.log('[DEBUG] Before auth - sandbox.instanceSocketConnected:', testdriver.sandbox?.instanceSocketConnected);
|
|
200
206
|
}
|
|
@@ -231,74 +237,80 @@ export function TestDriver(context, options = {}) {
|
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
// Register cleanup handler with dashcam.stop()
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
240
|
+
// We always register a new cleanup handler because on retry we need to clean up the new instance
|
|
241
|
+
const cleanup = async () => {
|
|
242
|
+
// Get the current instance from the WeakMap (not from closure)
|
|
243
|
+
// This ensures we clean up the correct instance on retries
|
|
244
|
+
const currentInstance = testDriverInstances.get(context.task);
|
|
245
|
+
if (!currentInstance) {
|
|
246
|
+
return; // Already cleaned up
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
// Stop dashcam if it was started - with timeout to prevent hanging
|
|
251
|
+
if (currentInstance._dashcam && currentInstance._dashcam.recording) {
|
|
252
|
+
try {
|
|
253
|
+
const dashcamUrl = await currentInstance.dashcam.stop();
|
|
254
|
+
console.log('');
|
|
255
|
+
console.log('🎥' + chalk.yellow(` Dashcam URL`) + `: ${dashcamUrl}`);
|
|
256
|
+
console.log('');
|
|
257
|
+
|
|
258
|
+
// Set test metadata directly on the Vitest task context
|
|
259
|
+
// This is the proper way to pass data from test to reporter
|
|
260
|
+
const platform = currentInstance.os || 'linux';
|
|
261
|
+
const absolutePath = context.task.file?.filepath || context.task.file?.name || 'unknown';
|
|
262
|
+
const projectRoot = process.cwd();
|
|
263
|
+
const testFile = absolutePath !== 'unknown'
|
|
264
|
+
? path.relative(projectRoot, absolutePath)
|
|
265
|
+
: absolutePath;
|
|
266
|
+
|
|
267
|
+
// Set metadata on the task for the reporter to read
|
|
268
|
+
context.task.meta.dashcamUrl = dashcamUrl || null;
|
|
269
|
+
context.task.meta.platform = platform;
|
|
270
|
+
context.task.meta.testFile = testFile;
|
|
271
|
+
context.task.meta.testOrder = 0;
|
|
272
|
+
context.task.meta.sessionId = currentInstance.getSessionId();
|
|
273
|
+
|
|
274
|
+
// Also register in memory if plugin is available (for cross-process scenarios)
|
|
275
|
+
if (globalThis.__testdriverPlugin?.registerDashcamUrl) {
|
|
276
|
+
globalThis.__testdriverPlugin.registerDashcamUrl(context.task.id, dashcamUrl, platform);
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// Log more detailed error information for debugging
|
|
280
|
+
console.error('❌ Failed to stop dashcam:', error.name || error.constructor?.name || 'Error');
|
|
281
|
+
if (error.message) console.error(' Message:', error.message);
|
|
282
|
+
// NotFoundError during cleanup is expected if sandbox already terminated
|
|
283
|
+
if (error.name === 'NotFoundError' || error.responseData?.error === 'NotFoundError') {
|
|
284
|
+
console.log(' ℹ️ Sandbox session already terminated - dashcam stop skipped');
|
|
285
|
+
}
|
|
286
|
+
// Mark as not recording to prevent retries
|
|
287
|
+
if (currentInstance._dashcam) {
|
|
288
|
+
currentInstance._dashcam.recording = false;
|
|
277
289
|
}
|
|
278
290
|
}
|
|
279
|
-
|
|
280
|
-
// Clean up console spies
|
|
281
|
-
cleanupConsoleSpy(testdriver);
|
|
282
|
-
|
|
283
|
-
// Wait for connection to finish if it was initiated
|
|
284
|
-
if (testdriver.__connectionPromise) {
|
|
285
|
-
await testdriver.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Disconnect with timeout
|
|
289
|
-
await Promise.race([
|
|
290
|
-
testdriver.disconnect(),
|
|
291
|
-
new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
|
|
292
|
-
]);
|
|
293
|
-
} catch (error) {
|
|
294
|
-
console.error('Error disconnecting client:', error);
|
|
295
291
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
292
|
+
|
|
293
|
+
// Clean up console spies
|
|
294
|
+
cleanupConsoleSpy(currentInstance);
|
|
295
|
+
|
|
296
|
+
// Wait for connection to finish if it was initiated
|
|
297
|
+
if (currentInstance.__connectionPromise) {
|
|
298
|
+
await currentInstance.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Disconnect with timeout
|
|
302
|
+
await Promise.race([
|
|
303
|
+
currentInstance.disconnect(),
|
|
304
|
+
new Promise((resolve) => setTimeout(resolve, 5000)) // 5s timeout for disconnect
|
|
305
|
+
]);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error('Error disconnecting client:', error);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
lifecycleHandlers.set(context.task, cleanup);
|
|
311
|
+
|
|
312
|
+
// Vitest will call this automatically after the test (each retry attempt)
|
|
313
|
+
context.onTestFinished?.(cleanup);
|
|
302
314
|
|
|
303
315
|
return testdriver;
|
|
304
316
|
}
|
package/package.json
CHANGED
package/sdk.d.ts
CHANGED
|
@@ -242,6 +242,8 @@ export interface TestDriverOptions {
|
|
|
242
242
|
sandboxInstance?: string;
|
|
243
243
|
/** Cache key for element finding operations. If provided, enables caching tied to this key */
|
|
244
244
|
cacheKey?: string;
|
|
245
|
+
/** Reconnect to the last used sandbox (throws error if no last sandbox exists) */
|
|
246
|
+
reconnect?: boolean;
|
|
245
247
|
/** Redraw configuration for screen change detection */
|
|
246
248
|
redraw?: boolean | {
|
|
247
249
|
/** Enable redraw detection (default: true) */
|
|
@@ -264,6 +266,8 @@ export interface ConnectOptions {
|
|
|
264
266
|
sandboxId?: string;
|
|
265
267
|
/** Force creation of a new sandbox */
|
|
266
268
|
newSandbox?: boolean;
|
|
269
|
+
/** Reconnect to the last used sandbox (throws error if no last sandbox exists) */
|
|
270
|
+
reconnect?: boolean;
|
|
267
271
|
/** Direct IP address to connect to a running sandbox instance */
|
|
268
272
|
ip?: string;
|
|
269
273
|
/** Custom AMI ID for sandbox instance (e.g., 'ami-1234') */
|
|
@@ -276,6 +280,8 @@ export interface ConnectOptions {
|
|
|
276
280
|
headless?: boolean;
|
|
277
281
|
/** Reuse recent connection if available (default: true) */
|
|
278
282
|
reuseConnection?: boolean;
|
|
283
|
+
/** Keep sandbox alive for specified milliseconds after disconnect (default: 60000). Set to 0 to terminate immediately on disconnect. */
|
|
284
|
+
keepAlive?: number;
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
export interface SandboxInstance {
|
|
@@ -695,6 +701,18 @@ export default class TestDriverSDK {
|
|
|
695
701
|
*/
|
|
696
702
|
disconnect(): Promise<void>;
|
|
697
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Get the last sandbox info from the stored file
|
|
706
|
+
* @returns Last sandbox info or null if not found
|
|
707
|
+
*/
|
|
708
|
+
getLastSandboxId(): {
|
|
709
|
+
sandboxId: string | null;
|
|
710
|
+
os: 'windows' | 'linux';
|
|
711
|
+
ami: string | null;
|
|
712
|
+
instanceType: string | null;
|
|
713
|
+
timestamp: string | null;
|
|
714
|
+
} | null;
|
|
715
|
+
|
|
698
716
|
// Element Finding API
|
|
699
717
|
|
|
700
718
|
/**
|
|
@@ -702,7 +720,7 @@ export default class TestDriverSDK {
|
|
|
702
720
|
* Automatically locates the element and returns it
|
|
703
721
|
*
|
|
704
722
|
* @param description - Description of the element to find
|
|
705
|
-
* @param
|
|
723
|
+
* @param options - Cache threshold (number) or options object
|
|
706
724
|
* @returns Chainable promise that resolves to Element instance
|
|
707
725
|
*
|
|
708
726
|
* @example
|
|
@@ -719,17 +737,12 @@ export default class TestDriverSDK {
|
|
|
719
737
|
* const element = await client.find('login button', 0.01);
|
|
720
738
|
*
|
|
721
739
|
* @example
|
|
722
|
-
* // Poll
|
|
723
|
-
*
|
|
724
|
-
* while (!element?.found()) {
|
|
725
|
-
* element = await client.find('login button');
|
|
726
|
-
* if (!element.found()) {
|
|
727
|
-
* await new Promise(resolve => setTimeout(resolve, 1000));
|
|
728
|
-
* }
|
|
729
|
-
* }
|
|
740
|
+
* // Poll for element with timeout (retries every 5 seconds)
|
|
741
|
+
* const element = await client.find('loading complete indicator', { timeout: 30000 });
|
|
730
742
|
* await element.click();
|
|
731
743
|
*/
|
|
732
744
|
find(description: string, cacheThreshold?: number): ChainableElementPromise;
|
|
745
|
+
find(description: string, options?: { cacheThreshold?: number; cacheKey?: string; timeout?: number }): ChainableElementPromise;
|
|
733
746
|
|
|
734
747
|
/**
|
|
735
748
|
* Find all elements matching a description
|
package/sdk.js
CHANGED
|
@@ -372,10 +372,17 @@ class Element {
|
|
|
372
372
|
/**
|
|
373
373
|
* Find the element on screen
|
|
374
374
|
* @param {string} [newDescription] - Optional new description to search for
|
|
375
|
-
* @param {Object} [options] - Optional options object with cacheThreshold and/or
|
|
375
|
+
* @param {Object} [options] - Optional options object with cacheThreshold, cacheKey, and/or timeout
|
|
376
|
+
* @param {number} [options.timeout] - Max time in ms to poll for element (polls every 5 seconds)
|
|
376
377
|
* @returns {Promise<Element>} This element instance
|
|
377
378
|
*/
|
|
378
379
|
async find(newDescription, options) {
|
|
380
|
+
// Handle timeout/polling option
|
|
381
|
+
const timeout = typeof options === 'object' ? options?.timeout : null;
|
|
382
|
+
if (timeout && timeout > 0) {
|
|
383
|
+
return this._findWithTimeout(newDescription, options, timeout);
|
|
384
|
+
}
|
|
385
|
+
|
|
379
386
|
const description = newDescription || this.description;
|
|
380
387
|
if (newDescription) {
|
|
381
388
|
this.description = newDescription;
|
|
@@ -526,6 +533,61 @@ class Element {
|
|
|
526
533
|
return this;
|
|
527
534
|
}
|
|
528
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Find element with polling/timeout support
|
|
538
|
+
* @private
|
|
539
|
+
* @param {string} [newDescription] - Optional new description to search for
|
|
540
|
+
* @param {Object} options - Options object
|
|
541
|
+
* @param {number} timeout - Max time in ms to poll for element
|
|
542
|
+
* @returns {Promise<Element>} This element instance
|
|
543
|
+
*/
|
|
544
|
+
async _findWithTimeout(newDescription, options, timeout) {
|
|
545
|
+
const POLL_INTERVAL = 5000; // 5 seconds between attempts
|
|
546
|
+
const startTime = Date.now();
|
|
547
|
+
const description = newDescription || this.description;
|
|
548
|
+
|
|
549
|
+
// Log that we're starting a polling find
|
|
550
|
+
const { events } = require("./agent/events.js");
|
|
551
|
+
this.sdk.emitter.emit(events.log.log, `🔄 Polling for "${description}" (timeout: ${timeout}ms)`);
|
|
552
|
+
|
|
553
|
+
// Create options without timeout to avoid infinite recursion
|
|
554
|
+
const findOptions = typeof options === 'object' ? { ...options } : {};
|
|
555
|
+
delete findOptions.timeout;
|
|
556
|
+
|
|
557
|
+
let attempts = 0;
|
|
558
|
+
while (Date.now() - startTime < timeout) {
|
|
559
|
+
attempts++;
|
|
560
|
+
|
|
561
|
+
// Call the regular find (without timeout option)
|
|
562
|
+
await this.find(newDescription, findOptions);
|
|
563
|
+
|
|
564
|
+
if (this._found) {
|
|
565
|
+
this.sdk.emitter.emit(events.log.log, `✅ Found "${description}" after ${attempts} attempt(s)`);
|
|
566
|
+
return this;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const elapsed = Date.now() - startTime;
|
|
570
|
+
const remaining = timeout - elapsed;
|
|
571
|
+
|
|
572
|
+
if (remaining > POLL_INTERVAL) {
|
|
573
|
+
this.sdk.emitter.emit(events.log.log, `⏳ Element not found, retrying in 5s... (${Math.round(remaining / 1000)}s remaining)`);
|
|
574
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
|
|
575
|
+
} else if (remaining > 0) {
|
|
576
|
+
// Less than 5s remaining, wait the remaining time and try once more
|
|
577
|
+
await new Promise(resolve => setTimeout(resolve, remaining));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Final attempt after timeout
|
|
582
|
+
await this.find(newDescription, findOptions);
|
|
583
|
+
|
|
584
|
+
if (!this._found) {
|
|
585
|
+
this.sdk.emitter.emit(events.log.log, `❌ Element "${description}" not found after ${timeout}ms (${attempts} attempts)`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return this;
|
|
589
|
+
}
|
|
590
|
+
|
|
529
591
|
/**
|
|
530
592
|
* Sanitize response by removing large base64 data to prevent memory leaks
|
|
531
593
|
* @private
|
|
@@ -1183,6 +1245,9 @@ class TestDriverSDK {
|
|
|
1183
1245
|
this.sandboxAmi = options.sandboxAmi || null;
|
|
1184
1246
|
this.sandboxInstance = options.sandboxInstance || null;
|
|
1185
1247
|
|
|
1248
|
+
// Store reconnect preference from options
|
|
1249
|
+
this.reconnect = options.reconnect !== undefined ? options.reconnect : false;
|
|
1250
|
+
|
|
1186
1251
|
// Cache threshold configuration
|
|
1187
1252
|
// threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
|
|
1188
1253
|
// By default, cache is DISABLED (threshold = -1) to avoid unnecessary AI costs
|
|
@@ -1460,22 +1525,19 @@ class TestDriverSDK {
|
|
|
1460
1525
|
* @param {Object} options - Chrome extension launch options
|
|
1461
1526
|
* @param {string} [options.extensionPath] - Local filesystem path to the unpacked extension directory
|
|
1462
1527
|
* @param {string} [options.extensionId] - Chrome Web Store extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm" for uBlock Origin)
|
|
1463
|
-
* @param {string} [options.url='http://testdriver-sandbox.vercel.app/'] - URL to navigate to
|
|
1464
1528
|
* @param {boolean} [options.maximized=true] - Start maximized
|
|
1465
1529
|
* @returns {Promise<void>}
|
|
1466
1530
|
* @example
|
|
1467
1531
|
* // Load extension from local path
|
|
1468
1532
|
* await testdriver.exec('sh', 'git clone https://github.com/user/extension.git /tmp/extension');
|
|
1469
1533
|
* await testdriver.provision.chromeExtension({
|
|
1470
|
-
* extensionPath: '/tmp/extension'
|
|
1471
|
-
* url: 'https://example.com'
|
|
1534
|
+
* extensionPath: '/tmp/extension'
|
|
1472
1535
|
* });
|
|
1473
1536
|
*
|
|
1474
1537
|
* @example
|
|
1475
1538
|
* // Load extension by Chrome Web Store ID
|
|
1476
1539
|
* await testdriver.provision.chromeExtension({
|
|
1477
|
-
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm'
|
|
1478
|
-
* url: 'https://example.com'
|
|
1540
|
+
* extensionId: 'cjpalhdlnbpafiamejdnhcphjbkeiagm' // uBlock Origin
|
|
1479
1541
|
* });
|
|
1480
1542
|
*/
|
|
1481
1543
|
chromeExtension: async (options = {}) => {
|
|
@@ -1485,7 +1547,6 @@ class TestDriverSDK {
|
|
|
1485
1547
|
const {
|
|
1486
1548
|
extensionPath: providedExtensionPath,
|
|
1487
1549
|
extensionId,
|
|
1488
|
-
url = 'http://testdriver-sandbox.vercel.app/',
|
|
1489
1550
|
maximized = true,
|
|
1490
1551
|
} = options;
|
|
1491
1552
|
|
|
@@ -1603,7 +1664,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1603
1664
|
console.log(`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`);
|
|
1604
1665
|
}
|
|
1605
1666
|
|
|
1606
|
-
// If dashcam is available
|
|
1667
|
+
// If dashcam is available, set up file logging
|
|
1607
1668
|
if (this._dashcam) {
|
|
1608
1669
|
// Create the log file on the remote machine
|
|
1609
1670
|
const logPath = this.os === "windows"
|
|
@@ -1615,11 +1676,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1615
1676
|
: `touch ${logPath}`;
|
|
1616
1677
|
|
|
1617
1678
|
await this.exec(shell, createLogCmd, 10000, true);
|
|
1618
|
-
|
|
1619
|
-
const urlObj = new URL(url);
|
|
1620
|
-
const domain = urlObj.hostname;
|
|
1621
|
-
const pattern = `*${domain}*`;
|
|
1622
|
-
await this._dashcam.addWebLog(pattern, 'Web Logs');
|
|
1623
1679
|
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1624
1680
|
}
|
|
1625
1681
|
|
|
@@ -1695,19 +1751,19 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1695
1751
|
chromeArgs.push(`--load-extension=${extensionPath}`);
|
|
1696
1752
|
}
|
|
1697
1753
|
|
|
1698
|
-
// Launch Chrome
|
|
1754
|
+
// Launch Chrome (opens to New Tab by default)
|
|
1699
1755
|
if (this.os === 'windows') {
|
|
1700
1756
|
const argsString = chromeArgs.map(arg => `"${arg}"`).join(', ');
|
|
1701
1757
|
await this.exec(
|
|
1702
1758
|
shell,
|
|
1703
|
-
`Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList ${argsString}
|
|
1759
|
+
`Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList ${argsString}`,
|
|
1704
1760
|
30000
|
|
1705
1761
|
);
|
|
1706
1762
|
} else {
|
|
1707
1763
|
const argsString = chromeArgs.join(' ');
|
|
1708
1764
|
await this.exec(
|
|
1709
1765
|
shell,
|
|
1710
|
-
`chrome-for-testing ${argsString}
|
|
1766
|
+
`chrome-for-testing ${argsString} >/dev/null 2>&1 &`,
|
|
1711
1767
|
30000
|
|
1712
1768
|
);
|
|
1713
1769
|
}
|
|
@@ -1715,25 +1771,18 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1715
1771
|
// Wait for Chrome to be ready
|
|
1716
1772
|
await this.focusApplication('Google Chrome');
|
|
1717
1773
|
|
|
1718
|
-
// Wait for
|
|
1719
|
-
|
|
1720
|
-
const
|
|
1721
|
-
const domain = urlObj.hostname;
|
|
1722
|
-
|
|
1723
|
-
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1724
|
-
const result = await this.find(`${domain}`);
|
|
1774
|
+
// Wait for New Tab to appear
|
|
1775
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
1776
|
+
const result = await this.find('New Tab');
|
|
1725
1777
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
}
|
|
1778
|
+
if (result.found()) {
|
|
1779
|
+
break;
|
|
1780
|
+
} else {
|
|
1781
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1731
1782
|
}
|
|
1732
|
-
|
|
1733
|
-
await this.focusApplication('Google Chrome');
|
|
1734
|
-
} catch (e) {
|
|
1735
|
-
console.warn(`[provision.chromeExtension] ⚠️ Could not parse URL "${url}":`, e.message);
|
|
1736
1783
|
}
|
|
1784
|
+
|
|
1785
|
+
await this.focusApplication('Google Chrome');
|
|
1737
1786
|
},
|
|
1738
1787
|
|
|
1739
1788
|
/**
|
|
@@ -1900,33 +1949,82 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1900
1949
|
);
|
|
1901
1950
|
}
|
|
1902
1951
|
|
|
1903
|
-
|
|
1952
|
+
// Check if the downloaded file has a proper extension, if not scan the download directory
|
|
1953
|
+
let actualFilePath = filePath;
|
|
1954
|
+
const hasValidExtension = /\.(msi|exe|deb|rpm|appimage|sh|dmg|pkg)$/i.test(detectedFilename);
|
|
1955
|
+
|
|
1956
|
+
if (!hasValidExtension && this.os === 'windows') {
|
|
1957
|
+
// On Windows, scan the download directory for .msi or .exe files
|
|
1958
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for .msi or .exe files...`);
|
|
1959
|
+
const scanResult = await this.exec(
|
|
1960
|
+
shell,
|
|
1961
|
+
`Get-ChildItem -Path "${downloadDir}" -File | Where-Object { $_.Extension -match '\\.(msi|exe)$' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`,
|
|
1962
|
+
30000,
|
|
1963
|
+
true
|
|
1964
|
+
);
|
|
1965
|
+
|
|
1966
|
+
if (scanResult && scanResult.trim()) {
|
|
1967
|
+
actualFilePath = scanResult.trim();
|
|
1968
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1969
|
+
}
|
|
1970
|
+
} else if (!hasValidExtension && this.os === 'linux') {
|
|
1971
|
+
// On Linux, scan for common installer extensions
|
|
1972
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for installer files...`);
|
|
1973
|
+
const scanResult = await this.exec(
|
|
1974
|
+
shell,
|
|
1975
|
+
`find "${downloadDir}" -maxdepth 1 -type f \\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.sh" \\) -printf '%T@ %p\\n' | sort -rn | head -1 | cut -d' ' -f2-`,
|
|
1976
|
+
30000,
|
|
1977
|
+
true
|
|
1978
|
+
);
|
|
1979
|
+
|
|
1980
|
+
if (scanResult && scanResult.trim()) {
|
|
1981
|
+
actualFilePath = scanResult.trim();
|
|
1982
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1983
|
+
}
|
|
1984
|
+
} else if (!hasValidExtension && this.os === 'darwin') {
|
|
1985
|
+
// On macOS, scan for common installer extensions
|
|
1986
|
+
console.log(`[provision.installer] Downloaded file has no extension, scanning for installer files...`);
|
|
1987
|
+
const scanResult = await this.exec(
|
|
1988
|
+
shell,
|
|
1989
|
+
`find "${downloadDir}" -maxdepth 1 -type f \\( -name "*.dmg" -o -name "*.pkg" \\) -print0 | xargs -0 ls -t | head -1`,
|
|
1990
|
+
30000,
|
|
1991
|
+
true
|
|
1992
|
+
);
|
|
1993
|
+
|
|
1994
|
+
if (scanResult && scanResult.trim()) {
|
|
1995
|
+
actualFilePath = scanResult.trim();
|
|
1996
|
+
console.log(`[provision.installer] Found installer: ${actualFilePath}`);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
console.log(`[provision.installer] ✅ Downloaded to ${actualFilePath}`);
|
|
1904
2001
|
|
|
1905
|
-
// Auto-detect install command based on file extension
|
|
1906
|
-
const
|
|
2002
|
+
// Auto-detect install command based on file extension (use actualFilePath for extension detection)
|
|
2003
|
+
const actualFilename = actualFilePath.split(/[/\\]/).pop() || '';
|
|
2004
|
+
const ext = actualFilename.split('.').pop()?.toLowerCase();
|
|
1907
2005
|
let installCommand = null;
|
|
1908
2006
|
|
|
1909
2007
|
if (this.os === 'windows') {
|
|
1910
2008
|
if (ext === 'msi') {
|
|
1911
|
-
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${
|
|
2009
|
+
installCommand = `Start-Process msiexec -ArgumentList '/i', '"${actualFilePath}"', '/quiet', '/norestart' -Wait`;
|
|
1912
2010
|
} else if (ext === 'exe') {
|
|
1913
|
-
installCommand = `Start-Process "${
|
|
2011
|
+
installCommand = `Start-Process "${actualFilePath}" -ArgumentList '/S' -Wait`;
|
|
1914
2012
|
}
|
|
1915
2013
|
} else if (this.os === 'linux') {
|
|
1916
2014
|
if (ext === 'deb') {
|
|
1917
|
-
installCommand = `sudo dpkg -i "${
|
|
2015
|
+
installCommand = `sudo dpkg -i "${actualFilePath}" && sudo apt-get install -f -y`;
|
|
1918
2016
|
} else if (ext === 'rpm') {
|
|
1919
|
-
installCommand = `sudo rpm -i "${
|
|
2017
|
+
installCommand = `sudo rpm -i "${actualFilePath}"`;
|
|
1920
2018
|
} else if (ext === 'appimage') {
|
|
1921
|
-
installCommand = `chmod +x "${
|
|
2019
|
+
installCommand = `chmod +x "${actualFilePath}"`;
|
|
1922
2020
|
} else if (ext === 'sh') {
|
|
1923
|
-
installCommand = `chmod +x "${
|
|
2021
|
+
installCommand = `chmod +x "${actualFilePath}" && "${actualFilePath}"`;
|
|
1924
2022
|
}
|
|
1925
2023
|
} else if (this.os === 'darwin') {
|
|
1926
2024
|
if (ext === 'dmg') {
|
|
1927
|
-
installCommand = `hdiutil attach "${
|
|
2025
|
+
installCommand = `hdiutil attach "${actualFilePath}" -mountpoint /Volumes/installer && cp -R /Volumes/installer/*.app /Applications/ && hdiutil detach /Volumes/installer`;
|
|
1928
2026
|
} else if (ext === 'pkg') {
|
|
1929
|
-
installCommand = `sudo installer -pkg "${
|
|
2027
|
+
installCommand = `sudo installer -pkg "${actualFilePath}" -target /`;
|
|
1930
2028
|
}
|
|
1931
2029
|
}
|
|
1932
2030
|
|
|
@@ -1942,7 +2040,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1942
2040
|
await this.focusApplication(appName);
|
|
1943
2041
|
}
|
|
1944
2042
|
|
|
1945
|
-
return
|
|
2043
|
+
return actualFilePath;
|
|
1946
2044
|
},
|
|
1947
2045
|
|
|
1948
2046
|
/**
|
|
@@ -2039,6 +2137,29 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2039
2137
|
: this.newSandbox,
|
|
2040
2138
|
};
|
|
2041
2139
|
|
|
2140
|
+
// Handle reconnect option - use last sandbox file
|
|
2141
|
+
// Check both connectOptions and constructor options
|
|
2142
|
+
const shouldReconnect = connectOptions.reconnect !== undefined
|
|
2143
|
+
? connectOptions.reconnect
|
|
2144
|
+
: this.reconnect;
|
|
2145
|
+
|
|
2146
|
+
if (shouldReconnect) {
|
|
2147
|
+
const lastSandbox = this.agent.getLastSandboxId();
|
|
2148
|
+
if (!lastSandbox || !lastSandbox.sandboxId) {
|
|
2149
|
+
throw new Error(
|
|
2150
|
+
"Cannot reconnect: No previous sandbox found. Run a test first to create a sandbox, or remove the reconnect option."
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
this.agent.sandboxId = lastSandbox.sandboxId;
|
|
2154
|
+
buildEnvOptions.new = false;
|
|
2155
|
+
|
|
2156
|
+
// Use OS from last sandbox if not explicitly specified
|
|
2157
|
+
if (!connectOptions.os && lastSandbox.os) {
|
|
2158
|
+
this.agent.sandboxOs = lastSandbox.os;
|
|
2159
|
+
this.os = lastSandbox.os;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2042
2163
|
// Set agent properties for buildEnv to use
|
|
2043
2164
|
if (connectOptions.sandboxId) {
|
|
2044
2165
|
this.agent.sandboxId = connectOptions.sandboxId;
|
|
@@ -2068,6 +2189,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2068
2189
|
} else {
|
|
2069
2190
|
this.agent.sandboxOs = this.os;
|
|
2070
2191
|
}
|
|
2192
|
+
// Use keepAlive from connectOptions if provided
|
|
2193
|
+
if (connectOptions.keepAlive !== undefined) {
|
|
2194
|
+
this.agent.keepAlive = connectOptions.keepAlive;
|
|
2195
|
+
}
|
|
2071
2196
|
|
|
2072
2197
|
// Set redrawThreshold on agent's cliArgs.options
|
|
2073
2198
|
this.agent.cliArgs.options.redrawThreshold = this.redrawThreshold;
|
|
@@ -2150,6 +2275,14 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2150
2275
|
return this.session?.get() || null;
|
|
2151
2276
|
}
|
|
2152
2277
|
|
|
2278
|
+
/**
|
|
2279
|
+
* Get the last sandbox info from the stored file
|
|
2280
|
+
* @returns {Object|null} Last sandbox info including sandboxId, os, ami, instanceType, timestamp, or null if not found
|
|
2281
|
+
*/
|
|
2282
|
+
getLastSandboxId() {
|
|
2283
|
+
return this.agent.getLastSandboxId();
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2153
2286
|
// ====================================
|
|
2154
2287
|
// Element Finding API
|
|
2155
2288
|
// ====================================
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - Reconnect Test Part 1: Provision
|
|
3
|
+
*
|
|
4
|
+
* This test provisions a new sandbox and navigates to the login page.
|
|
5
|
+
* The sandbox ID is saved to .testdriver/last-sandbox for the next test.
|
|
6
|
+
*
|
|
7
|
+
* The sandbox has keepAlive: 120000 (2 minutes) after disconnect.
|
|
8
|
+
* Run reconnect-signin.test.mjs within 2 minutes of this test completing.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* 1. npm test -- test/testdriver/reconnect-provision.test.mjs
|
|
12
|
+
* 2. (within 2 minutes) npm test -- test/testdriver/reconnect-signin.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
16
|
+
import { TestDriver } from "../../lib/vitest/hooks.mjs";
|
|
17
|
+
|
|
18
|
+
describe("Reconnect Test - Part 1: Provision", () => {
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
// Explicitly DO NOT disconnect - we want the sandbox to stay alive
|
|
22
|
+
// for the reconnect test. The sandbox will auto-terminate after keepAlive TTL.
|
|
23
|
+
console.log("\n⚠️ NOT disconnecting - sandbox will stay alive for ~2 minutes (keepAlive: 120000)");
|
|
24
|
+
console.log(" Run reconnect-signin.test.mjs within 2 minutes to test reconnect\n");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should provision sandbox and navigate to login page", async (context) => {
|
|
28
|
+
|
|
29
|
+
const testdriver = TestDriver(context, { newSandbox: true, headless: false });
|
|
30
|
+
|
|
31
|
+
// Provision Chrome and navigate to login page
|
|
32
|
+
await testdriver.provision.chrome({
|
|
33
|
+
url: 'http://testdriver-sandbox.vercel.app/login',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
// Verify we're on the login page
|
|
38
|
+
const result = await testdriver.assert("I can see a Sign In button");
|
|
39
|
+
expect(result).toBeTruthy();
|
|
40
|
+
|
|
41
|
+
// Get the sandbox ID that was saved
|
|
42
|
+
const lastSandbox = testdriver.getLastSandboxId();
|
|
43
|
+
console.log("\n✅ Sandbox provisioned:", lastSandbox?.sandboxId);
|
|
44
|
+
console.log(" Sandbox info saved to .testdriver/last-sandbox");
|
|
45
|
+
|
|
46
|
+
expect(lastSandbox).toBeTruthy();
|
|
47
|
+
expect(lastSandbox.sandboxId).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
});
|