@fasttest-ai/qa-agent 0.3.0 → 0.4.1
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/LICENSE +190 -0
- package/README.md +188 -0
- package/bin/install.js +3 -0
- package/bin/qa-agent.js +7 -2
- package/dist/actions.d.ts +0 -1
- package/dist/actions.js +0 -1
- package/dist/actions.js.map +1 -1
- package/dist/browser.d.ts +3 -1
- package/dist/browser.js +33 -10
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +16 -3
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +59 -30
- package/dist/cloud.js +42 -24
- package/dist/cloud.js.map +1 -1
- package/dist/healer.d.ts +6 -1
- package/dist/healer.js +86 -16
- package/dist/healer.js.map +1 -1
- package/dist/index.js +483 -48
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +11 -0
- package/dist/install.js +500 -0
- package/dist/install.js.map +1 -0
- package/dist/runner.d.ts +21 -0
- package/dist/runner.js +181 -18
- package/dist/runner.js.map +1 -1
- package/package.json +26 -4
package/dist/index.js
CHANGED
|
@@ -14,10 +14,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
14
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { BrowserManager, sanitizePath } from "./browser.js";
|
|
21
|
+
import { CloudClient, QuotaExceededError } from "./cloud.js";
|
|
21
22
|
import * as actions from "./actions.js";
|
|
22
23
|
import { executeRun } from "./runner.js";
|
|
23
24
|
import { healSelector } from "./healer.js";
|
|
@@ -85,7 +86,9 @@ After testing, provide a clear summary:
|
|
|
85
86
|
selectors
|
|
86
87
|
|
|
87
88
|
If cloud is connected (setup completed), ask if the user wants to save \
|
|
88
|
-
passing tests as a reusable suite via \`save_suite\` for CI/CD replay.
|
|
89
|
+
passing tests as a reusable suite via \`save_suite\` for CI/CD replay. \
|
|
90
|
+
If tests span multiple features (e.g. auth, navigation, forms), organize \
|
|
91
|
+
them into separate suites by feature rather than one big suite.
|
|
89
92
|
|
|
90
93
|
## Saving tests for CI/CD
|
|
91
94
|
|
|
@@ -100,7 +103,18 @@ The test runner resolves these from environment variables at execution time. \
|
|
|
100
103
|
In CI, they are set as GitHub repository secrets.
|
|
101
104
|
|
|
102
105
|
Do NOT use placeholders for non-sensitive data like URLs, button labels, or \
|
|
103
|
-
page content — only for credentials, tokens, and secrets
|
|
106
|
+
page content — only for credentials, tokens, and secrets.
|
|
107
|
+
|
|
108
|
+
## Step intent for self-healing
|
|
109
|
+
|
|
110
|
+
For each step, include an \`intent\` field describing what the step is trying \
|
|
111
|
+
to accomplish in plain English. This is critical for self-healing: when a \
|
|
112
|
+
selector breaks, the runner uses the intent to find the right replacement \
|
|
113
|
+
element. Good intents describe the WHAT, not the HOW:
|
|
114
|
+
- Good: \`"Click the 'Add to Cart' button"\`
|
|
115
|
+
- Good: \`"Fill the email input in the login form"\`
|
|
116
|
+
- Bad: \`"Click #add-to-cart"\` (just restates the selector)
|
|
117
|
+
- Bad: \`"Click"\` (too vague)`;
|
|
104
118
|
const LOCAL_EXPLORE_PROMPT = `\
|
|
105
119
|
You are autonomously exploring a web application to discover testable flows. \
|
|
106
120
|
The page snapshot and screenshot above show your starting point.
|
|
@@ -201,6 +215,83 @@ You are the last resort. Use your reasoning to diagnose and fix this.
|
|
|
201
215
|
- Do NOT suggest fragile selectors (nth-child, auto-generated CSS classes).
|
|
202
216
|
- Do NOT suggest more than 3 candidates — if none of them work after \
|
|
203
217
|
verification, the element is likely gone.`;
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Vibe Shield prompts — the seatbelt for vibe coding
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
const VIBE_SHIELD_FIRST_RUN_PROMPT = `\
|
|
222
|
+
You are setting up **Vibe Shield** — an automatic safety net for this application.
|
|
223
|
+
Your job: explore the app, build a comprehensive test suite, save it, and run the baseline.
|
|
224
|
+
|
|
225
|
+
## Step 1: Explore (discover what to protect)
|
|
226
|
+
|
|
227
|
+
Use a breadth-first approach to survey the app:
|
|
228
|
+
1. Read the page snapshot above. Note every navigation link, button, and form.
|
|
229
|
+
2. Click through the main navigation to discover all top-level pages.
|
|
230
|
+
3. For each new page, use browser_snapshot to capture its structure.
|
|
231
|
+
4. Keep track of pages visited — do NOT revisit pages you've already seen.
|
|
232
|
+
5. Stop after visiting {max_pages} pages, or when all reachable pages are found.
|
|
233
|
+
|
|
234
|
+
Do NOT explore: external links, social media, docs, terms/privacy pages.
|
|
235
|
+
|
|
236
|
+
## Step 2: Build test cases (create the safety net)
|
|
237
|
+
|
|
238
|
+
For EACH testable flow you discovered, construct a test case with:
|
|
239
|
+
- A navigate step to the starting URL
|
|
240
|
+
- The exact interaction steps (click, fill, etc.) using the most stable selectors \
|
|
241
|
+
from your snapshots (data-testid > aria-label > role > text > CSS)
|
|
242
|
+
- An \`intent\` field on EVERY step describing what it does in plain English \
|
|
243
|
+
(e.g. "Click the 'Sign In' button", "Fill the email field with test credentials")
|
|
244
|
+
- At least one assertion per flow verifying the expected outcome
|
|
245
|
+
|
|
246
|
+
Cover these flow types (in priority order):
|
|
247
|
+
1. **Navigation flows**: Can the user reach all main pages?
|
|
248
|
+
2. **Form submissions**: Do forms submit successfully with valid data?
|
|
249
|
+
3. **CRUD operations**: Can users create, read, update, delete?
|
|
250
|
+
4. **Authentication**: Login/logout if applicable
|
|
251
|
+
5. **Error states**: What happens with empty/invalid form submissions?
|
|
252
|
+
|
|
253
|
+
## Step 3: Save (persist the safety net)
|
|
254
|
+
|
|
255
|
+
Group test cases by feature area and save MULTIPLE suites — one per feature. \
|
|
256
|
+
For example, if the app has auth, a dashboard, and settings, create:
|
|
257
|
+
- \`save_suite(suite_name: "{suite_name}: Auth", ...)\` for login/logout/signup tests
|
|
258
|
+
- \`save_suite(suite_name: "{suite_name}: Dashboard", ...)\` for dashboard tests
|
|
259
|
+
- \`save_suite(suite_name: "{suite_name}: Settings", ...)\` for settings tests
|
|
260
|
+
|
|
261
|
+
Use project: "{project}" for all suites. If the app is very simple (1-2 pages), \
|
|
262
|
+
a single suite is fine.
|
|
263
|
+
|
|
264
|
+
IMPORTANT: Replace any credentials with \`{{VAR_NAME}}\` placeholders:
|
|
265
|
+
- Passwords: \`{{TEST_USER_PASSWORD}}\`
|
|
266
|
+
- Emails: \`{{TEST_USER_EMAIL}}\`
|
|
267
|
+
- API keys: \`{{STRIPE_TEST_KEY}}\`
|
|
268
|
+
|
|
269
|
+
Include an \`intent\` field on every step for self-healing.
|
|
270
|
+
|
|
271
|
+
## Step 4: Run baseline (establish the starting point)
|
|
272
|
+
|
|
273
|
+
Call \`run\` for each suite to execute all tests.
|
|
274
|
+
This establishes the baseline. Future runs will show what changed.
|
|
275
|
+
|
|
276
|
+
Present the results clearly — this is the first Vibe Shield report for this app.`;
|
|
277
|
+
const VIBE_SHIELD_RERUN_PROMPT = `\
|
|
278
|
+
**Vibe Shield** suite "{suite_name}" already exists with {test_count} test case(s).
|
|
279
|
+
Running regression check to see what changed since the last run...
|
|
280
|
+
|
|
281
|
+
Call the \`run\` tool with suite_name="{suite_name}".
|
|
282
|
+
|
|
283
|
+
Also check for other Vibe Shield suites for this app using \`list_suites\` with \
|
|
284
|
+
search="{suite_name}". If there are multiple feature suites (e.g. "{suite_name}: Auth", \
|
|
285
|
+
"{suite_name}: Dashboard"), run all of them.
|
|
286
|
+
|
|
287
|
+
The results will include a regression diff showing:
|
|
288
|
+
- **Regressions**: Tests that were passing but now fail (something broke)
|
|
289
|
+
- **Fixes**: Tests that were failing but now pass (something was fixed)
|
|
290
|
+
- **New tests**: Tests added since the last run
|
|
291
|
+
- **Self-healed**: Selectors that changed but were automatically repaired
|
|
292
|
+
|
|
293
|
+
Present the Vibe Shield report clearly. If regressions are found, highlight them \
|
|
294
|
+
prominently — the developer needs to know what their last change broke.`;
|
|
204
295
|
const LOCAL_CHAOS_PROMPT = `\
|
|
205
296
|
You are running a "Break My App" adversarial testing session. Your goal is to \
|
|
206
297
|
systematically attack this page to find security issues, crashes, and missing validation. \
|
|
@@ -305,15 +396,38 @@ function parseArgs() {
|
|
|
305
396
|
// ---------------------------------------------------------------------------
|
|
306
397
|
const consoleLogs = [];
|
|
307
398
|
const MAX_LOGS = 500;
|
|
399
|
+
const recordedSteps = [];
|
|
400
|
+
let recording = false;
|
|
401
|
+
function recordStep(step) {
|
|
402
|
+
if (!recording)
|
|
403
|
+
return;
|
|
404
|
+
recordedSteps.push({ ...step, timestamp: Date.now() });
|
|
405
|
+
}
|
|
406
|
+
function startRecording() {
|
|
407
|
+
recordedSteps.length = 0;
|
|
408
|
+
recording = true;
|
|
409
|
+
}
|
|
410
|
+
function stopRecording() {
|
|
411
|
+
recording = false;
|
|
412
|
+
return [...recordedSteps];
|
|
413
|
+
}
|
|
308
414
|
// ---------------------------------------------------------------------------
|
|
309
415
|
// Boot — resolve auth from CLI > config file > null (local-only mode)
|
|
310
416
|
// ---------------------------------------------------------------------------
|
|
311
417
|
const cliArgs = parseArgs();
|
|
312
418
|
const globalCfg = loadGlobalConfig();
|
|
313
|
-
// Resolution: CLI --api-key wins, then config file, then undefined
|
|
314
|
-
|
|
419
|
+
// Resolution: CLI --api-key wins, then env var, then config file, then undefined
|
|
420
|
+
// Filter out unresolved ${...} placeholders (e.g. from .mcp.json when env var is unset)
|
|
421
|
+
function isRealKey(v) {
|
|
422
|
+
if (!v)
|
|
423
|
+
return undefined;
|
|
424
|
+
if (/^\$\{.+\}$/.test(v))
|
|
425
|
+
return undefined;
|
|
426
|
+
return v;
|
|
427
|
+
}
|
|
428
|
+
const resolvedApiKey = isRealKey(cliArgs.apiKey) || isRealKey(process.env.FASTTEST_API_KEY) || isRealKey(globalCfg.api_key) || undefined;
|
|
315
429
|
const resolvedBaseUrl = cliArgs.baseUrl || globalCfg.base_url || "https://api.fasttest.ai";
|
|
316
|
-
const orgSlug = resolvedApiKey ? (resolvedApiKey.split("_")[1] ?? "default") : "default";
|
|
430
|
+
const orgSlug = sanitizePath(resolvedApiKey ? (resolvedApiKey.split("_")[1] ?? "default") : "default");
|
|
317
431
|
const browserMgr = new BrowserManager({
|
|
318
432
|
browserType: cliArgs.browser,
|
|
319
433
|
headless: cliArgs.headless,
|
|
@@ -359,24 +473,45 @@ function saveConfig(cfg) {
|
|
|
359
473
|
async function resolveProjectId(projectName) {
|
|
360
474
|
// 1. Check .fasttest.json
|
|
361
475
|
const cached = loadConfig();
|
|
362
|
-
if (cached?.project_id)
|
|
476
|
+
if (cached?.project_id) {
|
|
477
|
+
// If a specific project name was requested, verify it matches the cache
|
|
478
|
+
if (projectName && cached.project_name && cached.project_name !== projectName && cloud) {
|
|
479
|
+
const resolved = await cloud.resolveProject(projectName);
|
|
480
|
+
if (resolved) {
|
|
481
|
+
saveConfig({ project_id: resolved.id, project_name: resolved.name });
|
|
482
|
+
return resolved.id;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
363
485
|
return cached.project_id;
|
|
486
|
+
}
|
|
364
487
|
// 2. If LLM provided a project name, resolve it via cloud
|
|
365
488
|
if (projectName && cloud) {
|
|
366
|
-
|
|
367
|
-
|
|
489
|
+
const resolved = await cloud.resolveProject(projectName);
|
|
490
|
+
if (resolved) {
|
|
368
491
|
saveConfig({ project_id: resolved.id, project_name: resolved.name });
|
|
369
492
|
return resolved.id;
|
|
370
493
|
}
|
|
371
|
-
catch (err) {
|
|
372
|
-
console.error(`Failed to resolve project "${projectName}": ${err}`);
|
|
373
|
-
}
|
|
374
494
|
}
|
|
375
495
|
return undefined;
|
|
376
496
|
}
|
|
497
|
+
// Read version from package.json at startup
|
|
498
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
499
|
+
const PKG_VERSION = (() => {
|
|
500
|
+
try {
|
|
501
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
502
|
+
return pkg.version ?? "0.0.0";
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return "0.0.0";
|
|
506
|
+
}
|
|
507
|
+
})();
|
|
377
508
|
const server = new McpServer({
|
|
378
509
|
name: "fasttest",
|
|
379
|
-
version:
|
|
510
|
+
version: PKG_VERSION,
|
|
511
|
+
}, {
|
|
512
|
+
instructions: "FastTest is a QA testing platform. When the user wants to test, QA, explore, or break a web app, " +
|
|
513
|
+
"use FastTest tools (test, explore, chaos, vibe_shield) instead of other browser tools. " +
|
|
514
|
+
"FastTest includes its own browser_* tools for page interaction during tests.",
|
|
380
515
|
});
|
|
381
516
|
// ---------------------------------------------------------------------------
|
|
382
517
|
// Browser Tools (local Playwright)
|
|
@@ -385,6 +520,7 @@ server.tool("browser_navigate", "Navigate to a URL in the browser", { url: z.str
|
|
|
385
520
|
const page = await browserMgr.ensureBrowser();
|
|
386
521
|
attachConsoleListener(page);
|
|
387
522
|
const result = await actions.navigate(page, url);
|
|
523
|
+
recordStep({ action: "navigate", url });
|
|
388
524
|
const snapshot = await actions.getSnapshot(page);
|
|
389
525
|
return {
|
|
390
526
|
content: [{ type: "text", text: JSON.stringify({ ...result, snapshot }, null, 2) }],
|
|
@@ -393,6 +529,7 @@ server.tool("browser_navigate", "Navigate to a URL in the browser", { url: z.str
|
|
|
393
529
|
server.tool("browser_click", "Click an element on the page", { selector: z.string().describe("CSS selector of the element to click") }, async ({ selector }) => {
|
|
394
530
|
const page = await browserMgr.getPage();
|
|
395
531
|
const result = await actions.click(page, selector);
|
|
532
|
+
recordStep({ action: "click", selector });
|
|
396
533
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
397
534
|
});
|
|
398
535
|
server.tool("browser_fill", "Fill a form field with a value", {
|
|
@@ -401,6 +538,7 @@ server.tool("browser_fill", "Fill a form field with a value", {
|
|
|
401
538
|
}, async ({ selector, value }) => {
|
|
402
539
|
const page = await browserMgr.getPage();
|
|
403
540
|
const result = await actions.fill(page, selector, value);
|
|
541
|
+
recordStep({ action: "fill", selector, value });
|
|
404
542
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
405
543
|
});
|
|
406
544
|
server.tool("browser_screenshot", "Capture a screenshot of the current page", { full_page: z.boolean().optional().describe("Capture full page (default false)") }, async ({ full_page }) => {
|
|
@@ -460,16 +598,19 @@ server.tool("browser_restore_session", "Restore a previously saved browser sessi
|
|
|
460
598
|
server.tool("browser_go_back", "Navigate back in the browser history", {}, async () => {
|
|
461
599
|
const page = await browserMgr.getPage();
|
|
462
600
|
const result = await actions.goBack(page);
|
|
601
|
+
recordStep({ action: "go_back" });
|
|
463
602
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
464
603
|
});
|
|
465
604
|
server.tool("browser_go_forward", "Navigate forward in the browser history", {}, async () => {
|
|
466
605
|
const page = await browserMgr.getPage();
|
|
467
606
|
const result = await actions.goForward(page);
|
|
607
|
+
recordStep({ action: "go_forward" });
|
|
468
608
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
469
609
|
});
|
|
470
610
|
server.tool("browser_press_key", "Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)", { key: z.string().describe("Key to press (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Control+a')") }, async ({ key }) => {
|
|
471
611
|
const page = await browserMgr.getPage();
|
|
472
612
|
const result = await actions.pressKey(page, key);
|
|
613
|
+
recordStep({ action: "press_key", key });
|
|
473
614
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
474
615
|
});
|
|
475
616
|
server.tool("browser_file_upload", "Upload file(s) to a file input element", {
|
|
@@ -478,6 +619,7 @@ server.tool("browser_file_upload", "Upload file(s) to a file input element", {
|
|
|
478
619
|
}, async ({ selector, paths }) => {
|
|
479
620
|
const page = await browserMgr.getPage();
|
|
480
621
|
const result = await actions.uploadFile(page, selector, paths);
|
|
622
|
+
recordStep({ action: "upload_file", selector, value: paths.join(",") });
|
|
481
623
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
482
624
|
});
|
|
483
625
|
server.tool("browser_handle_dialog", "Accept or dismiss a JavaScript dialog (alert, confirm, prompt)", {
|
|
@@ -563,6 +705,7 @@ server.tool("browser_fill_form", "Fill multiple form fields at once (batch opera
|
|
|
563
705
|
}, async ({ fields }) => {
|
|
564
706
|
const page = await browserMgr.getPage();
|
|
565
707
|
const result = await actions.fillForm(page, fields);
|
|
708
|
+
recordStep({ action: "fill_form", fields });
|
|
566
709
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
567
710
|
});
|
|
568
711
|
server.tool("browser_network_requests", "List captured network requests from the current session. Shows API calls, failed requests, and document loads (static assets are filtered out).", {
|
|
@@ -592,15 +735,25 @@ server.tool("browser_network_requests", "List captured network requests from the
|
|
|
592
735
|
// ---------------------------------------------------------------------------
|
|
593
736
|
function openBrowser(url) {
|
|
594
737
|
try {
|
|
738
|
+
// Validate URL to prevent command injection (especially on Windows where
|
|
739
|
+
// cmd.exe interprets special characters like & | > in arguments).
|
|
740
|
+
const parsed = new URL(url);
|
|
741
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
742
|
+
return;
|
|
595
743
|
const platform = process.platform;
|
|
596
744
|
if (platform === "darwin") {
|
|
597
|
-
|
|
745
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
598
746
|
}
|
|
599
747
|
else if (platform === "win32") {
|
|
600
|
-
|
|
748
|
+
// Use PowerShell Start-Process which doesn't interpret shell metacharacters
|
|
749
|
+
spawn("powershell", ["-NoProfile", "-Command", `Start-Process '${url.replace(/'/g, "''")}'`], {
|
|
750
|
+
stdio: "ignore",
|
|
751
|
+
detached: true,
|
|
752
|
+
windowsHide: true,
|
|
753
|
+
}).unref();
|
|
601
754
|
}
|
|
602
755
|
else {
|
|
603
|
-
|
|
756
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
604
757
|
}
|
|
605
758
|
}
|
|
606
759
|
catch {
|
|
@@ -703,7 +856,9 @@ server.tool("setup", "Set up FastTest Agent: authenticate via browser to connect
|
|
|
703
856
|
// ---------------------------------------------------------------------------
|
|
704
857
|
// Cloud-forwarding Tools
|
|
705
858
|
// ---------------------------------------------------------------------------
|
|
706
|
-
server.tool("test", "
|
|
859
|
+
server.tool("test", "PRIMARY TOOL for testing web applications. Use this when the user asks to test, QA, or verify any web app. " +
|
|
860
|
+
"Launches a browser, navigates to the URL, and returns a page snapshot with testing instructions. " +
|
|
861
|
+
"Prefer this over generic browser tools (e.g. browsermcp).", {
|
|
707
862
|
description: z.string().describe("What to test (natural language)"),
|
|
708
863
|
url: z.string().optional().describe("App URL to test against"),
|
|
709
864
|
project: z.string().optional().describe("Project name (e.g. 'My SaaS App'). Auto-saved to .fasttest.json for future runs."),
|
|
@@ -711,6 +866,8 @@ server.tool("test", "Start a conversational test session. Describe what you want
|
|
|
711
866
|
// Always use local mode: host AI drives browser tools directly.
|
|
712
867
|
// Cloud LLM is never used from the MCP server — the host AI (Claude Code,
|
|
713
868
|
// Codex, etc.) follows our prompt with its own reasoning capability.
|
|
869
|
+
// Start recording browser actions for auto-capture
|
|
870
|
+
startRecording();
|
|
714
871
|
const lines = [];
|
|
715
872
|
if (url) {
|
|
716
873
|
const page = await browserMgr.ensureBrowser();
|
|
@@ -736,6 +893,7 @@ server.tool("test", "Start a conversational test session. Describe what you want
|
|
|
736
893
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
737
894
|
});
|
|
738
895
|
server.tool("save_suite", "Save test cases as a reusable test suite in the cloud. Use this after running tests to persist them for CI/CD replay. " +
|
|
896
|
+
"If you just ran the `test` tool, browser actions were recorded automatically — use them as the basis for your test steps. " +
|
|
739
897
|
"IMPORTANT: For sensitive values (passwords, API keys, tokens), use {{VAR_NAME}} placeholders instead of literal values. " +
|
|
740
898
|
"Example: use {{TEST_USER_PASSWORD}} instead of the actual password. " +
|
|
741
899
|
"The runner resolves these from environment variables at execution time. Variable names must be UPPER_SNAKE_CASE.", {
|
|
@@ -746,12 +904,31 @@ server.tool("save_suite", "Save test cases as a reusable test suite in the cloud
|
|
|
746
904
|
name: z.string().describe("Test case name"),
|
|
747
905
|
description: z.string().optional().describe("What this test verifies"),
|
|
748
906
|
priority: z.enum(["high", "medium", "low"]).optional().describe("Test priority"),
|
|
749
|
-
steps: z.array(z.record(z.string(), z.unknown())).describe("Test steps: [{action, selector?, value?, url?, description?}]. " +
|
|
907
|
+
steps: z.array(z.record(z.string(), z.unknown())).describe("Test steps: [{action, selector?, value?, url?, description?, intent?}]. " +
|
|
908
|
+
"Include 'intent' on every step — a plain-English description of WHAT the step does (e.g. \"Click the 'Submit' button\"). " +
|
|
750
909
|
"Use {{VAR_NAME}} placeholders for sensitive values (e.g. value: '{{TEST_PASSWORD}}')"),
|
|
751
910
|
assertions: z.array(z.record(z.string(), z.unknown())).describe("Assertions: [{type, selector?, text?, url?, count?}]"),
|
|
752
911
|
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
753
912
|
})).describe("Array of test cases to save"),
|
|
754
913
|
}, async ({ suite_name, description, project, test_cases }) => {
|
|
914
|
+
// Stop recording and capture any auto-recorded steps
|
|
915
|
+
const captured = stopRecording();
|
|
916
|
+
if (!test_cases || test_cases.length === 0) {
|
|
917
|
+
if (captured.length > 0) {
|
|
918
|
+
// Return recorded steps so the host AI can build test cases from them
|
|
919
|
+
const stepsJson = JSON.stringify(captured.map(({ timestamp: _, ...s }) => s), null, 2);
|
|
920
|
+
return {
|
|
921
|
+
content: [{
|
|
922
|
+
type: "text",
|
|
923
|
+
text: `No test cases provided, but ${captured.length} browser actions were recorded during testing:\n\n` +
|
|
924
|
+
"```json\n" + stepsJson + "\n```\n\n" +
|
|
925
|
+
"Use these as the basis for your test cases and call `save_suite` again with the test_cases array populated. " +
|
|
926
|
+
"Add an `intent` field to each step and replace sensitive values with `{{VAR_NAME}}` placeholders.",
|
|
927
|
+
}],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return { content: [{ type: "text", text: "Cannot save an empty suite. Provide at least one test case." }] };
|
|
931
|
+
}
|
|
755
932
|
const c = requireCloud();
|
|
756
933
|
// Resolve project
|
|
757
934
|
const projectId = await resolveProjectId(project);
|
|
@@ -811,6 +988,24 @@ server.tool("save_suite", "Save test cases as a reusable test suite in the cloud
|
|
|
811
988
|
lines.push(` - ${v}`);
|
|
812
989
|
}
|
|
813
990
|
}
|
|
991
|
+
// Auto-detect shared steps across test cases in this project
|
|
992
|
+
try {
|
|
993
|
+
const detection = await c.detectSharedSteps(finalProjectId, true);
|
|
994
|
+
if (detection.created && detection.created.length > 0) {
|
|
995
|
+
lines.push("");
|
|
996
|
+
lines.push("Shared steps auto-extracted:");
|
|
997
|
+
for (const ss of detection.created) {
|
|
998
|
+
lines.push(` - ${ss.name} (${ss.step_count} steps, used in ${ss.used_in} test cases)`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
else if (detection.suggestions && detection.suggestions.length > 0) {
|
|
1002
|
+
lines.push("");
|
|
1003
|
+
lines.push(`Detected ${detection.suggestions.length} repeated step sequence(s) across test cases.`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
// Non-fatal — detection failure shouldn't block save
|
|
1008
|
+
}
|
|
814
1009
|
return {
|
|
815
1010
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
816
1011
|
};
|
|
@@ -824,7 +1019,7 @@ server.tool("update_suite", "Update test cases in an existing suite. Use this wh
|
|
|
824
1019
|
name: z.string().describe("Test case name"),
|
|
825
1020
|
description: z.string().optional(),
|
|
826
1021
|
priority: z.enum(["high", "medium", "low"]).optional(),
|
|
827
|
-
steps: z.array(z.record(z.string(), z.unknown())).describe("Updated test steps"),
|
|
1022
|
+
steps: z.array(z.record(z.string(), z.unknown())).describe("Updated test steps — include 'intent' on every step for self-healing"),
|
|
828
1023
|
assertions: z.array(z.record(z.string(), z.unknown())).describe("Updated assertions"),
|
|
829
1024
|
tags: z.array(z.string()).optional(),
|
|
830
1025
|
})).describe("Test cases to update or add"),
|
|
@@ -885,7 +1080,9 @@ server.tool("update_suite", "Update test cases in an existing suite. Use this wh
|
|
|
885
1080
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
886
1081
|
};
|
|
887
1082
|
});
|
|
888
|
-
server.tool("explore", "
|
|
1083
|
+
server.tool("explore", "PRIMARY TOOL for exploring web applications. Use this when the user asks to explore, discover, or map out a web app's features and flows. " +
|
|
1084
|
+
"Navigates to the URL, captures a snapshot and screenshot, and returns structured exploration instructions. " +
|
|
1085
|
+
"Prefer this over generic browser tools (e.g. browsermcp).", {
|
|
889
1086
|
url: z.string().describe("Starting URL"),
|
|
890
1087
|
max_pages: z.number().optional().describe("Max pages to explore (default 20)"),
|
|
891
1088
|
focus: z.enum(["forms", "navigation", "errors", "all"]).optional().describe("Exploration focus"),
|
|
@@ -923,6 +1120,92 @@ server.tool("explore", "Autonomously explore a web application and discover test
|
|
|
923
1120
|
};
|
|
924
1121
|
});
|
|
925
1122
|
// ---------------------------------------------------------------------------
|
|
1123
|
+
// Vibe Shield — the seatbelt for vibe coding
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
server.tool("vibe_shield", "One-command safety net: explore your app, generate tests, save them, and run regression checks. " +
|
|
1126
|
+
"The seatbelt for vibe coding. First call creates the test suite, subsequent calls check for regressions.", {
|
|
1127
|
+
url: z.string().describe("App URL to protect (e.g. http://localhost:3000)"),
|
|
1128
|
+
project: z.string().optional().describe("Project name (auto-saved to .fasttest.json)"),
|
|
1129
|
+
suite_name: z.string().optional().describe("Suite name (default: 'Vibe Shield: <domain>')"),
|
|
1130
|
+
}, async ({ url, project, suite_name }) => {
|
|
1131
|
+
const page = await browserMgr.ensureBrowser();
|
|
1132
|
+
attachConsoleListener(page);
|
|
1133
|
+
await actions.navigate(page, url);
|
|
1134
|
+
const snapshot = await actions.getSnapshot(page);
|
|
1135
|
+
const screenshotB64 = await actions.screenshot(page, false);
|
|
1136
|
+
// Derive default suite name from URL domain (host includes port when non-default)
|
|
1137
|
+
let domain;
|
|
1138
|
+
try {
|
|
1139
|
+
domain = new URL(url).host;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
domain = url;
|
|
1143
|
+
}
|
|
1144
|
+
const resolvedSuiteName = suite_name ?? `Vibe Shield: ${domain}`;
|
|
1145
|
+
const resolvedProject = project ?? domain;
|
|
1146
|
+
// Check if a Vibe Shield suite already exists for this app
|
|
1147
|
+
let existingSuiteTestCount = 0;
|
|
1148
|
+
if (cloud) {
|
|
1149
|
+
try {
|
|
1150
|
+
const suites = await cloud.listSuites(resolvedSuiteName);
|
|
1151
|
+
const match = suites.find((s) => s.name === resolvedSuiteName);
|
|
1152
|
+
if (match) {
|
|
1153
|
+
existingSuiteTestCount = match.test_case_count ?? 0;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
// Cloud not available or no suites — treat as first run
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const lines = [
|
|
1161
|
+
"## Page Snapshot",
|
|
1162
|
+
"```json",
|
|
1163
|
+
JSON.stringify(snapshot, null, 2),
|
|
1164
|
+
"```",
|
|
1165
|
+
"",
|
|
1166
|
+
];
|
|
1167
|
+
if (!cloud) {
|
|
1168
|
+
// Local-only mode: explore and test with browser tools, but can't save or run suites
|
|
1169
|
+
lines.push("## Vibe Shield: Local Mode");
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
lines.push("You are running in **local-only mode** (no cloud connection). " +
|
|
1172
|
+
"Vibe Shield will explore the app and test it using browser tools directly, " +
|
|
1173
|
+
"but test suites cannot be saved or re-run for regression tracking.\n\n" +
|
|
1174
|
+
"To enable persistent test suites and regression tracking, run the `setup` tool first.\n\n" +
|
|
1175
|
+
"## Explore and Test\n\n" +
|
|
1176
|
+
"Use a breadth-first approach to survey the app:\n" +
|
|
1177
|
+
"1. Read the page snapshot above. Note every navigation link, button, and form.\n" +
|
|
1178
|
+
"2. Click through the main navigation to discover all top-level pages.\n" +
|
|
1179
|
+
"3. For each new page, use browser_snapshot to capture its structure.\n" +
|
|
1180
|
+
"4. For each testable flow, manually execute it using browser tools (click, fill, assert).\n" +
|
|
1181
|
+
"5. Report which flows work and which are broken.\n\n" +
|
|
1182
|
+
"This is a one-time check — results are not persisted.");
|
|
1183
|
+
}
|
|
1184
|
+
else if (existingSuiteTestCount > 0) {
|
|
1185
|
+
// Re-run mode: suite exists, run regression check
|
|
1186
|
+
const prompt = VIBE_SHIELD_RERUN_PROMPT
|
|
1187
|
+
.replace(/\{suite_name\}/g, resolvedSuiteName)
|
|
1188
|
+
.replace(/\{test_count\}/g, String(existingSuiteTestCount));
|
|
1189
|
+
lines.push("## Vibe Shield: Regression Check");
|
|
1190
|
+
lines.push(prompt);
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
// First-run mode: explore, build, save, run
|
|
1194
|
+
const prompt = VIBE_SHIELD_FIRST_RUN_PROMPT
|
|
1195
|
+
.replace(/\{suite_name\}/g, resolvedSuiteName)
|
|
1196
|
+
.replace(/\{project\}/g, resolvedProject)
|
|
1197
|
+
.replace(/\{max_pages\}/g, "20");
|
|
1198
|
+
lines.push("## Vibe Shield: Setup");
|
|
1199
|
+
lines.push(prompt);
|
|
1200
|
+
}
|
|
1201
|
+
return {
|
|
1202
|
+
content: [
|
|
1203
|
+
{ type: "text", text: lines.join("\n") },
|
|
1204
|
+
{ type: "image", data: screenshotB64, mimeType: "image/jpeg" },
|
|
1205
|
+
],
|
|
1206
|
+
};
|
|
1207
|
+
});
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
926
1209
|
// Chaos Tools (Break My App)
|
|
927
1210
|
// ---------------------------------------------------------------------------
|
|
928
1211
|
server.tool("chaos", "Break My App mode: systematically try adversarial inputs to find security and stability bugs", {
|
|
@@ -930,7 +1213,7 @@ server.tool("chaos", "Break My App mode: systematically try adversarial inputs t
|
|
|
930
1213
|
focus: z.enum(["forms", "navigation", "auth", "all"]).optional().describe("Attack focus area"),
|
|
931
1214
|
duration: z.enum(["quick", "thorough"]).optional().describe("Quick scan or thorough attack (default: thorough)"),
|
|
932
1215
|
project: z.string().optional().describe("Project name for saving report"),
|
|
933
|
-
}, async ({ url, focus, duration }) => {
|
|
1216
|
+
}, async ({ url, focus, duration, project }) => {
|
|
934
1217
|
const page = await browserMgr.ensureBrowser();
|
|
935
1218
|
attachConsoleListener(page);
|
|
936
1219
|
await actions.navigate(page, url);
|
|
@@ -946,10 +1229,15 @@ server.tool("chaos", "Break My App mode: systematically try adversarial inputs t
|
|
|
946
1229
|
`URL: ${url}`,
|
|
947
1230
|
`Focus: ${focus ?? "all"}`,
|
|
948
1231
|
`Duration: ${duration ?? "thorough"}`,
|
|
1232
|
+
`Project: ${project ?? "none"}`,
|
|
949
1233
|
"",
|
|
950
1234
|
"## Instructions",
|
|
951
1235
|
LOCAL_CHAOS_PROMPT,
|
|
952
1236
|
];
|
|
1237
|
+
if (project) {
|
|
1238
|
+
lines.push("");
|
|
1239
|
+
lines.push(`When saving findings, use \`save_chaos_report\` with project="${project}".`);
|
|
1240
|
+
}
|
|
953
1241
|
if (duration === "quick") {
|
|
954
1242
|
lines.push("");
|
|
955
1243
|
lines.push("**QUICK MODE**: Only run Phase 1 (Survey) and Phase 2 (Input Fuzzing) with one payload per category. Skip Phases 3-5.");
|
|
@@ -980,13 +1268,19 @@ server.tool("save_chaos_report", "Save findings from a Break My App chaos sessio
|
|
|
980
1268
|
const c = requireCloud();
|
|
981
1269
|
let projectId;
|
|
982
1270
|
if (project) {
|
|
983
|
-
|
|
984
|
-
|
|
1271
|
+
const p = await resolveProjectId(project);
|
|
1272
|
+
if (p) {
|
|
985
1273
|
projectId = p;
|
|
986
1274
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1275
|
+
else if (cloud) {
|
|
1276
|
+
// resolveProjectId returned undefined, try direct cloud resolution
|
|
1277
|
+
try {
|
|
1278
|
+
const resolved = await cloud.resolveProject(project);
|
|
1279
|
+
projectId = resolved.id;
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
// Project not found — continue without project association
|
|
1283
|
+
}
|
|
990
1284
|
}
|
|
991
1285
|
}
|
|
992
1286
|
const report = await c.saveChaosReport(projectId, { url, findings });
|
|
@@ -1011,9 +1305,10 @@ server.tool("save_chaos_report", "Save findings from a Break My App chaos sessio
|
|
|
1011
1305
|
server.tool("run", "Run a test suite. Executes all test cases in a real browser and returns results. Optionally posts results as a GitHub PR comment.", {
|
|
1012
1306
|
suite_id: z.string().optional().describe("Test suite ID to run (provide this OR suite_name)"),
|
|
1013
1307
|
suite_name: z.string().optional().describe("Test suite name to run (resolved to ID automatically). Example: 'checkout flow'"),
|
|
1308
|
+
environment_name: z.string().optional().describe("Environment to run against (e.g. 'staging', 'production'). Resolved to environment ID automatically. If omitted, uses the project's default base URL."),
|
|
1014
1309
|
test_case_ids: z.array(z.string()).optional().describe("Specific test case IDs to run (default: all in suite)"),
|
|
1015
1310
|
pr_url: z.string().optional().describe("GitHub PR URL — if provided, posts results as a PR comment (e.g. https://github.com/owner/repo/pull/123)"),
|
|
1016
|
-
}, async ({ suite_id, suite_name, test_case_ids, pr_url }) => {
|
|
1311
|
+
}, async ({ suite_id, suite_name, environment_name, test_case_ids, pr_url }) => {
|
|
1017
1312
|
// Resolve suite_id from suite_name if needed
|
|
1018
1313
|
let resolvedSuiteId = suite_id;
|
|
1019
1314
|
if (!resolvedSuiteId && suite_name) {
|
|
@@ -1032,50 +1327,172 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
|
|
|
1032
1327
|
content: [{ type: "text", text: "Either suite_id or suite_name is required. Use `list_suites` to find available suites." }],
|
|
1033
1328
|
};
|
|
1034
1329
|
}
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1330
|
+
const cloudClient = requireCloud();
|
|
1331
|
+
// Resolve environment name to ID if provided
|
|
1332
|
+
let environmentId;
|
|
1333
|
+
if (environment_name) {
|
|
1334
|
+
try {
|
|
1335
|
+
const env = await cloudClient.resolveEnvironment(resolvedSuiteId, environment_name);
|
|
1336
|
+
environmentId = env.id;
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
return {
|
|
1340
|
+
content: [{ type: "text", text: `Could not find environment "${environment_name}" for this suite's project. Check available environments in the dashboard.` }],
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
let summary;
|
|
1345
|
+
try {
|
|
1346
|
+
summary = await executeRun(browserMgr, cloudClient, {
|
|
1347
|
+
suiteId: resolvedSuiteId,
|
|
1348
|
+
environmentId,
|
|
1349
|
+
testCaseIds: test_case_ids,
|
|
1350
|
+
aiFallback: true,
|
|
1351
|
+
}, consoleLogs);
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
if (err instanceof QuotaExceededError) {
|
|
1355
|
+
const upgrade = err.plan === "free"
|
|
1356
|
+
? "Upgrade to Pro ($15/mo) for 1,000 runs/month"
|
|
1357
|
+
: err.plan === "pro"
|
|
1358
|
+
? "Upgrade to Team ($99/mo) for unlimited runs"
|
|
1359
|
+
: "Contact support for higher limits";
|
|
1360
|
+
return {
|
|
1361
|
+
content: [{
|
|
1362
|
+
type: "text",
|
|
1363
|
+
text: [
|
|
1364
|
+
`## Monthly run limit reached`,
|
|
1365
|
+
``,
|
|
1366
|
+
`You've used **${err.used}/${err.limit} runs** this month on the **${err.plan.toUpperCase()}** plan.`,
|
|
1367
|
+
``,
|
|
1368
|
+
`${upgrade} at https://fasttest.ai`,
|
|
1369
|
+
].join("\n"),
|
|
1370
|
+
}],
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
throw err;
|
|
1374
|
+
}
|
|
1039
1375
|
// Format a human-readable summary
|
|
1040
1376
|
const lines = [
|
|
1041
|
-
`#
|
|
1377
|
+
`# Vibe Shield Report ${summary.status === "passed" ? "✅ PASSED" : "❌ FAILED"}`,
|
|
1042
1378
|
`Execution ID: ${summary.execution_id}`,
|
|
1043
1379
|
`Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed} | Skipped: ${summary.skipped}`,
|
|
1044
1380
|
`Duration: ${(summary.duration_ms / 1000).toFixed(1)}s`,
|
|
1045
1381
|
"",
|
|
1046
1382
|
];
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1383
|
+
// Fetch regression diff from cloud
|
|
1384
|
+
let diff = null;
|
|
1385
|
+
try {
|
|
1386
|
+
diff = await cloudClient.getExecutionDiff(summary.execution_id);
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
// Non-fatal — diff may not be available
|
|
1390
|
+
}
|
|
1391
|
+
// Show regression diff if we have a previous run to compare against
|
|
1392
|
+
if (diff?.previous_execution_id) {
|
|
1393
|
+
if (diff.regressions.length > 0) {
|
|
1394
|
+
lines.push(`## ⚠️ Regressions (${diff.regressions.length} test(s) broke since last run)`);
|
|
1395
|
+
for (const r of diff.regressions) {
|
|
1396
|
+
lines.push(` ❌ ${r.name} — was PASSING, now FAILING`);
|
|
1397
|
+
if (r.error) {
|
|
1398
|
+
lines.push(` Error: ${r.error}`);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
lines.push("");
|
|
1402
|
+
}
|
|
1403
|
+
if (diff.fixes.length > 0) {
|
|
1404
|
+
lines.push(`## ✅ Fixed (${diff.fixes.length} test(s) started passing)`);
|
|
1405
|
+
for (const f of diff.fixes) {
|
|
1406
|
+
lines.push(` ✅ ${f.name} — was FAILING, now PASSING`);
|
|
1407
|
+
}
|
|
1408
|
+
lines.push("");
|
|
1409
|
+
}
|
|
1410
|
+
if (diff.new_tests.length > 0) {
|
|
1411
|
+
lines.push(`## 🆕 New Tests (${diff.new_tests.length})`);
|
|
1412
|
+
for (const t of diff.new_tests) {
|
|
1413
|
+
const icon = t.status === "passed" ? "✅" : t.status === "failed" ? "❌" : "⏭️";
|
|
1414
|
+
lines.push(` ${icon} ${t.name}`);
|
|
1415
|
+
}
|
|
1416
|
+
lines.push("");
|
|
1417
|
+
}
|
|
1418
|
+
if (diff.regressions.length === 0 && diff.fixes.length === 0 && diff.new_tests.length === 0) {
|
|
1419
|
+
lines.push("## No changes since last run");
|
|
1420
|
+
lines.push(` ${diff.unchanged.passed} still passing, ${diff.unchanged.failed} still failing`);
|
|
1421
|
+
lines.push("");
|
|
1422
|
+
}
|
|
1423
|
+
// Always show full results after the diff summary
|
|
1424
|
+
lines.push("## All Test Results");
|
|
1425
|
+
for (const r of summary.results) {
|
|
1426
|
+
const icon = r.status === "passed" ? "✅" : r.status === "failed" ? "❌" : "⏭️";
|
|
1427
|
+
lines.push(` ${icon} ${r.name} (${r.duration_ms}ms)`);
|
|
1428
|
+
if (r.error) {
|
|
1429
|
+
lines.push(` Error: ${r.error}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
lines.push("");
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
// First run — show individual results
|
|
1436
|
+
lines.push("## Test Results (baseline run)");
|
|
1437
|
+
for (const r of summary.results) {
|
|
1438
|
+
const icon = r.status === "passed" ? "✅" : r.status === "failed" ? "❌" : "⏭️";
|
|
1439
|
+
lines.push(` ${icon} ${r.name} (${r.duration_ms}ms)`);
|
|
1440
|
+
if (r.error) {
|
|
1441
|
+
lines.push(` Error: ${r.error}`);
|
|
1442
|
+
}
|
|
1052
1443
|
}
|
|
1444
|
+
lines.push("");
|
|
1053
1445
|
}
|
|
1054
1446
|
// Show healing summary if any heals occurred
|
|
1055
1447
|
if (summary.healed.length > 0) {
|
|
1056
|
-
lines.push("");
|
|
1057
1448
|
lines.push(`## Self-Healed: ${summary.healed.length} selector(s)`);
|
|
1058
1449
|
for (const h of summary.healed) {
|
|
1059
1450
|
lines.push(` 🔧 "${h.test_case}" step ${h.step_index + 1}`);
|
|
1060
1451
|
lines.push(` ${h.original_selector} → ${h.new_selector}`);
|
|
1061
1452
|
lines.push(` Strategy: ${h.strategy} (${Math.round(h.confidence * 100)}% confidence)`);
|
|
1062
1453
|
}
|
|
1454
|
+
lines.push("");
|
|
1063
1455
|
}
|
|
1064
1456
|
// Collect flaky retries (tests that passed after retries)
|
|
1065
1457
|
const flakyRetries = summary.results
|
|
1066
1458
|
.filter((r) => r.status === "passed" && (r.retry_attempts ?? 0) > 0)
|
|
1067
1459
|
.map((r) => ({ name: r.name, retry_attempts: r.retry_attempts }));
|
|
1068
1460
|
if (flakyRetries.length > 0) {
|
|
1069
|
-
lines.push("");
|
|
1070
1461
|
lines.push(`## Flaky Tests: ${flakyRetries.length} test(s) required retries`);
|
|
1071
1462
|
for (const f of flakyRetries) {
|
|
1072
1463
|
lines.push(` ♻️ ${f.name} — passed after ${f.retry_attempts} retry(ies)`);
|
|
1073
1464
|
}
|
|
1465
|
+
lines.push("");
|
|
1466
|
+
}
|
|
1467
|
+
// AI fallback: if a step failed and we have diagnostic context, give the host AI
|
|
1468
|
+
// instructions to intervene using browser tools
|
|
1469
|
+
if (summary.ai_fallback) {
|
|
1470
|
+
const fb = summary.ai_fallback;
|
|
1471
|
+
lines.push("## AI Fallback — Manual Intervention Needed");
|
|
1472
|
+
lines.push("");
|
|
1473
|
+
lines.push(`Test **"${fb.test_case_name}"** failed at step ${fb.step_index + 1}.`);
|
|
1474
|
+
if (fb.intent) {
|
|
1475
|
+
lines.push(`**Intent**: ${fb.intent}`);
|
|
1476
|
+
}
|
|
1477
|
+
lines.push(`**Error**: ${fb.error}`);
|
|
1478
|
+
lines.push(`**Page URL**: ${fb.page_url}`);
|
|
1479
|
+
lines.push("");
|
|
1480
|
+
lines.push("The browser is still open on the failing page. You can use browser tools to:");
|
|
1481
|
+
lines.push("1. Take a `browser_snapshot` to see the current page state");
|
|
1482
|
+
lines.push("2. Use `heal` with the broken selector to find a replacement");
|
|
1483
|
+
lines.push("3. Manually execute the failing step with the correct selector");
|
|
1484
|
+
lines.push("4. If the element is genuinely missing, this may be a real bug in the app");
|
|
1485
|
+
lines.push("");
|
|
1486
|
+
lines.push("### Page Snapshot at failure");
|
|
1487
|
+
lines.push("```json");
|
|
1488
|
+
lines.push(JSON.stringify(fb.snapshot, null, 2));
|
|
1489
|
+
lines.push("```");
|
|
1490
|
+
lines.push("");
|
|
1074
1491
|
}
|
|
1075
1492
|
// Post PR comment if pr_url was provided
|
|
1076
1493
|
if (pr_url) {
|
|
1077
1494
|
try {
|
|
1078
|
-
const prResult = await
|
|
1495
|
+
const prResult = await cloudClient.postPrComment({
|
|
1079
1496
|
pr_url,
|
|
1080
1497
|
execution_id: summary.execution_id,
|
|
1081
1498
|
status: summary.status,
|
|
@@ -1096,13 +1513,22 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
|
|
|
1096
1513
|
confidence: h.confidence,
|
|
1097
1514
|
})),
|
|
1098
1515
|
flaky_retries: flakyRetries.length > 0 ? flakyRetries : undefined,
|
|
1516
|
+
regressions: diff?.regressions.map((r) => ({
|
|
1517
|
+
name: r.name,
|
|
1518
|
+
previous_status: r.previous_status,
|
|
1519
|
+
current_status: r.current_status,
|
|
1520
|
+
error: r.error,
|
|
1521
|
+
})),
|
|
1522
|
+
fixes: diff?.fixes.map((f) => ({
|
|
1523
|
+
name: f.name,
|
|
1524
|
+
previous_status: f.previous_status,
|
|
1525
|
+
current_status: f.current_status,
|
|
1526
|
+
})),
|
|
1099
1527
|
});
|
|
1100
1528
|
const commentUrl = prResult.comment_url;
|
|
1101
|
-
lines.push("");
|
|
1102
1529
|
lines.push(`📝 PR comment posted: ${commentUrl ?? pr_url}`);
|
|
1103
1530
|
}
|
|
1104
1531
|
catch (err) {
|
|
1105
|
-
lines.push("");
|
|
1106
1532
|
lines.push(`⚠️ Failed to post PR comment: ${err}`);
|
|
1107
1533
|
}
|
|
1108
1534
|
}
|
|
@@ -1150,9 +1576,18 @@ server.tool("list_suites", "List test suites across all projects. Use this to fi
|
|
|
1150
1576
|
}
|
|
1151
1577
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1152
1578
|
});
|
|
1153
|
-
server.tool("health", "Check if the FastTest Agent backend is reachable", {
|
|
1154
|
-
|
|
1155
|
-
|
|
1579
|
+
server.tool("health", "Check if the FastTest Agent backend is reachable", {
|
|
1580
|
+
base_url: z.string().optional().describe("Override base URL to check (defaults to configured URL)"),
|
|
1581
|
+
}, async ({ base_url }) => {
|
|
1582
|
+
const url = base_url || resolvedBaseUrl || "https://api.fasttest.ai";
|
|
1583
|
+
try {
|
|
1584
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
|
1585
|
+
const data = await res.json();
|
|
1586
|
+
return { content: [{ type: "text", text: `Backend at ${url} is healthy: ${JSON.stringify(data)}` }] };
|
|
1587
|
+
}
|
|
1588
|
+
catch (err) {
|
|
1589
|
+
return { content: [{ type: "text", text: `Backend at ${url} is unreachable: ${String(err)}` }] };
|
|
1590
|
+
}
|
|
1156
1591
|
});
|
|
1157
1592
|
// ---------------------------------------------------------------------------
|
|
1158
1593
|
// Healing Tools (Phase 5)
|