testdriverai 7.2.80 → 7.2.82
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/ai/agents/testdriver.md +133 -94
- package/interfaces/cli/commands/init.js +147 -2
- package/interfaces/vitest-plugin.mjs +45 -1
- package/mcp-server/dist/server.mjs +133 -15
- package/package.json +2 -2
- package/sdk.d.ts +7 -0
- package/sdk.js +233 -1
package/ai/agents/testdriver.md
CHANGED
|
@@ -139,30 +139,32 @@ import { TestDriver } from "testdriverai/vitest/hooks";
|
|
|
139
139
|
|
|
140
140
|
describe("My Test Suite", () => {
|
|
141
141
|
it("should do something", async (context) => {
|
|
142
|
-
// Initialize TestDriver
|
|
142
|
+
// Initialize TestDriver - screenshots are captured automatically before/after each command
|
|
143
143
|
const testdriver = TestDriver(context);
|
|
144
144
|
|
|
145
145
|
// Start with provision - this launches the sandbox and browser
|
|
146
146
|
await testdriver.provision.chrome({
|
|
147
147
|
url: "https://example.com",
|
|
148
148
|
});
|
|
149
|
-
await testdriver.screenshot(); // Capture initial page state
|
|
150
149
|
|
|
151
150
|
// Find elements and interact
|
|
151
|
+
// Note: Screenshots are automatically captured before/after find() and click()
|
|
152
152
|
const button = await testdriver.find("Sign In button");
|
|
153
|
-
await testdriver.screenshot(); // Capture before click
|
|
154
153
|
await button.click();
|
|
155
154
|
await testdriver.wait(2000); // Wait for state change
|
|
156
|
-
await testdriver.screenshot(); // Capture after click
|
|
157
155
|
|
|
158
156
|
// Assert using natural language
|
|
159
|
-
|
|
157
|
+
// Screenshots are automatically captured before/after assert()
|
|
160
158
|
const result = await testdriver.assert("the dashboard is visible");
|
|
161
159
|
expect(result).toBeTruthy();
|
|
162
160
|
});
|
|
163
161
|
});
|
|
164
162
|
```
|
|
165
163
|
|
|
164
|
+
<Note>
|
|
165
|
+
**Automatic Screenshots**: TestDriver captures screenshots before and after every command by default. Screenshots are saved with descriptive names like `001-click-before-L42-submit-button.png` that include the line number from your test file.
|
|
166
|
+
</Note>
|
|
167
|
+
|
|
166
168
|
## Provisioning Options
|
|
167
169
|
|
|
168
170
|
Most tests start with `testdriver.provision`.
|
|
@@ -220,54 +222,21 @@ await element.mouseUp(); // release mouse
|
|
|
220
222
|
element.found(); // check if found (boolean)
|
|
221
223
|
```
|
|
222
224
|
|
|
223
|
-
### Screenshots
|
|
224
|
-
|
|
225
|
-
**Use `screenshot()` liberally throughout your tests** to capture the screen state at key moments. This makes debugging much easier when tests fail - you can see exactly what the screen looked like at each step.
|
|
225
|
+
### Automatic Screenshots (Enabled by Default)
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
// Capture a screenshot - saved to .testdriver/screenshots/<test-file>/
|
|
229
|
-
const screenshotPath = await testdriver.screenshot();
|
|
230
|
-
console.log("Screenshot saved to:", screenshotPath);
|
|
227
|
+
TestDriver **automatically captures screenshots before and after every command** by default. This creates a complete visual timeline without any additional code. Screenshots are named with the line number from your test file, making it easy to trace issues:
|
|
231
228
|
|
|
232
|
-
// Include mouse cursor in screenshot
|
|
233
|
-
await testdriver.screenshot(1, false, true);
|
|
234
229
|
```
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
-
|
|
238
|
-
-
|
|
239
|
-
-
|
|
240
|
-
-
|
|
241
|
-
-
|
|
242
|
-
- When debugging a flaky or failing test
|
|
243
|
-
|
|
244
|
-
**⚠️ Important: Add delays before screenshots after actions**
|
|
245
|
-
|
|
246
|
-
When you click or interact with an element that triggers a state change (page navigation, modal opening, content loading), **add a short delay before taking a screenshot** to allow the application state to update:
|
|
247
|
-
|
|
248
|
-
```javascript
|
|
249
|
-
await element.click();
|
|
250
|
-
await testdriver.wait(2000); // Wait 2-3 seconds for state change
|
|
251
|
-
await testdriver.screenshot(); // Now capture the updated state
|
|
230
|
+
.testdriver/screenshots/login.test/
|
|
231
|
+
001-find-before-L15-email-input.png
|
|
232
|
+
002-find-after-L15-email-input.png
|
|
233
|
+
003-click-before-L16-email-input.png
|
|
234
|
+
004-click-after-L16-email-input.png
|
|
235
|
+
005-type-before-L17-userexamplecom.png
|
|
236
|
+
006-type-after-L17-userexamplecom.png
|
|
252
237
|
```
|
|
253
238
|
|
|
254
|
-
|
|
255
|
-
- Navigation clicks (page transitions)
|
|
256
|
-
- Button clicks that open modals or dialogs
|
|
257
|
-
- Form submissions
|
|
258
|
-
- Actions that trigger AJAX requests or animations
|
|
259
|
-
- Any interaction where visual feedback takes time to appear
|
|
260
|
-
|
|
261
|
-
**Screenshot file organization:**
|
|
262
|
-
|
|
263
|
-
```
|
|
264
|
-
.testdriver/
|
|
265
|
-
screenshots/
|
|
266
|
-
login.test/ # Folder per test file
|
|
267
|
-
screenshot-1737633600000.png
|
|
268
|
-
checkout.test/
|
|
269
|
-
screenshot-1737633700000.png
|
|
270
|
-
```
|
|
239
|
+
**Filename format:** `<seq>-<action>-<phase>-L<line>-<description>.png`
|
|
271
240
|
|
|
272
241
|
> **Note:** The screenshot folder for each test file is automatically cleared when the test starts.
|
|
273
242
|
|
|
@@ -295,30 +264,30 @@ session_start({ type: "chrome", url: "https://your-app.com/login", testFile: "te
|
|
|
295
264
|
→ Response includes: "ACTION REQUIRED: Append this code..."
|
|
296
265
|
→ ⚠️ IMMEDIATELY write to tests/login.test.mjs:
|
|
297
266
|
await testdriver.provision.chrome({ url: "https://your-app.com/login" });
|
|
298
|
-
await testdriver.screenshot(); // Capture initial page state
|
|
299
267
|
```
|
|
300
268
|
|
|
301
269
|
This provisions a sandbox with Chrome and navigates to your URL. You'll see a screenshot of the initial page.
|
|
302
270
|
|
|
271
|
+
> **Note**: Screenshots are captured automatically before/after each command. The generated code no longer includes manual `screenshot()` calls.
|
|
272
|
+
|
|
303
273
|
### Step 2: Interact with the App
|
|
304
274
|
|
|
305
|
-
Find elements and interact with them. **Write code to file after EACH action
|
|
275
|
+
Find elements and interact with them. **Write code to file after EACH action:**
|
|
306
276
|
|
|
307
277
|
```
|
|
308
278
|
find_and_click({ description: "email input field" })
|
|
309
279
|
→ Returns: screenshot with element highlighted
|
|
310
280
|
→ ⚠️ IMMEDIATELY append to test file:
|
|
311
281
|
await testdriver.find("email input field").click();
|
|
312
|
-
await testdriver.wait(2000); // Wait for state change
|
|
313
|
-
await testdriver.screenshot(); // Capture after click
|
|
314
282
|
|
|
315
283
|
type({ text: "user@example.com" })
|
|
316
284
|
→ Returns: screenshot showing typed text
|
|
317
285
|
→ ⚠️ IMMEDIATELY append to test file:
|
|
318
286
|
await testdriver.type("user@example.com");
|
|
319
|
-
await testdriver.screenshot(); // Capture after typing
|
|
320
287
|
```
|
|
321
288
|
|
|
289
|
+
> **Note**: Screenshots are automatically captured before/after each command. Each screenshot filename includes the line number (e.g., `001-click-before-L42-email-input.png`).
|
|
290
|
+
|
|
322
291
|
### Step 3: Verify Actions Succeeded (For Your Understanding)
|
|
323
292
|
|
|
324
293
|
After actions, use `check` to verify they worked. This is for YOUR understanding - does NOT generate code:
|
|
@@ -336,7 +305,6 @@ Use `assert` for pass/fail conditions. This DOES generate code for the test file
|
|
|
336
305
|
assert({ assertion: "the dashboard is visible" })
|
|
337
306
|
→ Returns: pass/fail with screenshot
|
|
338
307
|
→ ⚠️ IMMEDIATELY append to test file:
|
|
339
|
-
await testdriver.screenshot(); // Capture before assertion
|
|
340
308
|
const assertResult = await testdriver.assert("the dashboard is visible");
|
|
341
309
|
expect(assertResult).toBeTruthy();
|
|
342
310
|
```
|
|
@@ -372,28 +340,97 @@ Analyze the output, fix any issues, and iterate until the test passes.
|
|
|
372
340
|
| `assert` | AI-powered boolean assertion - GENERATES CODE for test files |
|
|
373
341
|
| `exec` | Execute JavaScript, shell, or PowerShell in sandbox |
|
|
374
342
|
| `screenshot` | Capture screenshot - **only use when user explicitly asks** |
|
|
375
|
-
| `list_local_screenshots` | List screenshots
|
|
343
|
+
| `list_local_screenshots` | List/filter screenshots by line, action, phase, regex, etc. |
|
|
376
344
|
| `view_local_screenshot` | View a local screenshot (returns image to AI + displays to user) |
|
|
377
345
|
|
|
378
346
|
### Debugging with Local Screenshots
|
|
379
347
|
|
|
380
|
-
After test runs (successful or failed), you can view saved screenshots to understand test behavior
|
|
348
|
+
After test runs (successful or failed), you can view saved screenshots to understand test behavior.
|
|
349
|
+
|
|
350
|
+
**Screenshot filename format:** `<seq>-<action>-<phase>-L<line>-<description>.png`
|
|
351
|
+
Example: `001-click-before-L42-submit-button.png`
|
|
381
352
|
|
|
382
|
-
**1. List
|
|
353
|
+
**1. List all screenshots from a test:**
|
|
383
354
|
|
|
384
355
|
```
|
|
385
356
|
list_local_screenshots({ directory: "login.test" })
|
|
386
357
|
```
|
|
387
358
|
|
|
388
|
-
|
|
359
|
+
**2. Filter by line number (find what happened at a specific line):**
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
// Find screenshots from line 42
|
|
363
|
+
list_local_screenshots({ line: 42 })
|
|
364
|
+
|
|
365
|
+
// Find screenshots from lines 10-20
|
|
366
|
+
list_local_screenshots({ lineRange: { start: 10, end: 20 } })
|
|
367
|
+
```
|
|
389
368
|
|
|
390
|
-
**
|
|
369
|
+
**3. Filter by action type:**
|
|
391
370
|
|
|
392
371
|
```
|
|
393
|
-
|
|
372
|
+
// Find all click screenshots
|
|
373
|
+
list_local_screenshots({ action: "click" })
|
|
374
|
+
|
|
375
|
+
// Find all assertions
|
|
376
|
+
list_local_screenshots({ action: "assert" })
|
|
394
377
|
```
|
|
395
378
|
|
|
396
|
-
|
|
379
|
+
**4. Filter by phase (before/after):**
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
// See state BEFORE actions (useful for debugging what was visible)
|
|
383
|
+
list_local_screenshots({ phase: "before" })
|
|
384
|
+
|
|
385
|
+
// See state AFTER actions (useful for verifying results)
|
|
386
|
+
list_local_screenshots({ phase: "after" })
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**5. Filter by regex pattern:**
|
|
390
|
+
|
|
391
|
+
```
|
|
392
|
+
// Find screenshots related to login
|
|
393
|
+
list_local_screenshots({ pattern: "login|signin" })
|
|
394
|
+
|
|
395
|
+
// Find button-related screenshots
|
|
396
|
+
list_local_screenshots({ pattern: "button.*click" })
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**6. Filter by sequence number:**
|
|
400
|
+
|
|
401
|
+
```
|
|
402
|
+
// Find screenshots 1-5 (first 5 actions)
|
|
403
|
+
list_local_screenshots({ sequenceRange: { start: 1, end: 5 } })
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**7. Sort results:**
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
// Sort by execution order (useful for understanding flow)
|
|
410
|
+
list_local_screenshots({ sortBy: "sequence" })
|
|
411
|
+
|
|
412
|
+
// Sort by line number (useful for tracing back to code)
|
|
413
|
+
list_local_screenshots({ sortBy: "line" })
|
|
414
|
+
|
|
415
|
+
// Sort by modified time (default - newest first)
|
|
416
|
+
list_local_screenshots({ sortBy: "modified" })
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**8. Combine filters:**
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
// Find click screenshots at line 42
|
|
423
|
+
list_local_screenshots({ directory: "checkout.test", line: 42, action: "click" })
|
|
424
|
+
|
|
425
|
+
// Find all "before" screenshots in lines 10-30
|
|
426
|
+
list_local_screenshots({ lineRange: { start: 10, end: 30 }, phase: "before" })
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
**9. View a screenshot:**
|
|
430
|
+
|
|
431
|
+
```
|
|
432
|
+
view_local_screenshot({ path: ".testdriver/screenshots/login.test/001-click-before-L42-submit-button.png" })
|
|
433
|
+
```
|
|
397
434
|
|
|
398
435
|
**When to use screenshot viewing:**
|
|
399
436
|
|
|
@@ -402,17 +439,18 @@ This displays the screenshot to both you (the AI) and the user via MCP App.
|
|
|
402
439
|
- **Comparing test runs** - View screenshots from multiple runs to identify flaky behavior
|
|
403
440
|
- **Verifying test logic** - Before running a test, view screenshots from previous runs to understand the UI flow
|
|
404
441
|
|
|
405
|
-
**
|
|
442
|
+
**Debugging workflow example:**
|
|
406
443
|
|
|
407
444
|
```
|
|
408
|
-
# Test failed, let's
|
|
409
|
-
list_local_screenshots({
|
|
445
|
+
# Test failed at line 42, let's see what happened
|
|
446
|
+
list_local_screenshots({ line: 42 })
|
|
410
447
|
|
|
411
|
-
# View the
|
|
412
|
-
view_local_screenshot({ path: ".testdriver/screenshots/checkout.test/
|
|
413
|
-
view_local_screenshot({ path: ".testdriver/screenshots/checkout.test/
|
|
448
|
+
# View the before/after state at that line
|
|
449
|
+
view_local_screenshot({ path: ".testdriver/screenshots/checkout.test/005-click-before-L42-submit-button.png" })
|
|
450
|
+
view_local_screenshot({ path: ".testdriver/screenshots/checkout.test/006-click-after-L42-submit-button.png" })
|
|
414
451
|
|
|
415
|
-
#
|
|
452
|
+
# Check what the screen looked like before the failing action
|
|
453
|
+
list_local_screenshots({ directory: "checkout.test", phase: "before", limit: 10 })
|
|
416
454
|
```
|
|
417
455
|
|
|
418
456
|
### Tips for MCP Workflow
|
|
@@ -437,28 +475,28 @@ view_local_screenshot({ path: ".testdriver/screenshots/checkout.test/before-asse
|
|
|
437
475
|
|
|
438
476
|
```javascript
|
|
439
477
|
// Development workflow example
|
|
478
|
+
// Note: Screenshots are automatically captured before/after each command!
|
|
440
479
|
it("should incrementally build test", async (context) => {
|
|
441
480
|
const testdriver = TestDriver(context);
|
|
442
481
|
await testdriver.provision.chrome({ url: "https://example.com" });
|
|
443
|
-
|
|
482
|
+
// Automatic screenshot: 001-provision-after-L3-chrome.png
|
|
444
483
|
|
|
445
484
|
// Step 1: Find and inspect
|
|
446
485
|
const element = await testdriver.find("Some button");
|
|
447
486
|
console.log("Element found:", element.found());
|
|
448
487
|
console.log("Coordinates:", element.x, element.y);
|
|
449
488
|
console.log("Confidence:", element.confidence);
|
|
450
|
-
|
|
489
|
+
// Automatic screenshot: 002-find-after-L7-some-button.png
|
|
451
490
|
|
|
452
491
|
// Step 2: Interact
|
|
453
492
|
await element.click();
|
|
454
|
-
|
|
455
|
-
await testdriver.screenshot(); // Capture after click
|
|
493
|
+
// Automatic screenshot: 003-click-after-L13-element.png
|
|
456
494
|
|
|
457
|
-
// Step 3: Assert
|
|
458
|
-
await testdriver.screenshot(); // Capture before assertion
|
|
495
|
+
// Step 3: Assert
|
|
459
496
|
const result = await testdriver.assert("Something happened");
|
|
460
497
|
console.log("Assertion result:", result);
|
|
461
498
|
expect(result).toBeTruthy();
|
|
499
|
+
// Automatic screenshot: 004-assert-after-L17-something-happened.png
|
|
462
500
|
|
|
463
501
|
// Then add more steps...
|
|
464
502
|
});
|
|
@@ -476,6 +514,7 @@ const testdriver = TestDriver(context, {
|
|
|
476
514
|
resolution: "1366x768", // Sandbox resolution
|
|
477
515
|
cache: true, // Enable element caching (default: true)
|
|
478
516
|
cacheKey: "my-test", // Cache key for element finding
|
|
517
|
+
autoScreenshots: true, // Capture screenshots before/after each command (default: true)
|
|
479
518
|
});
|
|
480
519
|
```
|
|
481
520
|
|
|
@@ -550,36 +589,36 @@ const date = await testdriver.exec("pwsh", "Get-Date", 5000);
|
|
|
550
589
|
|
|
551
590
|
### Capturing Screenshots
|
|
552
591
|
|
|
553
|
-
**
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
// Capture with mouse cursor visible
|
|
560
|
-
await testdriver.screenshot(1, false, true);
|
|
592
|
+
**Screenshots are captured automatically** before and after each SDK command (click, type, find, assert, etc.). Each screenshot filename includes:
|
|
593
|
+
- Sequential number for chronological ordering
|
|
594
|
+
- Action name (e.g., `click`, `find`, `assert`)
|
|
595
|
+
- Phase (`before` or `after`)
|
|
596
|
+
- Line number from your test file
|
|
597
|
+
- Description from the command
|
|
561
598
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
599
|
+
Example filenames:
|
|
600
|
+
- `001-provision-after-L8-chrome.png`
|
|
601
|
+
- `002-find-before-L12-login-button.png`
|
|
602
|
+
- `003-click-after-L12-element.png`
|
|
565
603
|
|
|
566
|
-
|
|
567
|
-
await testdriver.wait(2000); // Wait for state change
|
|
568
|
-
await testdriver.screenshot(); // After click
|
|
604
|
+
Screenshots are saved to `.testdriver/screenshots/<test-file>/`.
|
|
569
605
|
|
|
570
|
-
|
|
571
|
-
|
|
606
|
+
To disable automatic screenshots:
|
|
607
|
+
```javascript
|
|
608
|
+
const testdriver = TestDriver(context, { autoScreenshots: false });
|
|
609
|
+
```
|
|
572
610
|
|
|
573
|
-
|
|
574
|
-
|
|
611
|
+
For manual screenshots (e.g., with mouse cursor visible):
|
|
612
|
+
```javascript
|
|
613
|
+
await testdriver.screenshot(1, false, true);
|
|
575
614
|
```
|
|
576
615
|
|
|
577
616
|
## Tips for Agents
|
|
578
617
|
|
|
579
618
|
1. **⚠️ WRITE CODE IMMEDIATELY** - After EVERY successful MCP action, append the generated code to the test file RIGHT AWAY. Do NOT wait until the session ends.
|
|
580
619
|
2. **⚠️ RUN TESTS YOURSELF** - Do NOT tell the user to run tests. YOU must run the tests using `npx vitest run <testFile> --reporter=dot`. Always use `--reporter=dot` for cleaner output. Analyze the output and iterate until the test passes. **Always share the test report link** (e.g., `https://app.testdriver.ai/projects/.../reports/...`) with the user after each run.
|
|
581
|
-
3.
|
|
582
|
-
4. **⚠️ USE SCREENSHOT VIEWING FOR DEBUGGING** - When tests fail, use `list_local_screenshots` and `view_local_screenshot` MCP commands to see exactly what the UI looked like.
|
|
620
|
+
3. **Screenshots are automatic** - TestDriver captures screenshots before/after every command by default. Each screenshot filename includes the line number (e.g., `001-click-before-L42-submit-button.png`) making it easy to trace issues.
|
|
621
|
+
4. **⚠️ USE SCREENSHOT VIEWING FOR DEBUGGING** - When tests fail, use `list_local_screenshots` and `view_local_screenshot` MCP commands to see exactly what the UI looked like. The filenames tell you which line of code triggered each screenshot.
|
|
583
622
|
5. **⚠️ NEVER USE `.wait()`** - Do NOT use any `.wait()` method. Instead, use `find()` with a `timeout` option to poll for elements, or use `assert()` / `check()` to verify state. Explicit waits are flaky and slow.
|
|
584
623
|
6. **Use MCP tools for development** - Build tests interactively with visual feedback
|
|
585
624
|
7. **Always check `sdk.d.ts`** for method signatures and types when debugging generated tests
|
|
@@ -8,6 +8,14 @@ const readline = require("readline");
|
|
|
8
8
|
const os = require("os");
|
|
9
9
|
const { execSync } = require("child_process");
|
|
10
10
|
|
|
11
|
+
// Load .env file for CLI usage (TD_API_ROOT, etc.)
|
|
12
|
+
require("dotenv").config();
|
|
13
|
+
|
|
14
|
+
// API configuration
|
|
15
|
+
const API_BASE_URL = process.env.TD_API_ROOT || "https://v6.testdriver.ai";
|
|
16
|
+
const POLL_INTERVAL = 5000; // 5 seconds
|
|
17
|
+
const POLL_TIMEOUT = 900000; // 15 minutes
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* Init command - scaffolds Vitest SDK example tests for TestDriver
|
|
13
21
|
*/
|
|
@@ -79,8 +87,33 @@ class InitCommand extends BaseCommand {
|
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
console.log(chalk.cyan(" Setting up your TestDriver API key...\n"));
|
|
90
|
+
|
|
91
|
+
// Ask user how they want to authenticate
|
|
92
|
+
const choice = await this.askChoice(
|
|
93
|
+
" How would you like to authenticate?\n",
|
|
94
|
+
[
|
|
95
|
+
{ key: "1", label: "Login with browser", description: "(recommended)" },
|
|
96
|
+
{ key: "2", label: "Enter API key manually", description: "" },
|
|
97
|
+
],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (choice === "1") {
|
|
101
|
+
// Browser login flow
|
|
102
|
+
try {
|
|
103
|
+
const apiKey = await this.browserLogin();
|
|
104
|
+
if (apiKey) {
|
|
105
|
+
console.log(chalk.green("\n ✓ Logged in successfully!\n"));
|
|
106
|
+
return apiKey;
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.log(chalk.yellow(`\n ⚠️ Browser login failed: ${error.message}\n`));
|
|
110
|
+
console.log(chalk.gray(" Falling back to manual API key entry...\n"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Manual API key entry
|
|
82
115
|
console.log(
|
|
83
|
-
chalk.gray(" Get your API key from: https://console.testdriver.ai/team"),
|
|
116
|
+
chalk.gray(" Get your API key from: https://console.testdriver.ai/team\n"),
|
|
84
117
|
);
|
|
85
118
|
|
|
86
119
|
// Ask if user wants to open the browser
|
|
@@ -89,7 +122,6 @@ class InitCommand extends BaseCommand {
|
|
|
89
122
|
);
|
|
90
123
|
if (shouldOpen) {
|
|
91
124
|
try {
|
|
92
|
-
// Dynamic import for ES module
|
|
93
125
|
const open = (await import("open")).default;
|
|
94
126
|
await open("https://console.testdriver.ai/team");
|
|
95
127
|
console.log(chalk.gray(" Opening browser...\n"));
|
|
@@ -119,6 +151,119 @@ class InitCommand extends BaseCommand {
|
|
|
119
151
|
}
|
|
120
152
|
}
|
|
121
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Browser-based login flow using device code
|
|
156
|
+
* @returns {Promise<string>} The API key
|
|
157
|
+
*/
|
|
158
|
+
async browserLogin() {
|
|
159
|
+
// Step 1: Create device code
|
|
160
|
+
process.stdout.write(chalk.gray(" Requesting authorization code..."));
|
|
161
|
+
|
|
162
|
+
const createResponse = await fetch(`${API_BASE_URL}/auth/device/code`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!createResponse.ok) {
|
|
168
|
+
throw new Error("Failed to create device code");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { device_code, verification_uri, expires_in, interval } = await createResponse.json();
|
|
172
|
+
console.log(chalk.green(" done\n"));
|
|
173
|
+
|
|
174
|
+
// Step 2: Open browser
|
|
175
|
+
console.log(chalk.cyan(` Opening browser to authorize CLI...\n`));
|
|
176
|
+
console.log(chalk.gray(` If browser doesn't open, visit:\n ${verification_uri}\n`));
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const open = (await import("open")).default;
|
|
180
|
+
await open(verification_uri);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Browser didn't open, user can use the URL manually
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Step 3: Poll for token
|
|
186
|
+
const pollInterval = (interval || 5) * 1000;
|
|
187
|
+
const timeout = (expires_in || 900) * 1000;
|
|
188
|
+
const startTime = Date.now();
|
|
189
|
+
|
|
190
|
+
process.stdout.write(chalk.gray(" Waiting for authorization..."));
|
|
191
|
+
|
|
192
|
+
// Start spinner
|
|
193
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
194
|
+
let spinnerIndex = 0;
|
|
195
|
+
const spinnerInterval = setInterval(() => {
|
|
196
|
+
process.stdout.write(`\r Waiting for authorization... ${spinnerFrames[spinnerIndex]}`);
|
|
197
|
+
spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
|
|
198
|
+
}, 100);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
while (Date.now() - startTime < timeout) {
|
|
202
|
+
await this.sleep(pollInterval);
|
|
203
|
+
|
|
204
|
+
const tokenResponse = await fetch(`${API_BASE_URL}/auth/device/token`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "Content-Type": "application/json" },
|
|
207
|
+
body: JSON.stringify({ deviceCode: device_code }),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const data = await tokenResponse.json();
|
|
211
|
+
|
|
212
|
+
if (tokenResponse.ok && data.apiKey) {
|
|
213
|
+
clearInterval(spinnerInterval);
|
|
214
|
+
process.stdout.write("\r Waiting for authorization... " + chalk.green("✓") + "\n");
|
|
215
|
+
return data.apiKey;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (data.error === "expired_token") {
|
|
219
|
+
clearInterval(spinnerInterval);
|
|
220
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// authorization_pending - continue polling
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
clearInterval(spinnerInterval);
|
|
227
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
228
|
+
} catch (error) {
|
|
229
|
+
clearInterval(spinnerInterval);
|
|
230
|
+
process.stdout.write("\n");
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Ask user to choose from a list of options
|
|
237
|
+
*/
|
|
238
|
+
async askChoice(question, options) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const rl = readline.createInterface({
|
|
241
|
+
input: process.stdin,
|
|
242
|
+
output: process.stdout,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
console.log(question);
|
|
246
|
+
for (const opt of options) {
|
|
247
|
+
const desc = opt.description ? chalk.gray(` ${opt.description}`) : "";
|
|
248
|
+
console.log(` ${chalk.cyan(opt.key)}. ${opt.label}${desc}`);
|
|
249
|
+
}
|
|
250
|
+
console.log("");
|
|
251
|
+
|
|
252
|
+
rl.question(" Enter choice [1]: ", (answer) => {
|
|
253
|
+
rl.close();
|
|
254
|
+
const normalized = answer.trim() || "1";
|
|
255
|
+
resolve(normalized);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Sleep for a given number of milliseconds
|
|
262
|
+
*/
|
|
263
|
+
sleep(ms) {
|
|
264
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
265
|
+
}
|
|
266
|
+
|
|
122
267
|
/**
|
|
123
268
|
* Prompt for hidden input (like password)
|
|
124
269
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
2
|
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
3
4
|
import { createRequire } from "module";
|
|
4
5
|
import path from "path";
|
|
5
6
|
import { postOrUpdateTestResults } from "../lib/github-comment.mjs";
|
|
@@ -1201,6 +1202,49 @@ function getGitInfo() {
|
|
|
1201
1202
|
// GitHub Comment Helper
|
|
1202
1203
|
// ============================================================================
|
|
1203
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* Extract PR number from GitHub Actions environment
|
|
1207
|
+
* Checks multiple sources: env vars, event file, and GITHUB_REF
|
|
1208
|
+
* @returns {string|null} PR number or null if not found
|
|
1209
|
+
*/
|
|
1210
|
+
function extractPRNumber() {
|
|
1211
|
+
// Try direct environment variables first
|
|
1212
|
+
let prNumber =
|
|
1213
|
+
process.env.GITHUB_PR_NUMBER ||
|
|
1214
|
+
process.env.TD_GITHUB_PR ||
|
|
1215
|
+
process.env.PR_NUMBER;
|
|
1216
|
+
|
|
1217
|
+
if (prNumber) {
|
|
1218
|
+
return prNumber;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Try to extract from GitHub Actions event path
|
|
1222
|
+
if (process.env.GITHUB_EVENT_PATH) {
|
|
1223
|
+
try {
|
|
1224
|
+
const eventData = JSON.parse(
|
|
1225
|
+
fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"),
|
|
1226
|
+
);
|
|
1227
|
+
if (eventData.pull_request?.number) {
|
|
1228
|
+
return String(eventData.pull_request.number);
|
|
1229
|
+
}
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
logger.debug("Could not read GitHub event file:", err.message);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Try to extract from GITHUB_REF (refs/pull/123/merge or refs/pull/123/head)
|
|
1236
|
+
if (process.env.GITHUB_REF) {
|
|
1237
|
+
const match = process.env.GITHUB_REF.match(
|
|
1238
|
+
/refs\/pull\/(\d+)\/(merge|head)/,
|
|
1239
|
+
);
|
|
1240
|
+
if (match) {
|
|
1241
|
+
return match[1];
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1204
1248
|
/**
|
|
1205
1249
|
* Post GitHub comment with test results if enabled
|
|
1206
1250
|
* Checks for GitHub token and PR number in environment variables
|
|
@@ -1220,7 +1264,7 @@ async function postGitHubCommentIfEnabled(testRunUrl, stats, completeData) {
|
|
|
1220
1264
|
|
|
1221
1265
|
// Check if GitHub comment posting is enabled
|
|
1222
1266
|
const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
1223
|
-
const prNumber =
|
|
1267
|
+
const prNumber = extractPRNumber();
|
|
1224
1268
|
const commitSha = process.env.GITHUB_SHA || pluginState.gitInfo.commit;
|
|
1225
1269
|
|
|
1226
1270
|
// Only post if we have a token and either a PR number or commit SHA
|
|
@@ -1388,20 +1388,57 @@ server.registerTool("exec", {
|
|
|
1388
1388
|
throw error;
|
|
1389
1389
|
}
|
|
1390
1390
|
});
|
|
1391
|
+
function parseScreenshotFilename(filename) {
|
|
1392
|
+
// Match pattern: 001-click-before-L42-submit-button.png or 001-click-error-L42-submit-button.png
|
|
1393
|
+
const match = filename.match(/^(\d+)-([a-z]+)-(before|after|error)-L(\d+)-(.+)\.png$/i);
|
|
1394
|
+
if (match) {
|
|
1395
|
+
return {
|
|
1396
|
+
sequence: parseInt(match[1], 10),
|
|
1397
|
+
action: match[2].toLowerCase(),
|
|
1398
|
+
phase: match[3].toLowerCase(),
|
|
1399
|
+
lineNumber: parseInt(match[4], 10),
|
|
1400
|
+
description: match[5],
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
return {};
|
|
1404
|
+
}
|
|
1391
1405
|
// List Local Screenshots - lists screenshots saved to .testdriver directory
|
|
1392
1406
|
server.registerTool("list_local_screenshots", {
|
|
1393
|
-
description: `List screenshots saved in the .testdriver directory.
|
|
1407
|
+
description: `List and filter screenshots saved in the .testdriver directory.
|
|
1408
|
+
|
|
1409
|
+
Screenshots from auto-screenshot feature use the format: <seq>-<action>-<phase>-L<line>-<description>.png
|
|
1410
|
+
Example: 001-click-before-L42-submit-button.png
|
|
1394
1411
|
|
|
1395
|
-
This tool
|
|
1396
|
-
|
|
1412
|
+
This tool supports powerful filtering to find specific screenshots:
|
|
1413
|
+
- By test file (directory)
|
|
1414
|
+
- By line number or range
|
|
1415
|
+
- By action type (click, find, type, assert, etc.)
|
|
1416
|
+
- By phase (before/after/error - error screenshots are captured when actions fail)
|
|
1417
|
+
- By regex pattern on filename
|
|
1418
|
+
- By sequence number range
|
|
1397
1419
|
|
|
1398
1420
|
Returns a list of screenshot paths that can be viewed with the 'view_local_screenshot' tool.`,
|
|
1399
1421
|
inputSchema: z.object({
|
|
1400
|
-
directory: z.string().optional().describe("
|
|
1422
|
+
directory: z.string().optional().describe("Test file or subdirectory to search (e.g., 'login.test', 'mcp-screenshots'). If not provided, searches all."),
|
|
1423
|
+
line: z.number().optional().describe("Filter by exact line number from test file (e.g., 42 matches L42)"),
|
|
1424
|
+
lineRange: z.object({
|
|
1425
|
+
start: z.number().describe("Start line number (inclusive)"),
|
|
1426
|
+
end: z.number().describe("End line number (inclusive)"),
|
|
1427
|
+
}).optional().describe("Filter by line number range (e.g., { start: 10, end: 20 })"),
|
|
1428
|
+
action: z.string().optional().describe("Filter by action type: click, find, type, assert, provision, scroll, hover, etc."),
|
|
1429
|
+
phase: z.enum(["before", "after", "error"]).optional().describe("Filter by phase: 'before' (pre-action), 'after' (post-action), or 'error' (when action fails)"),
|
|
1430
|
+
pattern: z.string().optional().describe("Regex pattern to match against filename (e.g., 'submit|login' or 'button.*click')"),
|
|
1431
|
+
sequence: z.number().optional().describe("Filter by exact sequence number"),
|
|
1432
|
+
sequenceRange: z.object({
|
|
1433
|
+
start: z.number().describe("Start sequence (inclusive)"),
|
|
1434
|
+
end: z.number().describe("End sequence (inclusive)"),
|
|
1435
|
+
}).optional().describe("Filter by sequence range (e.g., { start: 1, end: 10 })"),
|
|
1436
|
+
limit: z.number().optional().describe("Maximum number of results to return (default: 50)"),
|
|
1437
|
+
sortBy: z.enum(["modified", "sequence", "line"]).optional().describe("Sort by: 'modified' (newest first), 'sequence' (execution order), or 'line' (line number). Default: 'modified'"),
|
|
1401
1438
|
}),
|
|
1402
1439
|
}, async (params) => {
|
|
1403
1440
|
const startTime = Date.now();
|
|
1404
|
-
logger.info("list_local_screenshots: Starting", {
|
|
1441
|
+
logger.info("list_local_screenshots: Starting", { ...params });
|
|
1405
1442
|
try {
|
|
1406
1443
|
// Find .testdriver directory - check current working directory and common locations
|
|
1407
1444
|
const possiblePaths = [
|
|
@@ -1420,6 +1457,16 @@ Returns a list of screenshot paths that can be viewed with the 'view_local_scree
|
|
|
1420
1457
|
return createToolResult(false, "No .testdriver directory found. Screenshots are saved here during test runs.", { error: "Directory not found" });
|
|
1421
1458
|
}
|
|
1422
1459
|
const screenshots = [];
|
|
1460
|
+
// Compile regex pattern if provided
|
|
1461
|
+
let regexPattern = null;
|
|
1462
|
+
if (params.pattern) {
|
|
1463
|
+
try {
|
|
1464
|
+
regexPattern = new RegExp(params.pattern, "i");
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
return createToolResult(false, `Invalid regex pattern: ${params.pattern}`, { error: "Invalid regex" });
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1423
1470
|
// Function to recursively find PNG files
|
|
1424
1471
|
const findPngFiles = (dir) => {
|
|
1425
1472
|
if (!fs.existsSync(dir))
|
|
@@ -1434,49 +1481,120 @@ Returns a list of screenshot paths that can be viewed with the 'view_local_scree
|
|
|
1434
1481
|
}
|
|
1435
1482
|
}
|
|
1436
1483
|
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".png")) {
|
|
1484
|
+
const parsed = parseScreenshotFilename(entry.name);
|
|
1485
|
+
// Apply filters
|
|
1486
|
+
if (params.line !== undefined && parsed.lineNumber !== params.line)
|
|
1487
|
+
continue;
|
|
1488
|
+
if (params.lineRange && (parsed.lineNumber === undefined ||
|
|
1489
|
+
parsed.lineNumber < params.lineRange.start ||
|
|
1490
|
+
parsed.lineNumber > params.lineRange.end))
|
|
1491
|
+
continue;
|
|
1492
|
+
if (params.action && parsed.action !== params.action.toLowerCase())
|
|
1493
|
+
continue;
|
|
1494
|
+
if (params.phase && parsed.phase !== params.phase)
|
|
1495
|
+
continue;
|
|
1496
|
+
if (params.sequence !== undefined && parsed.sequence !== params.sequence)
|
|
1497
|
+
continue;
|
|
1498
|
+
if (params.sequenceRange && (parsed.sequence === undefined ||
|
|
1499
|
+
parsed.sequence < params.sequenceRange.start ||
|
|
1500
|
+
parsed.sequence > params.sequenceRange.end))
|
|
1501
|
+
continue;
|
|
1502
|
+
if (regexPattern && !regexPattern.test(entry.name))
|
|
1503
|
+
continue;
|
|
1437
1504
|
const stats = fs.statSync(fullPath);
|
|
1438
1505
|
screenshots.push({
|
|
1439
1506
|
path: fullPath,
|
|
1440
1507
|
name: entry.name,
|
|
1441
1508
|
modified: stats.mtime,
|
|
1442
1509
|
size: stats.size,
|
|
1510
|
+
parsed,
|
|
1443
1511
|
});
|
|
1444
1512
|
}
|
|
1445
1513
|
}
|
|
1446
1514
|
};
|
|
1447
1515
|
findPngFiles(testdriverDir);
|
|
1448
|
-
// Sort
|
|
1449
|
-
|
|
1516
|
+
// Sort based on sortBy parameter
|
|
1517
|
+
const sortBy = params.sortBy || "modified";
|
|
1518
|
+
if (sortBy === "modified") {
|
|
1519
|
+
screenshots.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1520
|
+
}
|
|
1521
|
+
else if (sortBy === "sequence") {
|
|
1522
|
+
screenshots.sort((a, b) => (a.parsed.sequence ?? Infinity) - (b.parsed.sequence ?? Infinity));
|
|
1523
|
+
}
|
|
1524
|
+
else if (sortBy === "line") {
|
|
1525
|
+
screenshots.sort((a, b) => (a.parsed.lineNumber ?? Infinity) - (b.parsed.lineNumber ?? Infinity));
|
|
1526
|
+
}
|
|
1450
1527
|
const duration = Date.now() - startTime;
|
|
1451
1528
|
logger.info("list_local_screenshots: Completed", { count: screenshots.length, duration });
|
|
1452
1529
|
if (screenshots.length === 0) {
|
|
1453
|
-
|
|
1530
|
+
const filters = [];
|
|
1531
|
+
if (params.directory)
|
|
1532
|
+
filters.push(`directory=${params.directory}`);
|
|
1533
|
+
if (params.line)
|
|
1534
|
+
filters.push(`line=${params.line}`);
|
|
1535
|
+
if (params.lineRange)
|
|
1536
|
+
filters.push(`lineRange=${params.lineRange.start}-${params.lineRange.end}`);
|
|
1537
|
+
if (params.action)
|
|
1538
|
+
filters.push(`action=${params.action}`);
|
|
1539
|
+
if (params.phase)
|
|
1540
|
+
filters.push(`phase=${params.phase}`);
|
|
1541
|
+
if (params.pattern)
|
|
1542
|
+
filters.push(`pattern=${params.pattern}`);
|
|
1543
|
+
if (params.sequence)
|
|
1544
|
+
filters.push(`sequence=${params.sequence}`);
|
|
1545
|
+
if (params.sequenceRange)
|
|
1546
|
+
filters.push(`sequenceRange=${params.sequenceRange.start}-${params.sequenceRange.end}`);
|
|
1547
|
+
const filterMsg = filters.length > 0 ? ` with filters: ${filters.join(", ")}` : "";
|
|
1548
|
+
return createToolResult(true, `No screenshots found in .testdriver directory${filterMsg}.`, {
|
|
1454
1549
|
action: "list_local_screenshots",
|
|
1455
1550
|
count: 0,
|
|
1456
1551
|
directory: testdriverDir,
|
|
1552
|
+
filters: params,
|
|
1457
1553
|
duration
|
|
1458
1554
|
});
|
|
1459
1555
|
}
|
|
1460
|
-
|
|
1461
|
-
const
|
|
1556
|
+
const limit = params.limit || 50;
|
|
1557
|
+
const limitedScreenshots = screenshots.slice(0, limit);
|
|
1558
|
+
// Format the list for display with parsed info
|
|
1559
|
+
const screenshotList = limitedScreenshots.map((s, i) => {
|
|
1462
1560
|
const relativePath = path.relative(testdriverDir, s.path);
|
|
1463
1561
|
const sizeKB = Math.round(s.size / 1024);
|
|
1464
1562
|
const timeAgo = formatTimeAgo(s.modified);
|
|
1465
|
-
|
|
1563
|
+
// Add parsed info if available
|
|
1564
|
+
const parts = [`${i + 1}. ${relativePath}`];
|
|
1565
|
+
const meta = [];
|
|
1566
|
+
if (s.parsed.lineNumber)
|
|
1567
|
+
meta.push(`L${s.parsed.lineNumber}`);
|
|
1568
|
+
if (s.parsed.action)
|
|
1569
|
+
meta.push(s.parsed.action);
|
|
1570
|
+
if (s.parsed.phase)
|
|
1571
|
+
meta.push(s.parsed.phase);
|
|
1572
|
+
meta.push(`${sizeKB}KB`);
|
|
1573
|
+
meta.push(timeAgo);
|
|
1574
|
+
parts.push(`(${meta.join(", ")})`);
|
|
1575
|
+
return parts.join(" ");
|
|
1466
1576
|
}).join("\n");
|
|
1467
|
-
const message = screenshots.length >
|
|
1468
|
-
? `Found ${screenshots.length} screenshots (showing
|
|
1469
|
-
: `Found ${screenshots.length} screenshot(s):\n\n${screenshotList}`;
|
|
1577
|
+
const message = screenshots.length > limit
|
|
1578
|
+
? `Found ${screenshots.length} screenshots (showing ${limit} results, sorted by ${sortBy}):\n\n${screenshotList}`
|
|
1579
|
+
: `Found ${screenshots.length} screenshot(s) (sorted by ${sortBy}):\n\n${screenshotList}`;
|
|
1470
1580
|
return createToolResult(true, message, {
|
|
1471
1581
|
action: "list_local_screenshots",
|
|
1472
1582
|
count: screenshots.length,
|
|
1583
|
+
returned: limitedScreenshots.length,
|
|
1473
1584
|
directory: testdriverDir,
|
|
1474
|
-
|
|
1585
|
+
filters: params,
|
|
1586
|
+
sortBy,
|
|
1587
|
+
screenshots: limitedScreenshots.map(s => ({
|
|
1475
1588
|
path: s.path,
|
|
1476
1589
|
relativePath: path.relative(testdriverDir, s.path),
|
|
1477
1590
|
name: s.name,
|
|
1478
1591
|
modified: s.modified.toISOString(),
|
|
1479
1592
|
sizeBytes: s.size,
|
|
1593
|
+
sequence: s.parsed.sequence,
|
|
1594
|
+
action: s.parsed.action,
|
|
1595
|
+
phase: s.parsed.phase,
|
|
1596
|
+
lineNumber: s.parsed.lineNumber,
|
|
1597
|
+
description: s.parsed.description,
|
|
1480
1598
|
})),
|
|
1481
1599
|
duration
|
|
1482
1600
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testdriverai",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.82",
|
|
4
4
|
"description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
|
|
5
5
|
"main": "sdk.js",
|
|
6
6
|
"types": "sdk.d.ts",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"mocha": "^10.8.2",
|
|
133
133
|
"node-addon-api": "^8.0.0",
|
|
134
134
|
"prettier": "3.3.3",
|
|
135
|
-
"testdriverai": "^7.2.
|
|
135
|
+
"testdriverai": "^7.2.79",
|
|
136
136
|
"vitest": "^4.0.18"
|
|
137
137
|
},
|
|
138
138
|
"optionalDependencies": {
|
package/sdk.d.ts
CHANGED
|
@@ -263,6 +263,13 @@ export interface TestDriverOptions {
|
|
|
263
263
|
reconnect?: boolean;
|
|
264
264
|
/** Enable/disable Dashcam video recording (default: true) */
|
|
265
265
|
dashcam?: boolean;
|
|
266
|
+
/**
|
|
267
|
+
* Enable automatic screenshots before and after each command (default: true)
|
|
268
|
+
* Screenshots are saved to .testdriver/screenshots/<test>/ with descriptive filenames
|
|
269
|
+
* Format: <seq>-<action>-<phase>-L<line>-<description>.png
|
|
270
|
+
* Example: 001-click-before-L42-submit-button.png
|
|
271
|
+
*/
|
|
272
|
+
autoScreenshots?: boolean;
|
|
266
273
|
/** Redraw configuration for screen change detection */
|
|
267
274
|
redraw?:
|
|
268
275
|
| boolean
|
package/sdk.js
CHANGED
|
@@ -69,6 +69,53 @@ function getCallerFileHash() {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Get detailed caller information including file path, line number, and column
|
|
74
|
+
* Used for automatic screenshot naming to identify which line of code triggered an action
|
|
75
|
+
* @param {number} [skipFrames=0] - Additional frames to skip in the stack trace
|
|
76
|
+
* @returns {{filePath: string|null, line: number|null, column: number|null, functionName: string|null}}
|
|
77
|
+
*/
|
|
78
|
+
function getCallerInfo(skipFrames = 0) {
|
|
79
|
+
const originalPrepareStackTrace = Error.prepareStackTrace;
|
|
80
|
+
try {
|
|
81
|
+
const err = new Error();
|
|
82
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
83
|
+
const stack = err.stack;
|
|
84
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
85
|
+
|
|
86
|
+
// Look for the first file that's not sdk.js, hooks.mjs, or node internals
|
|
87
|
+
let skipped = 0;
|
|
88
|
+
for (const callSite of stack) {
|
|
89
|
+
const fileName = callSite.getFileName();
|
|
90
|
+
if (
|
|
91
|
+
fileName &&
|
|
92
|
+
!fileName.includes("sdk.js") &&
|
|
93
|
+
!fileName.includes("hooks.mjs") &&
|
|
94
|
+
!fileName.includes("hooks.js") &&
|
|
95
|
+
!fileName.includes("node_modules") &&
|
|
96
|
+
!fileName.includes("node:internal") &&
|
|
97
|
+
fileName !== "evalmachine.<anonymous>"
|
|
98
|
+
) {
|
|
99
|
+
if (skipped < skipFrames) {
|
|
100
|
+
skipped++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
filePath: fileName,
|
|
105
|
+
line: callSite.getLineNumber(),
|
|
106
|
+
column: callSite.getColumnNumber(),
|
|
107
|
+
functionName: callSite.getFunctionName(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Silently fail and return nulls
|
|
113
|
+
} finally {
|
|
114
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
115
|
+
}
|
|
116
|
+
return { filePath: null, line: null, column: null, functionName: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
/**
|
|
73
120
|
* Custom error class for element operation failures
|
|
74
121
|
* Includes debugging information like screenshots and AI responses
|
|
@@ -1430,6 +1477,12 @@ class TestDriverSDK {
|
|
|
1430
1477
|
this._lastPromiseSettled = true;
|
|
1431
1478
|
this._lastCommandName = null;
|
|
1432
1479
|
|
|
1480
|
+
// Auto-screenshots configuration
|
|
1481
|
+
// When enabled, automatically captures screenshots before/after each command
|
|
1482
|
+
// Screenshots are saved to .testdriver/screenshots/<test>/ with descriptive names
|
|
1483
|
+
this.autoScreenshots = options.autoScreenshots !== false;
|
|
1484
|
+
this._screenshotSequence = 0; // Counter for sequential screenshot naming
|
|
1485
|
+
|
|
1433
1486
|
// Set up command methods that lazy-await connection
|
|
1434
1487
|
this._setupCommandMethods();
|
|
1435
1488
|
}
|
|
@@ -2733,10 +2786,18 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2733
2786
|
|
|
2734
2787
|
this._ensureConnected();
|
|
2735
2788
|
|
|
2789
|
+
// Get caller info for auto-screenshot naming
|
|
2790
|
+
const callerInfo = this.autoScreenshots ? getCallerInfo() : null;
|
|
2791
|
+
|
|
2736
2792
|
// Track this promise for unawaited detection
|
|
2737
2793
|
this._lastCommandName = "find";
|
|
2738
2794
|
this._lastPromiseSettled = false;
|
|
2739
2795
|
|
|
2796
|
+
// Take "before" screenshot if enabled
|
|
2797
|
+
if (this.autoScreenshots) {
|
|
2798
|
+
await this._saveAutoScreenshot("find", "before", callerInfo, description);
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2740
2801
|
const element = new Element(
|
|
2741
2802
|
description,
|
|
2742
2803
|
this,
|
|
@@ -2744,6 +2805,12 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2744
2805
|
this.commands,
|
|
2745
2806
|
);
|
|
2746
2807
|
const result = await element.find(null, options);
|
|
2808
|
+
|
|
2809
|
+
// Take "after" screenshot if enabled
|
|
2810
|
+
if (this.autoScreenshots) {
|
|
2811
|
+
await this._saveAutoScreenshot("find", "after", callerInfo, description);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2747
2814
|
this._lastPromiseSettled = true;
|
|
2748
2815
|
return result;
|
|
2749
2816
|
})();
|
|
@@ -2792,10 +2859,18 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2792
2859
|
|
|
2793
2860
|
this._ensureConnected();
|
|
2794
2861
|
|
|
2862
|
+
// Get caller info for auto-screenshot naming
|
|
2863
|
+
const callerInfo = this.autoScreenshots ? getCallerInfo() : null;
|
|
2864
|
+
|
|
2795
2865
|
// Track this promise for unawaited detection
|
|
2796
2866
|
this._lastCommandName = "findAll";
|
|
2797
2867
|
this._lastPromiseSettled = false;
|
|
2798
2868
|
|
|
2869
|
+
// Take "before" screenshot if enabled
|
|
2870
|
+
if (this.autoScreenshots) {
|
|
2871
|
+
await this._saveAutoScreenshot("findAll", "before", callerInfo, description);
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2799
2874
|
// Capture absolute timestamp at the very start of the command
|
|
2800
2875
|
// Frontend will calculate relative time using: timestamp - replay.clientStartDate
|
|
2801
2876
|
const absoluteTimestamp = Date.now();
|
|
@@ -2951,6 +3026,11 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2951
3026
|
this.emitter.emit(events.log.debug, ` Time: ${duration}ms`);
|
|
2952
3027
|
}
|
|
2953
3028
|
|
|
3029
|
+
// Take "after" screenshot if enabled
|
|
3030
|
+
if (this.autoScreenshots) {
|
|
3031
|
+
await this._saveAutoScreenshot("findAll", "after", callerInfo, description);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2954
3034
|
this._lastPromiseSettled = true;
|
|
2955
3035
|
return elements;
|
|
2956
3036
|
} else {
|
|
@@ -2989,6 +3069,11 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
2989
3069
|
});
|
|
2990
3070
|
}
|
|
2991
3071
|
|
|
3072
|
+
// Take "after" screenshot if enabled (no elements found)
|
|
3073
|
+
if (this.autoScreenshots) {
|
|
3074
|
+
await this._saveAutoScreenshot("findAll", "after", callerInfo, description);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
2992
3077
|
// No elements found - return empty array
|
|
2993
3078
|
this._lastPromiseSettled = true;
|
|
2994
3079
|
return [];
|
|
@@ -3025,6 +3110,11 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
3025
3110
|
});
|
|
3026
3111
|
}
|
|
3027
3112
|
|
|
3113
|
+
// Take "error" screenshot if enabled
|
|
3114
|
+
if (this.autoScreenshots) {
|
|
3115
|
+
await this._saveAutoScreenshot("findAll", "error", callerInfo, description);
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3028
3118
|
this._lastPromiseSettled = true;
|
|
3029
3119
|
return [];
|
|
3030
3120
|
}
|
|
@@ -3072,6 +3162,7 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
3072
3162
|
/**
|
|
3073
3163
|
* Dynamically set up command methods based on available commands
|
|
3074
3164
|
* This creates camelCase methods that wrap the underlying command functions
|
|
3165
|
+
* When autoScreenshots is enabled, captures before/after screenshots for each command
|
|
3075
3166
|
* @private
|
|
3076
3167
|
*/
|
|
3077
3168
|
_setupCommandMethods() {
|
|
@@ -3096,6 +3187,53 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
3096
3187
|
exec: "exec",
|
|
3097
3188
|
};
|
|
3098
3189
|
|
|
3190
|
+
// Helper to extract a description from command args for screenshot naming
|
|
3191
|
+
const getDescriptionFromArgs = (methodName, args) => {
|
|
3192
|
+
if (!args || args.length === 0) return "";
|
|
3193
|
+
const firstArg = args[0];
|
|
3194
|
+
|
|
3195
|
+
switch (methodName) {
|
|
3196
|
+
case "type":
|
|
3197
|
+
// For type, use the text being typed (truncated)
|
|
3198
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 20) : "";
|
|
3199
|
+
case "pressKeys":
|
|
3200
|
+
// For pressKeys, show the keys
|
|
3201
|
+
return Array.isArray(firstArg) ? firstArg.join("+") : String(firstArg);
|
|
3202
|
+
case "click":
|
|
3203
|
+
case "hover":
|
|
3204
|
+
// For click/hover, try to get coordinates or prompt
|
|
3205
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3206
|
+
return firstArg.prompt || `${firstArg.x},${firstArg.y}`;
|
|
3207
|
+
}
|
|
3208
|
+
return typeof firstArg === "number" ? `${firstArg},${args[1]}` : "";
|
|
3209
|
+
case "scroll":
|
|
3210
|
+
// For scroll, show direction
|
|
3211
|
+
return typeof firstArg === "string" ? firstArg : "down";
|
|
3212
|
+
case "waitForText":
|
|
3213
|
+
case "scrollUntilText":
|
|
3214
|
+
// For text-based commands, use the text
|
|
3215
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3216
|
+
return firstArg.text || "";
|
|
3217
|
+
}
|
|
3218
|
+
return typeof firstArg === "string" ? firstArg : "";
|
|
3219
|
+
case "focusApplication":
|
|
3220
|
+
// For focus, use the app name
|
|
3221
|
+
return typeof firstArg === "string" ? firstArg : "";
|
|
3222
|
+
case "assert":
|
|
3223
|
+
case "extract":
|
|
3224
|
+
// For assert/extract, use the assertion/description
|
|
3225
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 30) : "";
|
|
3226
|
+
case "exec":
|
|
3227
|
+
// For exec, show the language
|
|
3228
|
+
if (typeof firstArg === "object" && firstArg !== null) {
|
|
3229
|
+
return firstArg.language || "code";
|
|
3230
|
+
}
|
|
3231
|
+
return typeof firstArg === "string" ? firstArg : "code";
|
|
3232
|
+
default:
|
|
3233
|
+
return typeof firstArg === "string" ? firstArg.substring(0, 20) : "";
|
|
3234
|
+
}
|
|
3235
|
+
};
|
|
3236
|
+
|
|
3099
3237
|
// Create SDK methods that lazy-await connection then forward to this.commands
|
|
3100
3238
|
for (const [commandName, methodName] of Object.entries(commandMapping)) {
|
|
3101
3239
|
this[methodName] = async function (...args) {
|
|
@@ -3115,19 +3253,39 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
3115
3253
|
|
|
3116
3254
|
this._ensureConnected();
|
|
3117
3255
|
|
|
3118
|
-
// Capture the call site for better error reporting
|
|
3256
|
+
// Capture the call site for better error reporting AND for auto-screenshots
|
|
3119
3257
|
const callSite = {};
|
|
3120
3258
|
Error.captureStackTrace(callSite, this[methodName]);
|
|
3121
3259
|
|
|
3260
|
+
// Get caller info for auto-screenshot naming
|
|
3261
|
+
const callerInfo = this.autoScreenshots ? getCallerInfo() : null;
|
|
3262
|
+
const description = this.autoScreenshots ? getDescriptionFromArgs(methodName, args) : "";
|
|
3263
|
+
|
|
3122
3264
|
// Track this promise for unawaited detection
|
|
3123
3265
|
this._lastCommandName = methodName;
|
|
3124
3266
|
this._lastPromiseSettled = false;
|
|
3125
3267
|
|
|
3126
3268
|
try {
|
|
3269
|
+
// Take "before" screenshot if enabled
|
|
3270
|
+
if (this.autoScreenshots) {
|
|
3271
|
+
await this._saveAutoScreenshot(methodName, "before", callerInfo, description);
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3127
3274
|
const result = await this.commands[commandName](...args);
|
|
3275
|
+
|
|
3276
|
+
// Take "after" screenshot if enabled
|
|
3277
|
+
if (this.autoScreenshots) {
|
|
3278
|
+
await this._saveAutoScreenshot(methodName, "after", callerInfo, description);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3128
3281
|
this._lastPromiseSettled = true;
|
|
3129
3282
|
return result;
|
|
3130
3283
|
} catch (error) {
|
|
3284
|
+
// Take "error" screenshot if enabled (instead of "after")
|
|
3285
|
+
if (this.autoScreenshots) {
|
|
3286
|
+
await this._saveAutoScreenshot(methodName, "error", callerInfo, description);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3131
3289
|
this._lastPromiseSettled = true;
|
|
3132
3290
|
// Ensure we have a proper Error object with a message
|
|
3133
3291
|
let properError = error;
|
|
@@ -3212,6 +3370,80 @@ CAPTCHA_SOLVER_EOF`,
|
|
|
3212
3370
|
return filePath;
|
|
3213
3371
|
}
|
|
3214
3372
|
|
|
3373
|
+
/**
|
|
3374
|
+
* Save an automatic screenshot with descriptive naming
|
|
3375
|
+
* Used internally when autoScreenshots is enabled
|
|
3376
|
+
* @private
|
|
3377
|
+
* @param {string} actionName - Name of the action (click, type, hover, etc.)
|
|
3378
|
+
* @param {string} phase - 'before' or 'after'
|
|
3379
|
+
* @param {Object} callerInfo - Caller information from getCallerInfo()
|
|
3380
|
+
* @param {string} [description] - Optional description of the action target
|
|
3381
|
+
* @returns {Promise<string|null>} The file path where the screenshot was saved, or null if failed
|
|
3382
|
+
*/
|
|
3383
|
+
async _saveAutoScreenshot(actionName, phase, callerInfo, description = "") {
|
|
3384
|
+
if (!this.autoScreenshots || !this.connected) {
|
|
3385
|
+
return null;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
try {
|
|
3389
|
+
// Increment sequence for unique ordering
|
|
3390
|
+
this._screenshotSequence++;
|
|
3391
|
+
const seq = String(this._screenshotSequence).padStart(3, "0");
|
|
3392
|
+
|
|
3393
|
+
// Extract line number info
|
|
3394
|
+
const lineInfo = callerInfo.line ? `L${callerInfo.line}` : "L???";
|
|
3395
|
+
|
|
3396
|
+
// Sanitize description for filename (remove special chars, limit length)
|
|
3397
|
+
const sanitizedDesc = description
|
|
3398
|
+
.replace(/[^a-zA-Z0-9\s-]/g, "")
|
|
3399
|
+
.replace(/\s+/g, "-")
|
|
3400
|
+
.substring(0, 30)
|
|
3401
|
+
.toLowerCase();
|
|
3402
|
+
|
|
3403
|
+
// Build filename: 001-click-before-L42-submit-button.png
|
|
3404
|
+
const descPart = sanitizedDesc ? `-${sanitizedDesc}` : "";
|
|
3405
|
+
const filename = `${seq}-${actionName}-${phase}-${lineInfo}${descPart}.png`;
|
|
3406
|
+
|
|
3407
|
+
const base64Data = await this.system.captureScreenBase64(1, false, false);
|
|
3408
|
+
|
|
3409
|
+
// Save to .testdriver/screenshots/<test-file-name> directory
|
|
3410
|
+
let screenshotsDir = path.join(process.cwd(), ".testdriver", "screenshots");
|
|
3411
|
+
if (this.testFile) {
|
|
3412
|
+
const testFileName = path.basename(
|
|
3413
|
+
this.testFile,
|
|
3414
|
+
path.extname(this.testFile),
|
|
3415
|
+
);
|
|
3416
|
+
screenshotsDir = path.join(screenshotsDir, testFileName);
|
|
3417
|
+
}
|
|
3418
|
+
if (!fs.existsSync(screenshotsDir)) {
|
|
3419
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
const filePath = path.join(screenshotsDir, filename);
|
|
3423
|
+
|
|
3424
|
+
// Remove data:image/png;base64, prefix if present
|
|
3425
|
+
const cleanBase64 = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
|
3426
|
+
const buffer = Buffer.from(cleanBase64, "base64");
|
|
3427
|
+
|
|
3428
|
+
fs.writeFileSync(filePath, buffer);
|
|
3429
|
+
|
|
3430
|
+
// Debug log in verbose mode
|
|
3431
|
+
const debugMode = process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
3432
|
+
if (debugMode) {
|
|
3433
|
+
this.emitter.emit("log:debug", `📸 Auto-screenshot: ${filename}`);
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
return filePath;
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
// Don't fail the command if screenshot fails
|
|
3439
|
+
const debugMode = process.env.VERBOSE || process.env.DEBUG || process.env.TD_DEBUG;
|
|
3440
|
+
if (debugMode) {
|
|
3441
|
+
this.emitter.emit("log:debug", `Failed to save auto-screenshot: ${error.message}`);
|
|
3442
|
+
}
|
|
3443
|
+
return null;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3215
3447
|
/**
|
|
3216
3448
|
* Ensure the SDK is connected before running commands
|
|
3217
3449
|
* @private
|