@fasttest-ai/qa-agent 0.2.0 → 0.3.0
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/dist/actions.d.ts +3 -0
- package/dist/actions.js +38 -4
- package/dist/actions.js.map +1 -1
- package/dist/browser.d.ts +30 -0
- package/dist/browser.js +120 -6
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +3 -3
- package/dist/cli.js +5 -5
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +58 -13
- package/dist/cloud.js +100 -22
- package/dist/cloud.js.map +1 -1
- package/dist/config.d.ts +5 -4
- package/dist/config.js +20 -7
- package/dist/config.js.map +1 -1
- package/dist/healer.js +22 -14
- package/dist/healer.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +407 -66
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +245 -19
- package/dist/runner.js.map +1 -1
- package/dist/variables.d.ts +30 -0
- package/dist/variables.js +104 -0
- package/dist/variables.js.map +1 -0
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* FastTest Agent — MCP server (stdio transport).
|
|
4
4
|
*
|
|
5
5
|
* This is the ONLY MCP server in the architecture.
|
|
6
6
|
* Flow: Claude Code → MCP → Local Skill → HTTPS → Cloud API
|
|
7
7
|
*
|
|
8
8
|
* Exposes:
|
|
9
|
-
* -
|
|
9
|
+
* - 21 browser tools (Playwright, runs locally)
|
|
10
10
|
* - Local-first tools (test, explore, heal — host AI drives via structured prompts)
|
|
11
11
|
* - Cloud tools (save_suite, update_suite, run, status, cancel, etc. — require setup)
|
|
12
12
|
*/
|
|
@@ -15,6 +15,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
18
19
|
import { BrowserManager } from "./browser.js";
|
|
19
20
|
import { CloudClient } from "./cloud.js";
|
|
20
21
|
import * as actions from "./actions.js";
|
|
@@ -38,7 +39,10 @@ snapshot above shows the current state of the page. Follow this methodology:
|
|
|
38
39
|
- browser_click — click elements (use CSS selectors from the snapshot)
|
|
39
40
|
- browser_fill — type into inputs
|
|
40
41
|
- browser_press_key — keyboard actions (Enter, Tab, Escape)
|
|
41
|
-
-
|
|
42
|
+
- browser_fill_form — fill multiple form fields at once
|
|
43
|
+
- browser_drag — drag and drop elements
|
|
44
|
+
- browser_resize — resize viewport for responsive testing
|
|
45
|
+
- browser_tabs — manage browser tabs (list, create, switch, close)
|
|
42
46
|
- browser_wait — wait for elements or a timeout
|
|
43
47
|
3. **Verify**: After each significant action, use browser_assert to check \
|
|
44
48
|
the expected outcome. Available assertion types: element_visible, \
|
|
@@ -81,7 +85,22 @@ After testing, provide a clear summary:
|
|
|
81
85
|
selectors
|
|
82
86
|
|
|
83
87
|
If cloud is connected (setup completed), ask if the user wants to save \
|
|
84
|
-
passing tests as a reusable suite via \`save_suite\` for CI/CD replay
|
|
88
|
+
passing tests as a reusable suite via \`save_suite\` for CI/CD replay.
|
|
89
|
+
|
|
90
|
+
## Saving tests for CI/CD
|
|
91
|
+
|
|
92
|
+
When saving test suites via \`save_suite\`, replace sensitive values with \
|
|
93
|
+
\`{{VAR_NAME}}\` placeholders (UPPER_SNAKE_CASE):
|
|
94
|
+
- Passwords: \`{{TEST_USER_PASSWORD}}\`
|
|
95
|
+
- Emails/usernames: \`{{TEST_USER_EMAIL}}\`
|
|
96
|
+
- API keys: \`{{STRIPE_TEST_KEY}}\`
|
|
97
|
+
- Any value from .env files: use the matching env var name
|
|
98
|
+
|
|
99
|
+
The test runner resolves these from environment variables at execution time. \
|
|
100
|
+
In CI, they are set as GitHub repository secrets.
|
|
101
|
+
|
|
102
|
+
Do NOT use placeholders for non-sensitive data like URLs, button labels, or \
|
|
103
|
+
page content — only for credentials, tokens, and secrets.`;
|
|
85
104
|
const LOCAL_EXPLORE_PROMPT = `\
|
|
86
105
|
You are autonomously exploring a web application to discover testable flows. \
|
|
87
106
|
The page snapshot and screenshot above show your starting point.
|
|
@@ -182,6 +201,80 @@ You are the last resort. Use your reasoning to diagnose and fix this.
|
|
|
182
201
|
- Do NOT suggest fragile selectors (nth-child, auto-generated CSS classes).
|
|
183
202
|
- Do NOT suggest more than 3 candidates — if none of them work after \
|
|
184
203
|
verification, the element is likely gone.`;
|
|
204
|
+
const LOCAL_CHAOS_PROMPT = `\
|
|
205
|
+
You are running a "Break My App" adversarial testing session. Your goal is to \
|
|
206
|
+
systematically attack this page to find security issues, crashes, and missing validation. \
|
|
207
|
+
Use the browser tools (browser_fill, browser_click, browser_evaluate, browser_console_logs, \
|
|
208
|
+
browser_screenshot) to execute each attack.
|
|
209
|
+
|
|
210
|
+
WARNING: Run against staging/dev environments only. Adversarial payloads may trigger WAF rules.
|
|
211
|
+
|
|
212
|
+
## Phase 1: Survey
|
|
213
|
+
|
|
214
|
+
Read the page snapshot below carefully. Catalog every form, input field, button, \
|
|
215
|
+
link, and interactive element. Identify the most interesting targets — forms with \
|
|
216
|
+
auth, payment, CRUD operations, file uploads. Note the current URL and page title.
|
|
217
|
+
|
|
218
|
+
## Phase 2: Input Fuzzing
|
|
219
|
+
|
|
220
|
+
For each input field you found, try these payloads one at a time, submitting the \
|
|
221
|
+
form after each:
|
|
222
|
+
|
|
223
|
+
**XSS payloads:**
|
|
224
|
+
- \`<script>alert(1)</script>\`
|
|
225
|
+
- \`<img onerror=alert(1) src=x>\`
|
|
226
|
+
- \`javascript:alert(1)\`
|
|
227
|
+
|
|
228
|
+
**SQL injection:**
|
|
229
|
+
- \`' OR 1=1 --\`
|
|
230
|
+
- \`'; DROP TABLE users; --\`
|
|
231
|
+
|
|
232
|
+
**Boundary testing:**
|
|
233
|
+
- Empty submission (clear all fields, submit)
|
|
234
|
+
- Long string (paste 5000+ chars of "AAAA...")
|
|
235
|
+
- Unicode: RTL mark \\u200F, zero-width space \\u200B, emoji-only "🔥💀🎉"
|
|
236
|
+
- Negative numbers: \`-1\`, \`-999999\`
|
|
237
|
+
|
|
238
|
+
After each submission: call \`browser_console_logs\` and check for any \`[error]\` \
|
|
239
|
+
entries. Take a screenshot if you find something interesting.
|
|
240
|
+
|
|
241
|
+
## Phase 3: Interaction Fuzzing
|
|
242
|
+
|
|
243
|
+
- Double-click submit buttons rapidly (click twice with no delay)
|
|
244
|
+
- Rapid-fire click the same action button 5 times quickly
|
|
245
|
+
- Use \`browser_evaluate\` to click disabled buttons: \
|
|
246
|
+
\`document.querySelector('button[disabled]')?.removeAttribute('disabled'); \
|
|
247
|
+
document.querySelector('button[disabled]')?.click();\`
|
|
248
|
+
- Press browser back during form submission (navigate, then immediately go back)
|
|
249
|
+
|
|
250
|
+
## Phase 4: Auth & Access
|
|
251
|
+
|
|
252
|
+
- Use \`browser_evaluate\` to read localStorage and cookies: \
|
|
253
|
+
\`JSON.stringify({localStorage: {...localStorage}, cookies: document.cookie})\`
|
|
254
|
+
- If tokens are found, clear them: \
|
|
255
|
+
\`localStorage.clear(); document.cookie.split(';').forEach(c => \
|
|
256
|
+
document.cookie = c.trim().split('=')[0] + '=;expires=Thu, 01 Jan 1970');\`
|
|
257
|
+
- After clearing, try accessing the same page — does it still show protected content?
|
|
258
|
+
|
|
259
|
+
## Phase 5: Console Monitoring
|
|
260
|
+
|
|
261
|
+
After every action, check \`browser_console_logs\` for:
|
|
262
|
+
- Unhandled exceptions or promise rejections
|
|
263
|
+
- 404 or 500 network errors
|
|
264
|
+
- Exposed stack traces or sensitive data in error messages
|
|
265
|
+
|
|
266
|
+
## Output Format
|
|
267
|
+
|
|
268
|
+
After testing, summarize your findings as a structured list. For each finding:
|
|
269
|
+
- **severity**: critical (XSS executes, app crashes, data leak), high (unhandled \
|
|
270
|
+
exception, auth bypass), medium (missing validation, accepts garbage), low (cosmetic issue)
|
|
271
|
+
- **category**: xss, injection, crash, validation, error, auth
|
|
272
|
+
- **description**: What you found
|
|
273
|
+
- **reproduction_steps**: Numbered steps to reproduce
|
|
274
|
+
- **console_errors**: Any relevant console errors
|
|
275
|
+
|
|
276
|
+
If you want to save these findings, call the \`save_chaos_report\` tool with \
|
|
277
|
+
the URL and findings array.`;
|
|
185
278
|
// ---------------------------------------------------------------------------
|
|
186
279
|
// CLI arg parsing
|
|
187
280
|
// ---------------------------------------------------------------------------
|
|
@@ -219,7 +312,7 @@ const cliArgs = parseArgs();
|
|
|
219
312
|
const globalCfg = loadGlobalConfig();
|
|
220
313
|
// Resolution: CLI --api-key wins, then config file, then undefined
|
|
221
314
|
const resolvedApiKey = cliArgs.apiKey || globalCfg.api_key || undefined;
|
|
222
|
-
const resolvedBaseUrl = cliArgs.baseUrl || globalCfg.base_url || "https://api.
|
|
315
|
+
const resolvedBaseUrl = cliArgs.baseUrl || globalCfg.base_url || "https://api.fasttest.ai";
|
|
223
316
|
const orgSlug = resolvedApiKey ? (resolvedApiKey.split("_")[1] ?? "default") : "default";
|
|
224
317
|
const browserMgr = new BrowserManager({
|
|
225
318
|
browserType: cliArgs.browser,
|
|
@@ -235,7 +328,7 @@ let cloud = resolvedApiKey
|
|
|
235
328
|
// ---------------------------------------------------------------------------
|
|
236
329
|
function requireCloud() {
|
|
237
330
|
if (!cloud) {
|
|
238
|
-
throw new Error("Not connected to
|
|
331
|
+
throw new Error("Not connected to FastTest cloud. Run the `setup` tool first to create an organization.");
|
|
239
332
|
}
|
|
240
333
|
return cloud;
|
|
241
334
|
}
|
|
@@ -282,7 +375,7 @@ async function resolveProjectId(projectName) {
|
|
|
282
375
|
return undefined;
|
|
283
376
|
}
|
|
284
377
|
const server = new McpServer({
|
|
285
|
-
name: "
|
|
378
|
+
name: "fasttest",
|
|
286
379
|
version: "0.1.0",
|
|
287
380
|
});
|
|
288
381
|
// ---------------------------------------------------------------------------
|
|
@@ -404,71 +497,205 @@ server.tool("browser_evaluate", "Execute JavaScript in the page context and retu
|
|
|
404
497
|
const result = await actions.evaluate(page, expression);
|
|
405
498
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
406
499
|
});
|
|
500
|
+
server.tool("browser_drag", "Drag an element and drop it onto another element", {
|
|
501
|
+
source: z.string().describe("CSS selector of the element to drag"),
|
|
502
|
+
target: z.string().describe("CSS selector of the drop target"),
|
|
503
|
+
}, async ({ source, target }) => {
|
|
504
|
+
const page = await browserMgr.getPage();
|
|
505
|
+
const result = await actions.drag(page, source, target);
|
|
506
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
507
|
+
});
|
|
508
|
+
server.tool("browser_resize", "Resize the browser viewport (useful for responsive/mobile testing)", {
|
|
509
|
+
width: z.number().describe("Viewport width in pixels"),
|
|
510
|
+
height: z.number().describe("Viewport height in pixels"),
|
|
511
|
+
}, async ({ width, height }) => {
|
|
512
|
+
const page = await browserMgr.getPage();
|
|
513
|
+
const result = await actions.resize(page, width, height);
|
|
514
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
515
|
+
});
|
|
516
|
+
server.tool("browser_tabs", "Manage browser tabs: list, create, switch, or close tabs", {
|
|
517
|
+
action: z.enum(["list", "create", "switch", "close"]).describe("Tab action to perform"),
|
|
518
|
+
url: z.string().optional().describe("URL to open in new tab (only for 'create' action)"),
|
|
519
|
+
index: z.number().optional().describe("Tab index (for 'switch' and 'close' actions)"),
|
|
520
|
+
}, async ({ action, url, index }) => {
|
|
521
|
+
try {
|
|
522
|
+
switch (action) {
|
|
523
|
+
case "list": {
|
|
524
|
+
const pages = await browserMgr.listPagesAsync();
|
|
525
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, tabs: pages }) }] };
|
|
526
|
+
}
|
|
527
|
+
case "create": {
|
|
528
|
+
const page = await browserMgr.createPage(url);
|
|
529
|
+
return {
|
|
530
|
+
content: [{
|
|
531
|
+
type: "text",
|
|
532
|
+
text: JSON.stringify({ success: true, url: page.url(), title: await page.title() }),
|
|
533
|
+
}],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
case "switch": {
|
|
537
|
+
if (index === undefined) {
|
|
538
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "index is required for switch" }) }] };
|
|
539
|
+
}
|
|
540
|
+
const page = await browserMgr.switchToPage(index);
|
|
541
|
+
return {
|
|
542
|
+
content: [{
|
|
543
|
+
type: "text",
|
|
544
|
+
text: JSON.stringify({ success: true, url: page.url(), title: await page.title() }),
|
|
545
|
+
}],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
case "close": {
|
|
549
|
+
if (index === undefined) {
|
|
550
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "index is required for close" }) }] };
|
|
551
|
+
}
|
|
552
|
+
await browserMgr.closePage(index);
|
|
553
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true }) }] };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: String(err) }) }] };
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
server.tool("browser_fill_form", "Fill multiple form fields at once (batch operation, fewer round-trips than individual browser_fill calls)", {
|
|
562
|
+
fields: z.record(z.string(), z.string()).describe("Map of CSS selector → value to fill (e.g. {\"#email\": \"test@example.com\", \"#password\": \"secret\"})"),
|
|
563
|
+
}, async ({ fields }) => {
|
|
564
|
+
const page = await browserMgr.getPage();
|
|
565
|
+
const result = await actions.fillForm(page, fields);
|
|
566
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
567
|
+
});
|
|
568
|
+
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).", {
|
|
569
|
+
filter_status: z.number().optional().describe("Only show requests with this HTTP status code or higher (e.g. 400 for errors only)"),
|
|
570
|
+
}, async ({ filter_status }) => {
|
|
571
|
+
const entries = browserMgr.getNetworkSummary();
|
|
572
|
+
// Filter static assets — only show API/document/error requests
|
|
573
|
+
const filtered = entries.filter((e) => {
|
|
574
|
+
const mime = e.mimeType.toLowerCase();
|
|
575
|
+
const isRelevant = mime.includes("json") || mime.includes("text/html") ||
|
|
576
|
+
mime.includes("text/plain") || e.status >= 400;
|
|
577
|
+
if (!isRelevant)
|
|
578
|
+
return false;
|
|
579
|
+
if (filter_status !== undefined && e.status < filter_status)
|
|
580
|
+
return false;
|
|
581
|
+
return true;
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
content: [{
|
|
585
|
+
type: "text",
|
|
586
|
+
text: JSON.stringify({ total: filtered.length, requests: filtered.slice(-100) }, null, 2),
|
|
587
|
+
}],
|
|
588
|
+
};
|
|
589
|
+
});
|
|
407
590
|
// ---------------------------------------------------------------------------
|
|
408
|
-
// Setup Tool —
|
|
591
|
+
// Setup Tool — device auth flow (opens browser for secure authentication)
|
|
409
592
|
// ---------------------------------------------------------------------------
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
593
|
+
function openBrowser(url) {
|
|
594
|
+
try {
|
|
595
|
+
const platform = process.platform;
|
|
596
|
+
if (platform === "darwin") {
|
|
597
|
+
execFile("open", [url], { stdio: "ignore" });
|
|
598
|
+
}
|
|
599
|
+
else if (platform === "win32") {
|
|
600
|
+
execFile("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
execFile("xdg-open", [url], { stdio: "ignore" });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// Silently fail — the URL is shown to the user as fallback
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function sleep(ms) {
|
|
611
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
612
|
+
}
|
|
613
|
+
server.tool("setup", "Set up FastTest Agent: authenticate via browser to connect your editor to your FastTest account. Opens a browser window for secure login.", {
|
|
614
|
+
base_url: z.string().optional().describe("Cloud API base URL (default: https://api.fasttest.ai)"),
|
|
615
|
+
}, async ({ base_url }) => {
|
|
415
616
|
if (cloud) {
|
|
416
617
|
return {
|
|
417
618
|
content: [{
|
|
418
619
|
type: "text",
|
|
419
|
-
text: "Already connected to
|
|
620
|
+
text: "Already connected to FastTest cloud. To switch organizations, edit ~/.fasttest/config.json or pass --api-key on the CLI.",
|
|
420
621
|
}],
|
|
421
622
|
};
|
|
422
623
|
}
|
|
423
|
-
const slug = org_slug ?? org_name
|
|
424
|
-
.toLowerCase()
|
|
425
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
426
|
-
.replace(/^-|-$/g, "");
|
|
427
624
|
const targetBaseUrl = base_url ?? resolvedBaseUrl;
|
|
428
625
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
626
|
+
// 1. Request a device code from the backend
|
|
627
|
+
const deviceCode = await CloudClient.requestDeviceCode(targetBaseUrl);
|
|
628
|
+
// 2. Open the browser to the verification URL
|
|
629
|
+
openBrowser(deviceCode.verification_url);
|
|
630
|
+
const lines = [
|
|
631
|
+
"Opening your browser to authenticate...",
|
|
632
|
+
"",
|
|
633
|
+
"If it doesn't open automatically, visit:",
|
|
634
|
+
` ${deviceCode.verification_url}`,
|
|
635
|
+
"",
|
|
636
|
+
`Device code: **${deviceCode.code}**`,
|
|
637
|
+
"",
|
|
638
|
+
"Waiting for confirmation (expires in 5 minutes)...",
|
|
639
|
+
];
|
|
640
|
+
// 3. Poll for completion
|
|
641
|
+
const pollIntervalMs = 2000;
|
|
642
|
+
const maxAttempts = Math.ceil((deviceCode.expires_in * 1000) / pollIntervalMs);
|
|
643
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
644
|
+
await sleep(pollIntervalMs);
|
|
645
|
+
const status = await CloudClient.pollDeviceCode(targetBaseUrl, deviceCode.poll_token);
|
|
646
|
+
if (status.status === "completed" && status.api_key) {
|
|
647
|
+
// Save the API key
|
|
648
|
+
saveGlobalConfig({
|
|
649
|
+
api_key: status.api_key,
|
|
650
|
+
base_url: targetBaseUrl,
|
|
651
|
+
});
|
|
652
|
+
cloud = new CloudClient({ apiKey: status.api_key, baseUrl: targetBaseUrl });
|
|
653
|
+
return {
|
|
654
|
+
content: [{
|
|
655
|
+
type: "text",
|
|
656
|
+
text: [
|
|
657
|
+
...lines,
|
|
658
|
+
"",
|
|
659
|
+
`Authenticated as **${status.org_name}** (${status.org_slug}).`,
|
|
660
|
+
"",
|
|
661
|
+
` Config saved to: ~/.fasttest/config.json`,
|
|
662
|
+
"",
|
|
663
|
+
"Cloud features are now active. You can use `test`, `run`, `explore`, and all other tools.",
|
|
664
|
+
].join("\n"),
|
|
665
|
+
}],
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (status.status === "expired") {
|
|
669
|
+
return {
|
|
670
|
+
content: [{
|
|
671
|
+
type: "text",
|
|
672
|
+
text: [
|
|
673
|
+
...lines,
|
|
674
|
+
"",
|
|
675
|
+
"Device code expired. Run `setup` again to get a new code.",
|
|
676
|
+
].join("\n"),
|
|
677
|
+
}],
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
// Still pending — continue polling
|
|
681
|
+
}
|
|
682
|
+
// Timed out
|
|
438
683
|
return {
|
|
439
684
|
content: [{
|
|
440
685
|
type: "text",
|
|
441
686
|
text: [
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
`
|
|
445
|
-
` API Key: ${result.api_key}`,
|
|
446
|
-
` Config saved to: ~/.qa-agent/config.json`,
|
|
447
|
-
``,
|
|
448
|
-
`Cloud features are now active. You can use \`test\`, \`run\`, \`explore\`, and all other tools.`,
|
|
687
|
+
...lines,
|
|
688
|
+
"",
|
|
689
|
+
"Timed out waiting for browser confirmation. Run `setup` again to retry.",
|
|
449
690
|
].join("\n"),
|
|
450
691
|
}],
|
|
451
692
|
};
|
|
452
693
|
}
|
|
453
694
|
catch (err) {
|
|
454
|
-
const msg = String(err);
|
|
455
|
-
if (msg.includes("409")) {
|
|
456
|
-
return {
|
|
457
|
-
content: [{
|
|
458
|
-
type: "text",
|
|
459
|
-
text: [
|
|
460
|
-
`Organization slug "${slug}" is already taken.`,
|
|
461
|
-
`If this is your org, add your API key to ~/.qa-agent/config.json:`,
|
|
462
|
-
` { "api_key": "qa_${slug}_..." }`,
|
|
463
|
-
`Or pass --api-key on the CLI.`,
|
|
464
|
-
].join("\n"),
|
|
465
|
-
}],
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
695
|
return {
|
|
469
696
|
content: [{
|
|
470
697
|
type: "text",
|
|
471
|
-
text: `
|
|
698
|
+
text: `Setup failed: ${String(err)}`,
|
|
472
699
|
}],
|
|
473
700
|
};
|
|
474
701
|
}
|
|
@@ -508,7 +735,10 @@ server.tool("test", "Start a conversational test session. Describe what you want
|
|
|
508
735
|
}
|
|
509
736
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
510
737
|
});
|
|
511
|
-
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."
|
|
738
|
+
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. " +
|
|
739
|
+
"IMPORTANT: For sensitive values (passwords, API keys, tokens), use {{VAR_NAME}} placeholders instead of literal values. " +
|
|
740
|
+
"Example: use {{TEST_USER_PASSWORD}} instead of the actual password. " +
|
|
741
|
+
"The runner resolves these from environment variables at execution time. Variable names must be UPPER_SNAKE_CASE.", {
|
|
512
742
|
suite_name: z.string().describe("Name for the test suite (e.g. 'Login Flow', 'Checkout Tests')"),
|
|
513
743
|
description: z.string().optional().describe("What this suite tests"),
|
|
514
744
|
project: z.string().optional().describe("Project name (auto-resolved or created)"),
|
|
@@ -516,7 +746,8 @@ server.tool("save_suite", "Save test cases as a reusable test suite in the cloud
|
|
|
516
746
|
name: z.string().describe("Test case name"),
|
|
517
747
|
description: z.string().optional().describe("What this test verifies"),
|
|
518
748
|
priority: z.enum(["high", "medium", "low"]).optional().describe("Test priority"),
|
|
519
|
-
steps: z.array(z.record(z.string(), z.unknown())).describe("Test steps: [{action, selector?, value?, url?, description?}]"
|
|
749
|
+
steps: z.array(z.record(z.string(), z.unknown())).describe("Test steps: [{action, selector?, value?, url?, description?}]. " +
|
|
750
|
+
"Use {{VAR_NAME}} placeholders for sensitive values (e.g. value: '{{TEST_PASSWORD}}')"),
|
|
520
751
|
assertions: z.array(z.record(z.string(), z.unknown())).describe("Assertions: [{type, selector?, text?, url?, count?}]"),
|
|
521
752
|
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
522
753
|
})).describe("Array of test cases to save"),
|
|
@@ -554,23 +785,38 @@ server.tool("save_suite", "Save test cases as a reusable test suite in the cloud
|
|
|
554
785
|
});
|
|
555
786
|
savedCases.push(` - ${created.name} (${created.id})`);
|
|
556
787
|
}
|
|
788
|
+
// Scan for {{VAR}} placeholders to show CI/CD guidance
|
|
789
|
+
const allVars = new Set();
|
|
790
|
+
for (const tc of test_cases) {
|
|
791
|
+
const raw = JSON.stringify(tc.steps) + JSON.stringify(tc.assertions);
|
|
792
|
+
const matches = raw.matchAll(/\{\{([A-Z][A-Z0-9_]*)\}\}/g);
|
|
793
|
+
for (const m of matches)
|
|
794
|
+
allVars.add(m[1]);
|
|
795
|
+
}
|
|
796
|
+
const lines = [
|
|
797
|
+
`Suite "${suite.name}" saved successfully.`,
|
|
798
|
+
` Suite ID: ${suite.id}`,
|
|
799
|
+
` Project: ${finalProjectId}`,
|
|
800
|
+
` Test cases (${savedCases.length}):`,
|
|
801
|
+
...savedCases,
|
|
802
|
+
"",
|
|
803
|
+
`To replay: \`run(suite_id: "${suite.id}")\``,
|
|
804
|
+
`To replay by name: \`run(suite_name: "${suite_name}")\``,
|
|
805
|
+
];
|
|
806
|
+
if (allVars.size > 0) {
|
|
807
|
+
lines.push("");
|
|
808
|
+
lines.push("Environment variables required for CI/CD:");
|
|
809
|
+
lines.push("Set these as GitHub repository secrets before running in CI:");
|
|
810
|
+
for (const v of Array.from(allVars).sort()) {
|
|
811
|
+
lines.push(` - ${v}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
557
814
|
return {
|
|
558
|
-
content: [{
|
|
559
|
-
type: "text",
|
|
560
|
-
text: [
|
|
561
|
-
`Suite "${suite.name}" saved successfully.`,
|
|
562
|
-
` Suite ID: ${suite.id}`,
|
|
563
|
-
` Project: ${finalProjectId}`,
|
|
564
|
-
` Test cases (${savedCases.length}):`,
|
|
565
|
-
...savedCases,
|
|
566
|
-
"",
|
|
567
|
-
`To replay: \`run(suite_id: "${suite.id}")\``,
|
|
568
|
-
`To replay by name: \`run(suite_name: "${suite_name}")\``,
|
|
569
|
-
].join("\n"),
|
|
570
|
-
}],
|
|
815
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
571
816
|
};
|
|
572
817
|
});
|
|
573
|
-
server.tool("update_suite", "Update test cases in an existing suite. Use this when the app has changed and tests need updating."
|
|
818
|
+
server.tool("update_suite", "Update test cases in an existing suite. Use this when the app has changed and tests need updating. " +
|
|
819
|
+
"Use {{VAR_NAME}} placeholders for sensitive values (passwords, API keys, tokens) — same as save_suite.", {
|
|
574
820
|
suite_id: z.string().optional().describe("Suite ID to update (provide this OR suite_name)"),
|
|
575
821
|
suite_name: z.string().optional().describe("Suite name to update (resolved automatically)"),
|
|
576
822
|
test_cases: z.array(z.object({
|
|
@@ -677,6 +923,89 @@ server.tool("explore", "Autonomously explore a web application and discover test
|
|
|
677
923
|
};
|
|
678
924
|
});
|
|
679
925
|
// ---------------------------------------------------------------------------
|
|
926
|
+
// Chaos Tools (Break My App)
|
|
927
|
+
// ---------------------------------------------------------------------------
|
|
928
|
+
server.tool("chaos", "Break My App mode: systematically try adversarial inputs to find security and stability bugs", {
|
|
929
|
+
url: z.string().describe("URL to attack"),
|
|
930
|
+
focus: z.enum(["forms", "navigation", "auth", "all"]).optional().describe("Attack focus area"),
|
|
931
|
+
duration: z.enum(["quick", "thorough"]).optional().describe("Quick scan or thorough attack (default: thorough)"),
|
|
932
|
+
project: z.string().optional().describe("Project name for saving report"),
|
|
933
|
+
}, async ({ url, focus, duration }) => {
|
|
934
|
+
const page = await browserMgr.ensureBrowser();
|
|
935
|
+
attachConsoleListener(page);
|
|
936
|
+
await actions.navigate(page, url);
|
|
937
|
+
const snapshot = await actions.getSnapshot(page);
|
|
938
|
+
const screenshotB64 = await actions.screenshot(page, false);
|
|
939
|
+
const lines = [
|
|
940
|
+
"## Page Snapshot",
|
|
941
|
+
"```json",
|
|
942
|
+
JSON.stringify(snapshot, null, 2),
|
|
943
|
+
"```",
|
|
944
|
+
"",
|
|
945
|
+
"## Chaos Configuration",
|
|
946
|
+
`URL: ${url}`,
|
|
947
|
+
`Focus: ${focus ?? "all"}`,
|
|
948
|
+
`Duration: ${duration ?? "thorough"}`,
|
|
949
|
+
"",
|
|
950
|
+
"## Instructions",
|
|
951
|
+
LOCAL_CHAOS_PROMPT,
|
|
952
|
+
];
|
|
953
|
+
if (duration === "quick") {
|
|
954
|
+
lines.push("");
|
|
955
|
+
lines.push("**QUICK MODE**: Only run Phase 1 (Survey) and Phase 2 (Input Fuzzing) with one payload per category. Skip Phases 3-5.");
|
|
956
|
+
}
|
|
957
|
+
if (!cloud) {
|
|
958
|
+
lines.push("");
|
|
959
|
+
lines.push("---");
|
|
960
|
+
lines.push("*Running in local-only mode. Run the `setup` tool to enable saving chaos reports.*");
|
|
961
|
+
}
|
|
962
|
+
return {
|
|
963
|
+
content: [
|
|
964
|
+
{ type: "text", text: lines.join("\n") },
|
|
965
|
+
{ type: "image", data: screenshotB64, mimeType: "image/jpeg" },
|
|
966
|
+
],
|
|
967
|
+
};
|
|
968
|
+
});
|
|
969
|
+
server.tool("save_chaos_report", "Save findings from a Break My App chaos session to the cloud", {
|
|
970
|
+
url: z.string().describe("URL that was tested"),
|
|
971
|
+
project: z.string().optional().describe("Project name (auto-resolved or created)"),
|
|
972
|
+
findings: z.array(z.object({
|
|
973
|
+
severity: z.enum(["critical", "high", "medium", "low"]),
|
|
974
|
+
category: z.string().describe("e.g. xss, injection, crash, validation, error, auth"),
|
|
975
|
+
description: z.string(),
|
|
976
|
+
reproduction_steps: z.array(z.string()),
|
|
977
|
+
console_errors: z.array(z.string()).optional(),
|
|
978
|
+
})).describe("List of findings from the chaos session"),
|
|
979
|
+
}, async ({ url, project, findings }) => {
|
|
980
|
+
const c = requireCloud();
|
|
981
|
+
let projectId;
|
|
982
|
+
if (project) {
|
|
983
|
+
try {
|
|
984
|
+
const p = await resolveProjectId(project);
|
|
985
|
+
projectId = p;
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
const p = await c.resolveProject(project);
|
|
989
|
+
projectId = p.id;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
const report = await c.saveChaosReport(projectId, { url, findings });
|
|
993
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
994
|
+
for (const f of findings) {
|
|
995
|
+
sevCounts[f.severity]++;
|
|
996
|
+
}
|
|
997
|
+
const lines = [
|
|
998
|
+
`Chaos report saved (${findings.length} findings)`,
|
|
999
|
+
"",
|
|
1000
|
+
`Critical: ${sevCounts.critical} | High: ${sevCounts.high} | Medium: ${sevCounts.medium} | Low: ${sevCounts.low}`,
|
|
1001
|
+
"",
|
|
1002
|
+
`Report ID: ${report.id ?? "saved"}`,
|
|
1003
|
+
];
|
|
1004
|
+
return {
|
|
1005
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1006
|
+
};
|
|
1007
|
+
});
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
680
1009
|
// Execution Tools (Phase 3)
|
|
681
1010
|
// ---------------------------------------------------------------------------
|
|
682
1011
|
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.", {
|
|
@@ -732,6 +1061,17 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
|
|
|
732
1061
|
lines.push(` Strategy: ${h.strategy} (${Math.round(h.confidence * 100)}% confidence)`);
|
|
733
1062
|
}
|
|
734
1063
|
}
|
|
1064
|
+
// Collect flaky retries (tests that passed after retries)
|
|
1065
|
+
const flakyRetries = summary.results
|
|
1066
|
+
.filter((r) => r.status === "passed" && (r.retry_attempts ?? 0) > 0)
|
|
1067
|
+
.map((r) => ({ name: r.name, retry_attempts: r.retry_attempts }));
|
|
1068
|
+
if (flakyRetries.length > 0) {
|
|
1069
|
+
lines.push("");
|
|
1070
|
+
lines.push(`## Flaky Tests: ${flakyRetries.length} test(s) required retries`);
|
|
1071
|
+
for (const f of flakyRetries) {
|
|
1072
|
+
lines.push(` ♻️ ${f.name} — passed after ${f.retry_attempts} retry(ies)`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
735
1075
|
// Post PR comment if pr_url was provided
|
|
736
1076
|
if (pr_url) {
|
|
737
1077
|
try {
|
|
@@ -755,6 +1095,7 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
|
|
|
755
1095
|
strategy: h.strategy,
|
|
756
1096
|
confidence: h.confidence,
|
|
757
1097
|
})),
|
|
1098
|
+
flaky_retries: flakyRetries.length > 0 ? flakyRetries : undefined,
|
|
758
1099
|
});
|
|
759
1100
|
const commentUrl = prResult.comment_url;
|
|
760
1101
|
lines.push("");
|
|
@@ -809,7 +1150,7 @@ server.tool("list_suites", "List test suites across all projects. Use this to fi
|
|
|
809
1150
|
}
|
|
810
1151
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
811
1152
|
});
|
|
812
|
-
server.tool("health", "Check if the
|
|
1153
|
+
server.tool("health", "Check if the FastTest Agent backend is reachable", {}, async () => {
|
|
813
1154
|
const result = await requireCloud().health();
|
|
814
1155
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
815
1156
|
});
|