@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/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * QA Agent — MCP server (stdio transport).
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
- * - 16 browser tools (Playwright, runs locally)
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
- - browser_select_optionselect dropdown values
42
+ - browser_fill_formfill 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.qa-agent.dev";
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 QA Agent cloud. Run the `setup` tool first to create an organization.");
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: "qa-agent",
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 — first-time onboarding
591
+ // Setup Tool — device auth flow (opens browser for secure authentication)
409
592
  // ---------------------------------------------------------------------------
410
- server.tool("setup", "Set up QA Agent: create an organization and save API key. Run this before using cloud features (test plans, execution, healing).", {
411
- org_name: z.string().describe("Organization name (e.g. 'Acme Corp')"),
412
- org_slug: z.string().optional().describe("URL-safe slug (e.g. 'acme-corp'). Auto-derived from name if omitted."),
413
- base_url: z.string().optional().describe("Cloud API base URL (default: https://api.qa-agent.dev)"),
414
- }, async ({ org_name, org_slug, base_url }) => {
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 QA Agent cloud. To switch organizations, edit ~/.qa-agent/config.json or pass --api-key on the CLI.",
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
- const result = await CloudClient.createOrg(targetBaseUrl, {
430
- name: org_name,
431
- slug,
432
- });
433
- saveGlobalConfig({
434
- api_key: result.api_key,
435
- base_url: targetBaseUrl,
436
- });
437
- cloud = new CloudClient({ apiKey: result.api_key, baseUrl: targetBaseUrl });
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
- `Organization "${result.name}" created successfully.`,
443
- ``,
444
- ` Plan: ${result.plan}`,
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: `Failed to create organization: ${msg}`,
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 QA agent backend is reachable", {}, async () => {
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
  });