testdriverai 7.2.50 → 7.2.52
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/.testdriverai/screenshots/assert.test/screenshot-1769213095524.png +0 -0
- package/agents.md +65 -2
- package/docs/docs.json +1 -0
- package/docs/v7/device-config.mdx +0 -2
- package/docs/v7/screenshot.mdx +151 -0
- package/eslint.config.js +6 -0
- package/examples/assert.test.mjs +3 -0
- package/examples/chrome-extension.test.mjs +0 -3
- package/examples/no-provision.test.mjs +23 -0
- package/lib/vitest/hooks.mjs +39 -36
- package/package.json +1 -1
- package/sdk.d.ts +13 -6
- package/sdk.js +177 -350
- package/test/manual-unawaited-promise.test.mjs +31 -0
package/agents.md
CHANGED
|
@@ -88,11 +88,11 @@ The SDK has TypeScript types in `sdk.d.ts`. Key methods:
|
|
|
88
88
|
|--------|---------|
|
|
89
89
|
| `find(description)` | Find element by natural language |
|
|
90
90
|
| `findAll(description)` | Find all matching elements |
|
|
91
|
-
| `assert(assertion)` | AI-powered assertion |
|
|
92
|
-
| `type(text)` | Type text |
|
|
91
|
+
| `assert(assertion)` | AI-powered assertion || `screenshot()` | Capture and save screenshot locally || `type(text)` | Type text |
|
|
93
92
|
| `pressKeys([keys])` | Press keyboard keys |
|
|
94
93
|
| `scroll(direction)` | Scroll the page |
|
|
95
94
|
| `exec(language, code)` | Execute code in sandbox |
|
|
95
|
+
| `screenshot(scale, silent, mouse)` | Capture screenshot as base64 PNG |
|
|
96
96
|
| `ai(task)` | AI exploratory loop (see note below) |
|
|
97
97
|
|
|
98
98
|
### About `ai()` - Use for Exploration, Not Final Tests
|
|
@@ -146,6 +146,39 @@ await element.mouseUp(); // release mouse
|
|
|
146
146
|
element.found(); // check if found (boolean)
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Screenshots for Debugging
|
|
150
|
+
|
|
151
|
+
**Use `screenshot()` liberally during development** to see exactly what the sandbox screen looks like. Screenshots are saved locally and organized by test file.
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
// Capture a screenshot - saved to .testdriverai/screenshots/<test-file>/
|
|
155
|
+
const screenshotPath = await testdriver.screenshot();
|
|
156
|
+
console.log('Screenshot saved to:', screenshotPath);
|
|
157
|
+
|
|
158
|
+
// Include mouse cursor in screenshot
|
|
159
|
+
await testdriver.screenshot(1, false, true);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**When to use screenshots:**
|
|
163
|
+
- After `provision.chrome()` to verify the page loaded correctly
|
|
164
|
+
- Before/after clicking elements to see state changes
|
|
165
|
+
- When a `find()` fails to see what the AI is actually seeing
|
|
166
|
+
- Before `assert()` calls to debug assertion failures
|
|
167
|
+
- When tests behave unexpectedly
|
|
168
|
+
|
|
169
|
+
**Screenshot file organization:**
|
|
170
|
+
```
|
|
171
|
+
.testdriverai/
|
|
172
|
+
screenshots/
|
|
173
|
+
login.test/ # Folder per test file
|
|
174
|
+
screenshot-1737633600000.png
|
|
175
|
+
screenshot-1737633605000.png
|
|
176
|
+
checkout.test/
|
|
177
|
+
screenshot-1737633700000.png
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
> **Note:** The screenshot folder for each test file is automatically cleared when the test starts, so you only see screenshots from the most recent run.
|
|
181
|
+
|
|
149
182
|
## Best Workflow: Two-File Pattern
|
|
150
183
|
|
|
151
184
|
**The most efficient workflow for building tests uses two files.** This prevents having to restart from scratch when experimenting with new steps.
|
|
@@ -308,6 +341,9 @@ it("should incrementally build test", async (context) => {
|
|
|
308
341
|
const testdriver = TestDriver(context);
|
|
309
342
|
await testdriver.provision.chrome({ url: 'https://example.com' });
|
|
310
343
|
|
|
344
|
+
// Take a screenshot to see the initial state
|
|
345
|
+
await testdriver.screenshot();
|
|
346
|
+
|
|
311
347
|
// Step 1: Find and inspect
|
|
312
348
|
const element = await testdriver.find("Some button");
|
|
313
349
|
console.log("Element found:", element.found());
|
|
@@ -317,6 +353,9 @@ it("should incrementally build test", async (context) => {
|
|
|
317
353
|
// Step 2: Interact
|
|
318
354
|
await element.click();
|
|
319
355
|
|
|
356
|
+
// Screenshot after interaction to see the result
|
|
357
|
+
await testdriver.screenshot();
|
|
358
|
+
|
|
320
359
|
// Step 3: Assert and log
|
|
321
360
|
const result = await testdriver.assert("Something happened");
|
|
322
361
|
console.log("Assertion result:", result);
|
|
@@ -382,6 +421,20 @@ const output = await testdriver.exec("sh", "ls -la", 5000);
|
|
|
382
421
|
const date = await testdriver.exec("pwsh", "Get-Date", 5000);
|
|
383
422
|
```
|
|
384
423
|
|
|
424
|
+
### Capturing Screenshots
|
|
425
|
+
```javascript
|
|
426
|
+
// Capture a screenshot and save to file
|
|
427
|
+
const screenshot = await testdriver.screenshot();
|
|
428
|
+
const filepath = 'screenshot.png';
|
|
429
|
+
fs.writeFileSync(filepath, Buffer.from(screenshot, 'base64'));
|
|
430
|
+
console.log('Screenshot saved to:', filepath);
|
|
431
|
+
|
|
432
|
+
// Capture with mouse cursor visible
|
|
433
|
+
const screenshotWithMouse = await testdriver.screenshot(1, false, true);
|
|
434
|
+
fs.writeFileSync('screenshot-with-mouse.png', Buffer.from(screenshotWithMouse, 'base64'));
|
|
435
|
+
console.log('Screenshot with mouse saved to: screenshot-with-mouse.png');
|
|
436
|
+
```
|
|
437
|
+
|
|
385
438
|
## Tips for Agents
|
|
386
439
|
|
|
387
440
|
1. **Always check `sdk.d.ts`** for method signatures and types
|
|
@@ -390,3 +443,13 @@ const date = await testdriver.exec("pwsh", "Get-Date", 5000);
|
|
|
390
443
|
4. **Log element properties** to understand what the AI sees
|
|
391
444
|
5. **Use `assert()` with specific, descriptive natural language**
|
|
392
445
|
6. **Start simple** - get one step working before adding more
|
|
446
|
+
7. **Take screenshots liberally** - use `await testdriver.screenshot()` after key steps to debug what the sandbox actually shows. Check `.testdriverai/screenshots/<test-file>/` to review them.
|
|
447
|
+
8. **Always `await` async methods** - TestDriver will warn if you forget, but for TypeScript projects, add `@typescript-eslint/no-floating-promises` to your ESLint config to catch missing `await` at compile time:
|
|
448
|
+
```json
|
|
449
|
+
// eslint.config.js (for TypeScript projects)
|
|
450
|
+
{
|
|
451
|
+
"rules": {
|
|
452
|
+
"@typescript-eslint/no-floating-promises": "error"
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
```
|
package/docs/docs.json
CHANGED
|
@@ -120,7 +120,6 @@ describe("Chrome Extension Test", () => {
|
|
|
120
120
|
const testdriver = TestDriver(context);
|
|
121
121
|
|
|
122
122
|
// Clone extension from GitHub
|
|
123
|
-
await testdriver.ready();
|
|
124
123
|
await testdriver.exec(
|
|
125
124
|
'sh',
|
|
126
125
|
'git clone https://github.com/user/my-extension.git /tmp/my-extension',
|
|
@@ -284,7 +283,6 @@ describe("VS Code Test", () => {
|
|
|
284
283
|
const testdriver = TestDriver(context);
|
|
285
284
|
|
|
286
285
|
// Create a test project
|
|
287
|
-
await testdriver.ready();
|
|
288
286
|
await testdriver.exec('sh', 'mkdir -p /tmp/test-project && echo "print(1)" > /tmp/test-project/test.py', 10000);
|
|
289
287
|
|
|
290
288
|
// Launch VS Code
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "screenshot()"
|
|
3
|
+
sidebarTitle: "screenshot"
|
|
4
|
+
description: "Capture and save screenshots during test execution"
|
|
5
|
+
icon: "camera"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Capture a screenshot of the current screen and automatically save it to a local file. Screenshots are organized by test file for easy debugging and review.
|
|
11
|
+
|
|
12
|
+
## Syntax
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
const filePath = await testdriver.screenshot(scale, silent, mouse)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Parameters
|
|
19
|
+
|
|
20
|
+
<ParamField path="scale" type="number" default="1">
|
|
21
|
+
Scale factor for the screenshot (1 = original size, 0.5 = half size, 2 = double size)
|
|
22
|
+
</ParamField>
|
|
23
|
+
|
|
24
|
+
<ParamField path="silent" type="boolean" default="false">
|
|
25
|
+
Whether to suppress the log message showing where the screenshot was saved
|
|
26
|
+
</ParamField>
|
|
27
|
+
|
|
28
|
+
<ParamField path="mouse" type="boolean" default="false">
|
|
29
|
+
Whether to include the mouse cursor in the screenshot
|
|
30
|
+
</ParamField>
|
|
31
|
+
|
|
32
|
+
## Returns
|
|
33
|
+
|
|
34
|
+
`Promise<string>` - The absolute file path where the screenshot was saved
|
|
35
|
+
|
|
36
|
+
## File Organization
|
|
37
|
+
|
|
38
|
+
Screenshots are automatically saved to `.testdriverai/screenshots/<test-file-name>/` in your project root:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
.testdriverai/
|
|
42
|
+
screenshots/
|
|
43
|
+
login.test/
|
|
44
|
+
screenshot-1737633600000.png
|
|
45
|
+
screenshot-1737633605000.png
|
|
46
|
+
checkout.test/
|
|
47
|
+
screenshot-1737633700000.png
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
<Note>
|
|
51
|
+
The screenshot folder for each test file is automatically cleared when the test starts. This ensures you only see screenshots from the most recent test run.
|
|
52
|
+
</Note>
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
### Basic Screenshot
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
// Capture a screenshot with default settings
|
|
60
|
+
const screenshotPath = await testdriver.screenshot();
|
|
61
|
+
console.log('Screenshot saved to:', screenshotPath);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Screenshot with Mouse Cursor
|
|
65
|
+
|
|
66
|
+
```javascript
|
|
67
|
+
// Capture a screenshot showing the mouse cursor position
|
|
68
|
+
const screenshotPath = await testdriver.screenshot(1, false, true);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Silent Screenshot
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
// Capture without logging the save location
|
|
75
|
+
await testdriver.screenshot(1, true);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Debugging with Screenshots
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
import { describe, expect, it } from "vitest";
|
|
82
|
+
import { TestDriver } from "testdriverai/lib/vitest/hooks.mjs";
|
|
83
|
+
|
|
84
|
+
describe("Login Flow", () => {
|
|
85
|
+
it("should log in successfully", async (context) => {
|
|
86
|
+
const testdriver = TestDriver(context);
|
|
87
|
+
|
|
88
|
+
await testdriver.provision.chrome({
|
|
89
|
+
url: 'https://myapp.com/login',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Capture initial state
|
|
93
|
+
await testdriver.screenshot();
|
|
94
|
+
|
|
95
|
+
// Fill in login form
|
|
96
|
+
const emailInput = await testdriver.find("email input");
|
|
97
|
+
await emailInput.click();
|
|
98
|
+
await testdriver.type("user@example.com");
|
|
99
|
+
|
|
100
|
+
// Capture state after typing
|
|
101
|
+
await testdriver.screenshot();
|
|
102
|
+
|
|
103
|
+
const passwordInput = await testdriver.find("password input");
|
|
104
|
+
await passwordInput.click();
|
|
105
|
+
await testdriver.type("password123");
|
|
106
|
+
|
|
107
|
+
// Capture before clicking login
|
|
108
|
+
await testdriver.screenshot();
|
|
109
|
+
|
|
110
|
+
const loginButton = await testdriver.find("Login button");
|
|
111
|
+
await loginButton.click();
|
|
112
|
+
|
|
113
|
+
// Capture after login attempt
|
|
114
|
+
await testdriver.screenshot();
|
|
115
|
+
|
|
116
|
+
const result = await testdriver.assert("dashboard is visible");
|
|
117
|
+
expect(result).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Best Practices
|
|
123
|
+
|
|
124
|
+
<AccordionGroup>
|
|
125
|
+
<Accordion title="Use screenshots for debugging flaky tests">
|
|
126
|
+
When a test fails intermittently, add screenshots at key steps to capture the actual screen state. This helps identify timing issues or unexpected UI states.
|
|
127
|
+
</Accordion>
|
|
128
|
+
|
|
129
|
+
<Accordion title="Capture before assertions">
|
|
130
|
+
Take a screenshot before making assertions. If the assertion fails, you'll have a visual record of what the screen looked like.
|
|
131
|
+
|
|
132
|
+
```javascript
|
|
133
|
+
await testdriver.screenshot();
|
|
134
|
+
const result = await testdriver.assert("checkout button is visible");
|
|
135
|
+
```
|
|
136
|
+
</Accordion>
|
|
137
|
+
|
|
138
|
+
<Accordion title="Add to .gitignore">
|
|
139
|
+
Add `.testdriverai/screenshots/` to your `.gitignore` to avoid committing screenshots to version control:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
# .gitignore
|
|
143
|
+
.testdriverai/screenshots/
|
|
144
|
+
```
|
|
145
|
+
</Accordion>
|
|
146
|
+
</AccordionGroup>
|
|
147
|
+
|
|
148
|
+
## Related
|
|
149
|
+
|
|
150
|
+
- [assert()](/v7/assert) - Make AI-powered assertions
|
|
151
|
+
- [find()](/v7/find) - Locate elements on screen
|
package/eslint.config.js
CHANGED
|
@@ -45,6 +45,12 @@ module.exports = [
|
|
|
45
45
|
...globals.node,
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
|
+
rules: {
|
|
49
|
+
// Warn about floating promises (unawaited async calls)
|
|
50
|
+
// This catches missing `await` on async methods like click(), assert(), etc.
|
|
51
|
+
// Note: For TypeScript projects, use @typescript-eslint/no-floating-promises instead
|
|
52
|
+
"require-await": "warn",
|
|
53
|
+
},
|
|
48
54
|
},
|
|
49
55
|
{
|
|
50
56
|
// this needs to be it's own object for some reason
|
package/examples/assert.test.mjs
CHANGED
|
@@ -17,6 +17,9 @@ describe("Assert Test", () => {
|
|
|
17
17
|
url: 'http://testdriver-sandbox.vercel.app/login',
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
// Take a screenshot
|
|
21
|
+
await testdriver.screenshot();
|
|
22
|
+
|
|
20
23
|
// Assert the TestDriver.ai Sandbox login page is displayed
|
|
21
24
|
const result = await testdriver.assert(
|
|
22
25
|
"the TestDriver.ai Sandbox login page is displayed",
|
|
@@ -17,9 +17,6 @@ describe("Chrome Extension Test", () => {
|
|
|
17
17
|
|
|
18
18
|
const testdriver = TestDriver(context, { ip: context.ip || process.env.TD_IP, cacheKey: new Date().getTime().toString() });
|
|
19
19
|
|
|
20
|
-
// Wait for connection to be ready before running exec
|
|
21
|
-
await testdriver.ready();
|
|
22
|
-
|
|
23
20
|
// Determine OS-specific paths and commands
|
|
24
21
|
const shell = testdriver.os === 'windows' ? 'pwsh' : 'sh';
|
|
25
22
|
const extensionsDir = testdriver.os === 'windows'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver SDK - Assert Test (Vitest)
|
|
3
|
+
* Converted from: testdriver/acceptance/assert.yaml
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { TestDriver } from "../lib/vitest/hooks.mjs";
|
|
8
|
+
|
|
9
|
+
describe("Assert Test", () => {
|
|
10
|
+
it("should assert the testdriver login page shows", async (context) => {
|
|
11
|
+
const testdriver = TestDriver(context, {
|
|
12
|
+
ip: context.ip || process.env.TD_IP,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Assert the TestDriver.ai Sandbox login page is displayed
|
|
16
|
+
const result = await testdriver.assert(
|
|
17
|
+
"A desktop is visible",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(result).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
package/lib/vitest/hooks.mjs
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* test('my test', async (context) => {
|
|
10
10
|
* const testdriver = TestDriver(context, { headless: true });
|
|
11
11
|
*
|
|
12
|
-
* await testdriver.ready();
|
|
13
12
|
* await testdriver.provision.chrome({ url: 'https://example.com' });
|
|
14
13
|
* await testdriver.find('button').click();
|
|
15
14
|
* });
|
|
@@ -254,46 +253,50 @@ export function TestDriver(context, options = {}) {
|
|
|
254
253
|
// Pass test file name to SDK for debugger display
|
|
255
254
|
testdriver.testFile = testFile;
|
|
256
255
|
|
|
257
|
-
// Auto-connect if enabled (default: true)
|
|
258
|
-
const autoConnect = config.autoConnect !== undefined ? config.autoConnect : true;
|
|
259
256
|
const debugConsoleSpy = process.env.TD_DEBUG_CONSOLE_SPY === 'true';
|
|
260
257
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
258
|
+
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"
|
|
281
|
+
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
282
|
+
: "/tmp/testdriver.log";
|
|
283
|
+
|
|
284
|
+
const createLogCmd = testdriver.os === "windows"
|
|
285
|
+
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
286
|
+
: `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
293
|
await testdriver.dashcam.addFileLog(logPath, "TestDriver Log");
|
|
294
|
+
|
|
295
|
+
// Start dashcam recording (always, regardless of provision method)
|
|
296
|
+
await testdriver.dashcam.start();
|
|
297
|
+
}
|
|
294
298
|
|
|
295
299
|
})();
|
|
296
|
-
}
|
|
297
300
|
|
|
298
301
|
// Register cleanup handler with dashcam.stop()
|
|
299
302
|
// We always register a new cleanup handler because on retry we need to clean up the new instance
|
package/package.json
CHANGED
package/sdk.d.ts
CHANGED
|
@@ -244,6 +244,8 @@ export interface TestDriverOptions {
|
|
|
244
244
|
cacheKey?: string;
|
|
245
245
|
/** 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. */
|
|
246
246
|
reconnect?: boolean;
|
|
247
|
+
/** Enable/disable Dashcam video recording (default: true) */
|
|
248
|
+
dashcam?: boolean;
|
|
247
249
|
/** Redraw configuration for screen change detection */
|
|
248
250
|
redraw?: boolean | {
|
|
249
251
|
/** Enable redraw detection (default: true) */
|
|
@@ -810,6 +812,11 @@ export default class TestDriverSDK {
|
|
|
810
812
|
*/
|
|
811
813
|
readonly dashcam: DashcamAPI;
|
|
812
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Whether Dashcam recording is enabled (default: true)
|
|
817
|
+
*/
|
|
818
|
+
readonly dashcamEnabled: boolean;
|
|
819
|
+
|
|
813
820
|
/**
|
|
814
821
|
* Wait for the sandbox to be ready
|
|
815
822
|
* Called automatically by provision methods
|
|
@@ -1129,20 +1136,20 @@ export default class TestDriverSDK {
|
|
|
1129
1136
|
// Utility Methods
|
|
1130
1137
|
|
|
1131
1138
|
/**
|
|
1132
|
-
* Capture a screenshot of the current screen
|
|
1139
|
+
* Capture a screenshot of the current screen and save it to .testdriverai/screenshots
|
|
1133
1140
|
* @param scale - Scale factor for the screenshot (default: 1 = original size)
|
|
1134
1141
|
* @param silent - Whether to suppress logging (default: false)
|
|
1135
1142
|
* @param mouse - Whether to include mouse cursor (default: false)
|
|
1136
|
-
* @returns
|
|
1143
|
+
* @returns The file path where the screenshot was saved
|
|
1137
1144
|
*
|
|
1138
1145
|
* @example
|
|
1139
|
-
* // Capture a screenshot
|
|
1140
|
-
* const
|
|
1141
|
-
*
|
|
1146
|
+
* // Capture a screenshot (saves to .testdriverai/screenshots)
|
|
1147
|
+
* const screenshotPath = await client.screenshot();
|
|
1148
|
+
* console.log('Screenshot saved to:', screenshotPath);
|
|
1142
1149
|
*
|
|
1143
1150
|
* @example
|
|
1144
1151
|
* // Capture with mouse cursor visible
|
|
1145
|
-
* const
|
|
1152
|
+
* const screenshotPath = await client.screenshot(1, false, true);
|
|
1146
1153
|
*/
|
|
1147
1154
|
screenshot(
|
|
1148
1155
|
scale?: number,
|
package/sdk.js
CHANGED
|
@@ -1258,6 +1258,9 @@ class TestDriverSDK {
|
|
|
1258
1258
|
// Store reconnect preference from options
|
|
1259
1259
|
this.reconnect = options.reconnect !== undefined ? options.reconnect : false;
|
|
1260
1260
|
|
|
1261
|
+
// Store dashcam preference (default: true)
|
|
1262
|
+
this.dashcamEnabled = options.dashcam !== false;
|
|
1263
|
+
|
|
1261
1264
|
// Cache threshold configuration
|
|
1262
1265
|
// threshold = pixel difference allowed (0.05 = 5% difference, 95% similarity)
|
|
1263
1266
|
// By default, cache is DISABLED (threshold = -1) to avoid unnecessary AI costs
|
|
@@ -1333,6 +1336,13 @@ class TestDriverSDK {
|
|
|
1333
1336
|
|
|
1334
1337
|
// Set up dashcam API lazily
|
|
1335
1338
|
this._dashcam = null;
|
|
1339
|
+
|
|
1340
|
+
// Last-promise tracking for unawaited promise detection
|
|
1341
|
+
this._lastPromiseSettled = true;
|
|
1342
|
+
this._lastCommandName = null;
|
|
1343
|
+
|
|
1344
|
+
// Set up command methods that lazy-await connection
|
|
1345
|
+
this._setupCommandMethods();
|
|
1336
1346
|
}
|
|
1337
1347
|
|
|
1338
1348
|
/**
|
|
@@ -1344,19 +1354,36 @@ class TestDriverSDK {
|
|
|
1344
1354
|
await this.__connectionPromise;
|
|
1345
1355
|
}
|
|
1346
1356
|
if (!this.connected) {
|
|
1347
|
-
throw new Error('Not connected to sandbox. Call connect() first
|
|
1357
|
+
throw new Error('Not connected to sandbox. Call connect() first.');
|
|
1348
1358
|
}
|
|
1349
1359
|
}
|
|
1350
1360
|
|
|
1351
1361
|
/**
|
|
1352
1362
|
* Get or create the Dashcam instance
|
|
1353
|
-
* @returns {Dashcam} Dashcam instance
|
|
1363
|
+
* @returns {Dashcam} Dashcam instance (or no-op stub if dashcam is disabled)
|
|
1354
1364
|
*/
|
|
1355
1365
|
get dashcam() {
|
|
1356
1366
|
if (!this._dashcam) {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1367
|
+
// If dashcam is disabled, return a no-op stub
|
|
1368
|
+
if (!this.dashcamEnabled) {
|
|
1369
|
+
this._dashcam = {
|
|
1370
|
+
start: async () => {},
|
|
1371
|
+
stop: async () => null,
|
|
1372
|
+
auth: async () => {},
|
|
1373
|
+
addFileLog: async () => {},
|
|
1374
|
+
addWebLog: async () => {},
|
|
1375
|
+
addApplicationLog: async () => {},
|
|
1376
|
+
addLog: async () => {},
|
|
1377
|
+
isRecording: async () => false,
|
|
1378
|
+
getElapsedTime: () => null,
|
|
1379
|
+
recording: false,
|
|
1380
|
+
url: null,
|
|
1381
|
+
};
|
|
1382
|
+
} else {
|
|
1383
|
+
const { Dashcam } = require("./lib/core/index.js");
|
|
1384
|
+
// Don't pass apiKey - let Dashcam use its default key
|
|
1385
|
+
this._dashcam = new Dashcam(this);
|
|
1386
|
+
}
|
|
1360
1387
|
}
|
|
1361
1388
|
return this._dashcam;
|
|
1362
1389
|
}
|
|
@@ -1406,40 +1433,16 @@ class TestDriverSDK {
|
|
|
1406
1433
|
* @returns {Promise<void>}
|
|
1407
1434
|
*/
|
|
1408
1435
|
chrome: async (options = {}) => {
|
|
1409
|
-
// Automatically wait for connection to be ready
|
|
1410
|
-
await this.ready();
|
|
1411
|
-
|
|
1412
1436
|
const {
|
|
1413
1437
|
url = 'http://testdriver-sandbox.vercel.app/',
|
|
1414
1438
|
maximized = true,
|
|
1415
1439
|
guest = false,
|
|
1416
1440
|
} = options;
|
|
1417
1441
|
|
|
1418
|
-
// If dashcam is available, add web logs for all websites
|
|
1442
|
+
// If dashcam is available, add web logs for all websites
|
|
1443
|
+
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1419
1444
|
if (this._dashcam) {
|
|
1420
|
-
|
|
1421
|
-
// Create the log file on the remote machine
|
|
1422
|
-
const shell = this.os === "windows" ? "pwsh" : "sh";
|
|
1423
|
-
const logPath = this.os === "windows"
|
|
1424
|
-
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
1425
|
-
: "/tmp/testdriver.log";
|
|
1426
|
-
|
|
1427
|
-
const createLogCmd = this.os === "windows"
|
|
1428
|
-
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
1429
|
-
: `touch ${logPath}`;
|
|
1430
|
-
|
|
1431
|
-
await this.exec(shell, createLogCmd, 60000, true);
|
|
1432
|
-
|
|
1433
|
-
// Track all websites by default with "**" pattern
|
|
1434
|
-
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
1435
|
-
|
|
1436
|
-
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1437
|
-
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
// Automatically start dashcam if not already recording
|
|
1441
|
-
if (!this._dashcam || !this._dashcam.recording) {
|
|
1442
|
-
await this.dashcam.start();
|
|
1445
|
+
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
1443
1446
|
}
|
|
1444
1447
|
|
|
1445
1448
|
// Set up Chrome profile with preferences
|
|
@@ -1571,9 +1574,6 @@ class TestDriverSDK {
|
|
|
1571
1574
|
* });
|
|
1572
1575
|
*/
|
|
1573
1576
|
chromeExtension: async (options = {}) => {
|
|
1574
|
-
// Automatically wait for connection to be ready
|
|
1575
|
-
await this.ready();
|
|
1576
|
-
|
|
1577
1577
|
const {
|
|
1578
1578
|
extensionPath: providedExtensionPath,
|
|
1579
1579
|
extensionId,
|
|
@@ -1694,27 +1694,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1694
1694
|
console.log(`[provision.chromeExtension] Extension ${extensionId} extracted to ${extensionPath}`);
|
|
1695
1695
|
}
|
|
1696
1696
|
|
|
1697
|
-
// If dashcam is available,
|
|
1697
|
+
// If dashcam is available, add web logs for all websites
|
|
1698
|
+
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1698
1699
|
if (this._dashcam) {
|
|
1699
|
-
// Create the log file on the remote machine
|
|
1700
|
-
const logPath = this.os === "windows"
|
|
1701
|
-
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
1702
|
-
: "/tmp/testdriver.log";
|
|
1703
|
-
|
|
1704
|
-
const createLogCmd = this.os === "windows"
|
|
1705
|
-
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
1706
|
-
: `touch ${logPath}`;
|
|
1707
|
-
|
|
1708
|
-
await this.exec(shell, createLogCmd, 60000, true);
|
|
1709
|
-
|
|
1710
|
-
// Track all websites by default with "**" pattern
|
|
1711
1700
|
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
1712
|
-
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
// Automatically start dashcam if not already recording
|
|
1716
|
-
if (!this._dashcam || !this._dashcam.recording) {
|
|
1717
|
-
await this.dashcam.start();
|
|
1718
1701
|
}
|
|
1719
1702
|
|
|
1720
1703
|
// Set up Chrome profile with preferences
|
|
@@ -1827,9 +1810,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1827
1810
|
* @returns {Promise<void>}
|
|
1828
1811
|
*/
|
|
1829
1812
|
vscode: async (options = {}) => {
|
|
1830
|
-
// Automatically wait for connection to be ready
|
|
1831
|
-
await this.ready();
|
|
1832
|
-
|
|
1833
1813
|
const {
|
|
1834
1814
|
workspace = null,
|
|
1835
1815
|
extensions = [],
|
|
@@ -1837,27 +1817,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1837
1817
|
|
|
1838
1818
|
const shell = this.os === 'windows' ? 'pwsh' : 'sh';
|
|
1839
1819
|
|
|
1840
|
-
// If dashcam is available,
|
|
1820
|
+
// If dashcam is available, add web logs for all websites
|
|
1821
|
+
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1841
1822
|
if (this._dashcam) {
|
|
1842
|
-
// Create the log file on the remote machine
|
|
1843
|
-
const logPath = this.os === "windows"
|
|
1844
|
-
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
1845
|
-
: "/tmp/testdriver.log";
|
|
1846
|
-
|
|
1847
|
-
const createLogCmd = this.os === "windows"
|
|
1848
|
-
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
1849
|
-
: `touch ${logPath}`;
|
|
1850
|
-
|
|
1851
|
-
await this.exec(shell, createLogCmd, 60000, true);
|
|
1852
|
-
|
|
1853
|
-
// Track all websites by default with "**" pattern
|
|
1854
1823
|
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
1855
|
-
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
// Automatically start dashcam if not already recording
|
|
1859
|
-
if (!this._dashcam || !this._dashcam.recording) {
|
|
1860
|
-
await this.dashcam.start();
|
|
1861
1824
|
}
|
|
1862
1825
|
|
|
1863
1826
|
// Install extensions if provided
|
|
@@ -1920,9 +1883,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1920
1883
|
* await testdriver.exec('sh', `chmod +x "${filePath}" && "${filePath}" &`, 10000);
|
|
1921
1884
|
*/
|
|
1922
1885
|
installer: async (options = {}) => {
|
|
1923
|
-
// Automatically wait for connection to be ready
|
|
1924
|
-
await this.ready();
|
|
1925
|
-
|
|
1926
1886
|
const {
|
|
1927
1887
|
url,
|
|
1928
1888
|
filename,
|
|
@@ -1936,26 +1896,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
1936
1896
|
|
|
1937
1897
|
const shell = this.os === 'windows' ? 'pwsh' : 'sh';
|
|
1938
1898
|
|
|
1939
|
-
// If dashcam is available,
|
|
1899
|
+
// If dashcam is available, add web logs for all websites
|
|
1900
|
+
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
1940
1901
|
if (this._dashcam) {
|
|
1941
|
-
const logPath = this.os === "windows"
|
|
1942
|
-
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
1943
|
-
: "/tmp/testdriver.log";
|
|
1944
|
-
|
|
1945
|
-
const createLogCmd = this.os === "windows"
|
|
1946
|
-
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
1947
|
-
: `touch ${logPath}`;
|
|
1948
|
-
|
|
1949
|
-
await this.exec(shell, createLogCmd, 60000, true);
|
|
1950
|
-
|
|
1951
|
-
// Track all websites by default with "**" pattern
|
|
1952
1902
|
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
1953
|
-
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
// Automatically start dashcam if not already recording
|
|
1957
|
-
if (!this._dashcam || !this._dashcam.recording) {
|
|
1958
|
-
await this.dashcam.start();
|
|
1959
1903
|
}
|
|
1960
1904
|
|
|
1961
1905
|
// Determine download directory
|
|
@@ -2085,9 +2029,6 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2085
2029
|
* @returns {Promise<void>}
|
|
2086
2030
|
*/
|
|
2087
2031
|
electron: async (options = {}) => {
|
|
2088
|
-
// Automatically wait for connection to be ready
|
|
2089
|
-
await this.ready();
|
|
2090
|
-
|
|
2091
2032
|
const { appPath, args = [] } = options;
|
|
2092
2033
|
|
|
2093
2034
|
if (!appPath) {
|
|
@@ -2096,26 +2037,10 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2096
2037
|
|
|
2097
2038
|
const shell = this.os === 'windows' ? 'pwsh' : 'sh';
|
|
2098
2039
|
|
|
2099
|
-
// If dashcam is available,
|
|
2040
|
+
// If dashcam is available, add web logs for all websites
|
|
2041
|
+
// Note: File log and dashcam.start() are handled by the connection promise in hooks.mjs
|
|
2100
2042
|
if (this._dashcam) {
|
|
2101
|
-
const logPath = this.os === "windows"
|
|
2102
|
-
? "C:\\Users\\testdriver\\Documents\\testdriver.log"
|
|
2103
|
-
: "/tmp/testdriver.log";
|
|
2104
|
-
|
|
2105
|
-
const createLogCmd = this.os === "windows"
|
|
2106
|
-
? `New-Item -ItemType File -Path "${logPath}" -Force | Out-Null`
|
|
2107
|
-
: `touch ${logPath}`;
|
|
2108
|
-
|
|
2109
|
-
await this.exec(shell, createLogCmd, 60000, true);
|
|
2110
|
-
|
|
2111
|
-
// Track all websites by default with "**" pattern
|
|
2112
2043
|
await this._dashcam.addWebLog('**', 'Web Logs');
|
|
2113
|
-
await this._dashcam.addFileLog(logPath, "TestDriver Log");
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
// Automatically start dashcam if not already recording
|
|
2117
|
-
if (!this._dashcam || !this._dashcam.recording) {
|
|
2118
|
-
await this.dashcam.start();
|
|
2119
2044
|
}
|
|
2120
2045
|
|
|
2121
2046
|
const argsString = args.join(' ');
|
|
@@ -2190,6 +2115,15 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2190
2115
|
);
|
|
2191
2116
|
}
|
|
2192
2117
|
|
|
2118
|
+
// Clean up screenshots folder for this test file before running
|
|
2119
|
+
if (this.testFile) {
|
|
2120
|
+
const testFileName = path.basename(this.testFile, path.extname(this.testFile));
|
|
2121
|
+
const screenshotsDir = path.join(process.cwd(), ".testdriverai", "screenshots", testFileName);
|
|
2122
|
+
if (fs.existsSync(screenshotsDir)) {
|
|
2123
|
+
fs.rmSync(screenshotsDir, { recursive: true, force: true });
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2193
2127
|
// Authenticate first if not already authenticated
|
|
2194
2128
|
if (!this.authenticated) {
|
|
2195
2129
|
await this.auth();
|
|
@@ -2317,8 +2251,8 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2317
2251
|
this.agent.commands = commandsResult.commands;
|
|
2318
2252
|
this.agent.redraw = commandsResult.redraw;
|
|
2319
2253
|
|
|
2320
|
-
//
|
|
2321
|
-
this.
|
|
2254
|
+
// Command methods are already set up in constructor with lazy-await
|
|
2255
|
+
// They will use this.commands which is now populated
|
|
2322
2256
|
|
|
2323
2257
|
this.connected = true;
|
|
2324
2258
|
this.analytics.track("sdk.connect", {
|
|
@@ -2408,9 +2342,33 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2408
2342
|
* await element.click();
|
|
2409
2343
|
*/
|
|
2410
2344
|
find(description, options) {
|
|
2411
|
-
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2345
|
+
// Wrap in async IIFE to support lazy-await and promise tracking
|
|
2346
|
+
const findPromise = (async () => {
|
|
2347
|
+
// Lazy-await: wait for connection if still pending
|
|
2348
|
+
if (this.__connectionPromise) {
|
|
2349
|
+
await this.__connectionPromise;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Warn if previous command may not have been awaited
|
|
2353
|
+
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2354
|
+
console.warn(
|
|
2355
|
+
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2356
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2357
|
+
` Unawaited promises can cause race conditions and flaky tests.`
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
this._ensureConnected();
|
|
2362
|
+
|
|
2363
|
+
// Track this promise for unawaited detection
|
|
2364
|
+
this._lastCommandName = 'find';
|
|
2365
|
+
this._lastPromiseSettled = false;
|
|
2366
|
+
|
|
2367
|
+
const element = new Element(description, this, this.system, this.commands);
|
|
2368
|
+
const result = await element.find(null, options);
|
|
2369
|
+
this._lastPromiseSettled = true;
|
|
2370
|
+
return result;
|
|
2371
|
+
})();
|
|
2414
2372
|
|
|
2415
2373
|
// Create a chainable promise that allows direct method chaining
|
|
2416
2374
|
// e.g., await testdriver.find("button").click()
|
|
@@ -2440,8 +2398,26 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2440
2398
|
* }
|
|
2441
2399
|
*/
|
|
2442
2400
|
async findAll(description, options) {
|
|
2401
|
+
// Lazy-await: wait for connection if still pending
|
|
2402
|
+
if (this.__connectionPromise) {
|
|
2403
|
+
await this.__connectionPromise;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// Warn if previous command may not have been awaited
|
|
2407
|
+
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2408
|
+
console.warn(
|
|
2409
|
+
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2410
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2411
|
+
` Unawaited promises can cause race conditions and flaky tests.`
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2443
2415
|
this._ensureConnected();
|
|
2444
2416
|
|
|
2417
|
+
// Track this promise for unawaited detection
|
|
2418
|
+
this._lastCommandName = 'findAll';
|
|
2419
|
+
this._lastPromiseSettled = false;
|
|
2420
|
+
|
|
2445
2421
|
// Capture absolute timestamp at the very start of the command
|
|
2446
2422
|
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
2447
2423
|
const absoluteTimestamp = Date.now();
|
|
@@ -2589,6 +2565,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2589
2565
|
this.emitter.emit(events.log.debug, ` Time: ${duration}ms`);
|
|
2590
2566
|
}
|
|
2591
2567
|
|
|
2568
|
+
this._lastPromiseSettled = true;
|
|
2592
2569
|
return elements;
|
|
2593
2570
|
} else {
|
|
2594
2571
|
const duration = Date.now() - startTime;
|
|
@@ -2625,6 +2602,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2625
2602
|
}
|
|
2626
2603
|
|
|
2627
2604
|
// No elements found - return empty array
|
|
2605
|
+
this._lastPromiseSettled = true;
|
|
2628
2606
|
return [];
|
|
2629
2607
|
}
|
|
2630
2608
|
} catch (error) {
|
|
@@ -2657,6 +2635,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2657
2635
|
});
|
|
2658
2636
|
}
|
|
2659
2637
|
|
|
2638
|
+
this._lastPromiseSettled = true;
|
|
2660
2639
|
return [];
|
|
2661
2640
|
}
|
|
2662
2641
|
}
|
|
@@ -2706,251 +2685,74 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2706
2685
|
* @private
|
|
2707
2686
|
*/
|
|
2708
2687
|
_setupCommandMethods() {
|
|
2709
|
-
// Mapping from command names to SDK method names
|
|
2710
|
-
// Each command supports both positional args (legacy) and object args (new)
|
|
2688
|
+
// Mapping from internal command names to SDK method names
|
|
2711
2689
|
const commandMapping = {
|
|
2712
|
-
"hover-text":
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
"
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
* Hover over an image on screen
|
|
2730
|
-
* @deprecated Use find() and element.click() instead
|
|
2731
|
-
* @param {Object|string} options - Options object or description (legacy positional)
|
|
2732
|
-
* @param {string} options.description - Description of the image to find
|
|
2733
|
-
* @param {ClickAction} [options.action='click'] - Action to perform
|
|
2734
|
-
* @returns {Promise<{x: number, y: number, centerX: number, centerY: number}>}
|
|
2735
|
-
*/
|
|
2736
|
-
doc: "Hover over an image on screen (deprecated - use find() instead)",
|
|
2737
|
-
},
|
|
2738
|
-
"match-image": {
|
|
2739
|
-
name: "matchImage",
|
|
2740
|
-
/**
|
|
2741
|
-
* Match and interact with an image template
|
|
2742
|
-
* @param {Object|string} options - Options object or path (legacy positional)
|
|
2743
|
-
* @param {string} options.path - Path to the image template
|
|
2744
|
-
* @param {ClickAction} [options.action='click'] - Action to perform
|
|
2745
|
-
* @param {boolean} [options.invert=false] - Invert the match
|
|
2746
|
-
* @returns {Promise<boolean>}
|
|
2747
|
-
*/
|
|
2748
|
-
doc: "Match and interact with an image template",
|
|
2749
|
-
},
|
|
2750
|
-
type: {
|
|
2751
|
-
name: "type",
|
|
2752
|
-
/**
|
|
2753
|
-
* Type text
|
|
2754
|
-
* @param {string|number} text - Text to type
|
|
2755
|
-
* @param {Object} [options] - Additional options
|
|
2756
|
-
* @param {number} [options.delay=250] - Delay between keystrokes in milliseconds
|
|
2757
|
-
* @param {boolean} [options.secret=false] - If true, text is treated as sensitive (not logged or stored)
|
|
2758
|
-
* @returns {Promise<void>}
|
|
2759
|
-
*/
|
|
2760
|
-
doc: "Type text (use { secret: true } for passwords)",
|
|
2761
|
-
},
|
|
2762
|
-
"press-keys": {
|
|
2763
|
-
name: "pressKeys",
|
|
2764
|
-
/**
|
|
2765
|
-
* Press keyboard keys
|
|
2766
|
-
* @param {KeyboardKey[]} keys - Array of keys to press
|
|
2767
|
-
* @param {Object} [options] - Additional options (reserved for future use)
|
|
2768
|
-
* @returns {Promise<void>}
|
|
2769
|
-
*/
|
|
2770
|
-
doc: "Press keyboard keys",
|
|
2771
|
-
},
|
|
2772
|
-
click: {
|
|
2773
|
-
name: "click",
|
|
2774
|
-
/**
|
|
2775
|
-
* Click at coordinates
|
|
2776
|
-
* @param {Object|number} options - Options object or x coordinate (legacy positional)
|
|
2777
|
-
* @param {number} options.x - X coordinate
|
|
2778
|
-
* @param {number} options.y - Y coordinate
|
|
2779
|
-
* @param {ClickAction} [options.action='click'] - Type of click action
|
|
2780
|
-
* @returns {Promise<void>}
|
|
2781
|
-
*/
|
|
2782
|
-
doc: "Click at coordinates",
|
|
2783
|
-
},
|
|
2784
|
-
hover: {
|
|
2785
|
-
name: "hover",
|
|
2786
|
-
/**
|
|
2787
|
-
* Hover at coordinates
|
|
2788
|
-
* @param {Object|number} options - Options object or x coordinate (legacy positional)
|
|
2789
|
-
* @param {number} options.x - X coordinate
|
|
2790
|
-
* @param {number} options.y - Y coordinate
|
|
2791
|
-
* @returns {Promise<void>}
|
|
2792
|
-
*/
|
|
2793
|
-
doc: "Hover at coordinates",
|
|
2794
|
-
},
|
|
2795
|
-
scroll: {
|
|
2796
|
-
name: "scroll",
|
|
2797
|
-
/**
|
|
2798
|
-
* Scroll the page
|
|
2799
|
-
* @param {ScrollDirection} [direction='down'] - Direction to scroll
|
|
2800
|
-
* @param {Object} [options] - Additional options
|
|
2801
|
-
* @param {number} [options.amount=300] - Amount to scroll in pixels
|
|
2802
|
-
* @returns {Promise<void>}
|
|
2803
|
-
*/
|
|
2804
|
-
doc: "Scroll the page",
|
|
2805
|
-
},
|
|
2806
|
-
wait: {
|
|
2807
|
-
name: "wait",
|
|
2808
|
-
/**
|
|
2809
|
-
* Wait for specified time
|
|
2810
|
-
* @deprecated Consider using element polling with find() instead of arbitrary waits
|
|
2811
|
-
* @param {number} [timeout=3000] - Time to wait in milliseconds
|
|
2812
|
-
* @param {Object} [options] - Additional options (reserved for future use)
|
|
2813
|
-
* @returns {Promise<void>}
|
|
2814
|
-
*/
|
|
2815
|
-
doc: "Wait for specified time (deprecated - consider element polling instead)",
|
|
2816
|
-
},
|
|
2817
|
-
"wait-for-text": {
|
|
2818
|
-
name: "waitForText",
|
|
2819
|
-
/**
|
|
2820
|
-
* Wait for text to appear on screen
|
|
2821
|
-
* @deprecated Use find() in a polling loop instead
|
|
2822
|
-
* @param {Object|string} options - Options object or text (legacy positional)
|
|
2823
|
-
* @param {string} options.text - Text to wait for
|
|
2824
|
-
* @param {number} [options.timeout=5000] - Timeout in milliseconds
|
|
2825
|
-
* @returns {Promise<void>}
|
|
2826
|
-
*/
|
|
2827
|
-
doc: "Wait for text to appear on screen (deprecated - use find() in a loop instead)",
|
|
2828
|
-
},
|
|
2829
|
-
"wait-for-image": {
|
|
2830
|
-
name: "waitForImage",
|
|
2831
|
-
/**
|
|
2832
|
-
* Wait for image to appear on screen
|
|
2833
|
-
* @deprecated Use find() in a polling loop instead
|
|
2834
|
-
* @param {Object|string} options - Options object or description (legacy positional)
|
|
2835
|
-
* @param {string} options.description - Description of the image
|
|
2836
|
-
* @param {number} [options.timeout=10000] - Timeout in milliseconds
|
|
2837
|
-
* @returns {Promise<void>}
|
|
2838
|
-
*/
|
|
2839
|
-
doc: "Wait for image to appear on screen (deprecated - use find() in a loop instead)",
|
|
2840
|
-
},
|
|
2841
|
-
"scroll-until-text": {
|
|
2842
|
-
name: "scrollUntilText",
|
|
2843
|
-
/**
|
|
2844
|
-
* Scroll until text is found
|
|
2845
|
-
* @param {Object|string} options - Options object or text (legacy positional)
|
|
2846
|
-
* @param {string} options.text - Text to find
|
|
2847
|
-
* @param {ScrollDirection} [options.direction='down'] - Scroll direction
|
|
2848
|
-
* @param {number} [options.maxDistance=10000] - Maximum distance to scroll in pixels
|
|
2849
|
-
* @param {boolean} [options.invert=false] - Invert the match
|
|
2850
|
-
* @returns {Promise<void>}
|
|
2851
|
-
*/
|
|
2852
|
-
doc: "Scroll until text is found",
|
|
2853
|
-
},
|
|
2854
|
-
"scroll-until-image": {
|
|
2855
|
-
name: "scrollUntilImage",
|
|
2856
|
-
/**
|
|
2857
|
-
* Scroll until image is found
|
|
2858
|
-
* @param {Object|string} [options] - Options object or description (legacy positional)
|
|
2859
|
-
* @param {string} [options.description] - Description of the image
|
|
2860
|
-
* @param {ScrollDirection} [options.direction='down'] - Scroll direction
|
|
2861
|
-
* @param {number} [options.maxDistance=10000] - Maximum distance to scroll in pixels
|
|
2862
|
-
* @param {string} [options.method='mouse'] - Scroll method
|
|
2863
|
-
* @param {string} [options.path] - Path to image template
|
|
2864
|
-
* @param {boolean} [options.invert=false] - Invert the match
|
|
2865
|
-
* @returns {Promise<void>}
|
|
2866
|
-
*/
|
|
2867
|
-
doc: "Scroll until image is found",
|
|
2868
|
-
},
|
|
2869
|
-
"focus-application": {
|
|
2870
|
-
name: "focusApplication",
|
|
2871
|
-
/**
|
|
2872
|
-
* Focus an application by name
|
|
2873
|
-
* @param {string} name - Application name
|
|
2874
|
-
* @param {Object} [options] - Additional options (reserved for future use)
|
|
2875
|
-
* @returns {Promise<string>}
|
|
2876
|
-
*/
|
|
2877
|
-
doc: "Focus an application by name",
|
|
2878
|
-
},
|
|
2879
|
-
extract: {
|
|
2880
|
-
name: "extract",
|
|
2881
|
-
/**
|
|
2882
|
-
* Extract information from the screen using AI
|
|
2883
|
-
* @param {Object|string} options - Options object or description (legacy positional)
|
|
2884
|
-
* @param {string} options.description - What to extract
|
|
2885
|
-
* @returns {Promise<string>}
|
|
2886
|
-
*/
|
|
2887
|
-
doc: "Extract information from the screen",
|
|
2888
|
-
},
|
|
2889
|
-
assert: {
|
|
2890
|
-
name: "assert",
|
|
2891
|
-
/**
|
|
2892
|
-
* Make an AI-powered assertion
|
|
2893
|
-
* @param {string} assertion - Assertion to check
|
|
2894
|
-
* @param {Object} [options] - Additional options (reserved for future use)
|
|
2895
|
-
* @returns {Promise<boolean>}
|
|
2896
|
-
*/
|
|
2897
|
-
doc: "Make an AI-powered assertion",
|
|
2898
|
-
},
|
|
2899
|
-
exec: {
|
|
2900
|
-
name: "exec",
|
|
2901
|
-
/**
|
|
2902
|
-
* Execute code in the sandbox
|
|
2903
|
-
* @param {Object|ExecLanguage} options - Options object or language (legacy positional)
|
|
2904
|
-
* @param {ExecLanguage} [options.language='pwsh'] - Language ('js', 'pwsh', or 'sh')
|
|
2905
|
-
* @param {string} options.code - Code to execute
|
|
2906
|
-
* @param {number} [options.timeout] - Timeout in milliseconds
|
|
2907
|
-
* @param {boolean} [options.silent=false] - Suppress output
|
|
2908
|
-
* @returns {Promise<string>}
|
|
2909
|
-
*/
|
|
2910
|
-
doc: "Execute code in the sandbox",
|
|
2911
|
-
},
|
|
2690
|
+
"hover-text": "hoverText",
|
|
2691
|
+
"hover-image": "hoverImage",
|
|
2692
|
+
"match-image": "matchImage",
|
|
2693
|
+
"type": "type",
|
|
2694
|
+
"press-keys": "pressKeys",
|
|
2695
|
+
"click": "click",
|
|
2696
|
+
"hover": "hover",
|
|
2697
|
+
"scroll": "scroll",
|
|
2698
|
+
"wait": "wait",
|
|
2699
|
+
"wait-for-text": "waitForText",
|
|
2700
|
+
"wait-for-image": "waitForImage",
|
|
2701
|
+
"scroll-until-text": "scrollUntilText",
|
|
2702
|
+
"scroll-until-image": "scrollUntilImage",
|
|
2703
|
+
"focus-application": "focusApplication",
|
|
2704
|
+
"extract": "extract",
|
|
2705
|
+
"assert": "assert",
|
|
2706
|
+
"exec": "exec",
|
|
2912
2707
|
};
|
|
2913
2708
|
|
|
2914
|
-
// Create SDK methods
|
|
2915
|
-
Object.
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
return;
|
|
2922
|
-
}
|
|
2709
|
+
// Create SDK methods that lazy-await connection then forward to this.commands
|
|
2710
|
+
for (const [commandName, methodName] of Object.entries(commandMapping)) {
|
|
2711
|
+
this[methodName] = async function (...args) {
|
|
2712
|
+
// Lazy-await: wait for connection if still pending
|
|
2713
|
+
if (this.__connectionPromise) {
|
|
2714
|
+
await this.__connectionPromise;
|
|
2715
|
+
}
|
|
2923
2716
|
|
|
2924
|
-
|
|
2717
|
+
// Warn if previous command may not have been awaited
|
|
2718
|
+
if (this._lastCommandName && !this._lastPromiseSettled) {
|
|
2719
|
+
console.warn(
|
|
2720
|
+
`⚠️ Warning: Previous ${this._lastCommandName}() may not have been awaited.\n` +
|
|
2721
|
+
` Add "await" before the call: await testdriver.${this._lastCommandName}(...)\n` +
|
|
2722
|
+
` Unawaited promises can cause race conditions and flaky tests.`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2925
2725
|
|
|
2926
|
-
// Create the wrapper method with proper stack trace handling
|
|
2927
|
-
this[methodName] = async function (...args) {
|
|
2928
2726
|
this._ensureConnected();
|
|
2929
2727
|
|
|
2930
2728
|
// Capture the call site for better error reporting
|
|
2931
2729
|
const callSite = {};
|
|
2932
2730
|
Error.captureStackTrace(callSite, this[methodName]);
|
|
2933
2731
|
|
|
2732
|
+
// Track this promise for unawaited detection
|
|
2733
|
+
this._lastCommandName = methodName;
|
|
2734
|
+
this._lastPromiseSettled = false;
|
|
2735
|
+
|
|
2934
2736
|
try {
|
|
2935
|
-
|
|
2737
|
+
const result = await this.commands[commandName](...args);
|
|
2738
|
+
this._lastPromiseSettled = true;
|
|
2739
|
+
return result;
|
|
2936
2740
|
} catch (error) {
|
|
2741
|
+
this._lastPromiseSettled = true;
|
|
2937
2742
|
// Ensure we have a proper Error object with a message
|
|
2938
2743
|
let properError = error;
|
|
2939
2744
|
if (!(error instanceof Error)) {
|
|
2940
|
-
// If it's not an Error object, create one with a proper message
|
|
2941
2745
|
const errorMessage =
|
|
2942
2746
|
error?.message || error?.reason || JSON.stringify(error);
|
|
2943
2747
|
properError = new Error(errorMessage);
|
|
2944
|
-
// Preserve additional properties
|
|
2945
2748
|
if (error?.code) properError.code = error.code;
|
|
2946
2749
|
if (error?.fullError) properError.fullError = error.fullError;
|
|
2947
2750
|
}
|
|
2948
2751
|
|
|
2949
2752
|
// Replace the stack trace to point to the actual caller instead of SDK internals
|
|
2950
2753
|
if (Error.captureStackTrace && callSite.stack) {
|
|
2951
|
-
// Preserve the error message but use the captured call site stack
|
|
2952
2754
|
const errorMessage = properError.stack?.split("\n")[0];
|
|
2953
|
-
const callerStack = callSite.stack?.split("\n").slice(1);
|
|
2755
|
+
const callerStack = callSite.stack?.split("\n").slice(1);
|
|
2954
2756
|
properError.stack = errorMessage + "\n" + callerStack.join("\n");
|
|
2955
2757
|
}
|
|
2956
2758
|
throw properError;
|
|
@@ -2962,7 +2764,7 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2962
2764
|
value: methodName,
|
|
2963
2765
|
writable: false,
|
|
2964
2766
|
});
|
|
2965
|
-
}
|
|
2767
|
+
}
|
|
2966
2768
|
}
|
|
2967
2769
|
|
|
2968
2770
|
// ====================================
|
|
@@ -2970,24 +2772,49 @@ with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
|
|
2970
2772
|
// ====================================
|
|
2971
2773
|
|
|
2972
2774
|
/**
|
|
2973
|
-
* Capture a screenshot of the current screen
|
|
2775
|
+
* Capture a screenshot of the current screen and save it to .testdriverai/screenshots
|
|
2974
2776
|
* @param {number} [scale=1] - Scale factor for the screenshot (1 = original size)
|
|
2975
2777
|
* @param {boolean} [silent=false] - Whether to suppress logging
|
|
2976
2778
|
* @param {boolean} [mouse=false] - Whether to include mouse cursor
|
|
2977
|
-
* @returns {Promise<string>}
|
|
2779
|
+
* @returns {Promise<string>} The file path where the screenshot was saved
|
|
2978
2780
|
*
|
|
2979
2781
|
* @example
|
|
2980
|
-
* // Capture a screenshot
|
|
2981
|
-
* const
|
|
2982
|
-
*
|
|
2782
|
+
* // Capture a screenshot (saves to .testdriverai/screenshots)
|
|
2783
|
+
* const screenshotPath = await client.screenshot();
|
|
2784
|
+
* console.log('Screenshot saved to:', screenshotPath);
|
|
2983
2785
|
*
|
|
2984
2786
|
* @example
|
|
2985
2787
|
* // Capture with mouse cursor visible
|
|
2986
|
-
* const
|
|
2788
|
+
* const screenshotPath = await client.screenshot(1, false, true);
|
|
2987
2789
|
*/
|
|
2988
2790
|
async screenshot(scale = 1, silent = false, mouse = false) {
|
|
2989
2791
|
this._ensureConnected();
|
|
2990
|
-
|
|
2792
|
+
const base64Data = await this.system.captureScreenBase64(scale, silent, mouse);
|
|
2793
|
+
|
|
2794
|
+
// Save to .testdriverai/screenshots/<test-file-name> directory
|
|
2795
|
+
let screenshotsDir = path.join(process.cwd(), ".testdriverai", "screenshots");
|
|
2796
|
+
if (this.testFile) {
|
|
2797
|
+
const testFileName = path.basename(this.testFile, path.extname(this.testFile));
|
|
2798
|
+
screenshotsDir = path.join(screenshotsDir, testFileName);
|
|
2799
|
+
}
|
|
2800
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
2801
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
2805
|
+
const filePath = path.join(screenshotsDir, filename);
|
|
2806
|
+
|
|
2807
|
+
// Remove data:image/png;base64, prefix if present
|
|
2808
|
+
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
|
2809
|
+
const buffer = Buffer.from(cleanBase64, "base64");
|
|
2810
|
+
|
|
2811
|
+
fs.writeFileSync(filePath, buffer);
|
|
2812
|
+
|
|
2813
|
+
if (!silent) {
|
|
2814
|
+
this.emitter.emit("log:info", `📸 Screenshot saved to: ${filePath}`);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
return filePath;
|
|
2991
2818
|
}
|
|
2992
2819
|
|
|
2993
2820
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual test to verify unawaited promise detection
|
|
3
|
+
*
|
|
4
|
+
* Run with: vitest run test/manual-unawaited-promise.test.mjs
|
|
5
|
+
*
|
|
6
|
+
* Expected: You should see a warning like:
|
|
7
|
+
* ⚠️ Warning: Previous find() may not have been awaited.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { TestDriver } from "../lib/vitest/hooks.mjs";
|
|
11
|
+
|
|
12
|
+
describe("Unawaited Promise Detection", () => {
|
|
13
|
+
it("should warn when a promise is not awaited", async (context) => {
|
|
14
|
+
const testdriver = TestDriver(context);
|
|
15
|
+
|
|
16
|
+
await testdriver.provision.chrome({
|
|
17
|
+
url: 'https://example.com',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// INTENTIONALLY missing await - should trigger warning on next call
|
|
21
|
+
testdriver.find("some button");
|
|
22
|
+
|
|
23
|
+
// This second call should print a warning about the previous unawaited find()
|
|
24
|
+
const element = await testdriver.find("Example Domain heading");
|
|
25
|
+
|
|
26
|
+
console.log("Element found:", element.found());
|
|
27
|
+
|
|
28
|
+
// If we got here without error and saw the warning, the feature works!
|
|
29
|
+
expect(true).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
});
|