@fasttest-ai/qa-agent 0.1.3 → 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,29 +1,287 @@
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
- * - 10 browser tools (Playwright, runs locally)
10
- * - Cloud-forwarding tools (test, answer, approve, explore, run, status, cancel, etc.)
9
+ * - 21 browser tools (Playwright, runs locally)
10
+ * - Local-first tools (test, explore, heal host AI drives via structured prompts)
11
+ * - Cloud tools (save_suite, update_suite, run, status, cancel, etc. — require setup)
11
12
  */
12
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
15
  import { z } from "zod";
16
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { execFile } from "node:child_process";
15
19
  import { BrowserManager } from "./browser.js";
16
20
  import { CloudClient } from "./cloud.js";
17
21
  import * as actions from "./actions.js";
18
22
  import { executeRun } from "./runner.js";
19
23
  import { healSelector } from "./healer.js";
24
+ import { loadGlobalConfig, saveGlobalConfig } from "./config.js";
25
+ // ---------------------------------------------------------------------------
26
+ // Local-mode test prompt (used when no cloud is connected)
27
+ // ---------------------------------------------------------------------------
28
+ const LOCAL_TEST_PROMPT = `\
29
+ You are executing QA tests by driving a real browser via tools. The page \
30
+ snapshot above shows the current state of the page. Follow this methodology:
31
+
32
+ ## Execution loop (repeat for each test scenario)
33
+
34
+ 1. **Plan**: Read the page snapshot. Identify the elements you need to \
35
+ interact with. Pick the most stable selectors (data-testid > aria-label \
36
+ > role > text > CSS).
37
+ 2. **Act**: Execute steps using browser tools:
38
+ - browser_navigate — load a URL
39
+ - browser_click — click elements (use CSS selectors from the snapshot)
40
+ - browser_fill — type into inputs
41
+ - browser_press_key — keyboard actions (Enter, Tab, Escape)
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)
46
+ - browser_wait — wait for elements or a timeout
47
+ 3. **Verify**: After each significant action, use browser_assert to check \
48
+ the expected outcome. Available assertion types: element_visible, \
49
+ element_hidden, text_contains, text_equals, url_contains, url_equals, \
50
+ element_count, attribute_value.
51
+ 4. **Snapshot**: After actions that change the page (form submit, navigation, \
52
+ modal open), use browser_snapshot to get the updated page state before \
53
+ continuing.
54
+ 5. **Evidence**: Use browser_screenshot after key assertions to capture proof.
55
+
56
+ ## Error recovery
57
+
58
+ - If browser_click or browser_fill fails with "element not found", use the \
59
+ \`heal\` tool with the broken selector. It will try multiple strategies \
60
+ to find the element.
61
+ - If heal also fails, take a browser_snapshot and analyze the page state — \
62
+ the element may be behind a loading spinner, inside an iframe, or require \
63
+ scrolling.
64
+ - If an assertion fails, do NOT retry the same assertion. Report it as a \
65
+ failure — it may be a real bug.
66
+
67
+ ## What to test
68
+
69
+ Based on the test request above, cover these scenarios in order:
70
+ 1. **Happy path**: The primary flow as described. This is the most important.
71
+ 2. **Input validation**: If the flow has form fields, test empty submission \
72
+ and one invalid input format.
73
+ 3. **Error states**: If the flow involves API calls or actions that can fail, \
74
+ test one failure scenario.
75
+
76
+ Only test scenarios that are relevant to the request. Don't force edge cases \
77
+ that don't apply.
78
+
79
+ ## Output format
80
+
81
+ After testing, provide a clear summary:
82
+ - List each scenario tested with PASS or FAIL
83
+ - For failures, include what was expected vs. what happened
84
+ - If any selectors were healed during testing, note the original and new \
85
+ selectors
86
+
87
+ 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
+
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.`;
104
+ const LOCAL_EXPLORE_PROMPT = `\
105
+ You are autonomously exploring a web application to discover testable flows. \
106
+ The page snapshot and screenshot above show your starting point.
107
+
108
+ ## Exploration methodology
109
+
110
+ Use a breadth-first approach: survey the app's structure before diving deep.
111
+
112
+ ### Phase 1: Survey (explore broadly)
113
+ 1. Read the current page snapshot. Note every navigation link, button, and form.
114
+ 2. Click through the main navigation to discover all top-level pages.
115
+ 3. For each new page, use browser_snapshot to capture its structure.
116
+ 4. Keep a mental map of pages visited and their URLs — do NOT revisit pages \
117
+ you've already seen.
118
+
119
+ ### Phase 2: Catalog (go deeper on high-value pages)
120
+ For pages that have forms, CRUD operations, or multi-step flows:
121
+ 1. Identify the form fields and their types.
122
+ 2. Note any authentication requirements (login walls, role-based access).
123
+ 3. Look for state-changing actions (create, edit, delete).
124
+
125
+ ## Stopping criteria
126
+ - Stop after visiting the number of pages specified in "Max pages" above.
127
+ - Stop if you encounter a login wall and don't have credentials.
128
+ - Stop if you've visited all reachable pages from the main navigation.
129
+ - Do NOT explore: external links, social media, terms/privacy pages, \
130
+ documentation, or links that would leave the application domain.
131
+
132
+ ## Tools to use
133
+ - browser_click — navigate to pages, open menus, expand sections
134
+ - browser_navigate — go to a specific URL
135
+ - browser_snapshot — capture the accessibility tree of the current page
136
+ - browser_screenshot — capture visual evidence of interesting pages
137
+ - browser_go_back — return to the previous page
138
+
139
+ ## Output format
140
+
141
+ After exploring, present a structured summary:
142
+
143
+ **Pages discovered:**
144
+ | URL | Page type | Key elements |
145
+ |-----|-----------|--------------|
146
+
147
+ **Testable flows discovered:**
148
+ 1. Flow name — brief description (pages involved)
149
+ 2. Flow name — brief description (pages involved)
150
+
151
+ **Forms found:**
152
+ - Page URL: field names and types
153
+
154
+ Then ask: "Which flows would you like me to test? I can run them now with \
155
+ the \`test\` tool, or save them as a reusable suite with \`save_suite\`."`;
156
+ const LOCAL_HEAL_PROMPT = `\
157
+ A test step failed because a CSS selector no longer matches any element. \
158
+ Four automated repair strategies (data-testid matching, ARIA label matching, \
159
+ text content matching, structural matching) have already been tried and failed.
160
+
161
+ You are the last resort. Use your reasoning to diagnose and fix this.
162
+
163
+ ## Broken selector details
164
+ - Selector: {selector}
165
+ - Error: {error_message}
166
+ - Page URL: {page_url}
167
+
168
+ ## Diagnosis steps
169
+
170
+ 1. **Understand the intent**: What element was the selector trying to target? \
171
+ Parse the selector to determine: is it a button, input, link, container? \
172
+ What was its purpose in the test?
173
+
174
+ 2. **Search the snapshot**: The page snapshot is below. Look for elements \
175
+ that match the INTENT of the original selector, not its syntax. Search by:
176
+ - Role (button, textbox, link, heading)
177
+ - Label or visible text
178
+ - Position in the page structure (e.g., "the submit button in the login form")
179
+
180
+ 3. **Determine root cause**: Why did the selector break?
181
+ - **Renamed**: Element exists but with a different ID/class/attribute
182
+ - **Moved**: Element exists but in a different part of the DOM
183
+ - **Replaced**: Old element removed, new one added with different markup
184
+ - **Hidden**: Element exists but is not visible (behind a modal, in a \
185
+ collapsed section, requires scrolling)
186
+ - **Removed**: Element genuinely doesn't exist — this is a REAL BUG
187
+
188
+ 4. **Construct a new selector**: Build the most stable selector possible.
189
+ Priority: [data-testid] > [aria-label] > role-based > text-based > \
190
+ structural.
191
+
192
+ 5. **Verify**: Use browser_assert with type "element_visible" and your new \
193
+ selector. If it passes, report the fix. If it fails, try your next best \
194
+ candidate.
195
+
196
+ ## Important
197
+
198
+ - If the element genuinely doesn't exist on the page (not renamed, not \
199
+ moved, not hidden), report it as a REAL BUG. Say: "This appears to be a \
200
+ real bug — the [element description] is missing from the page."
201
+ - Do NOT suggest fragile selectors (nth-child, auto-generated CSS classes).
202
+ - Do NOT suggest more than 3 candidates — if none of them work after \
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.`;
20
278
  // ---------------------------------------------------------------------------
21
279
  // CLI arg parsing
22
280
  // ---------------------------------------------------------------------------
23
281
  function parseArgs() {
24
282
  const args = process.argv.slice(2);
25
- let apiKey = "";
26
- let baseUrl = "http://localhost:9000";
283
+ let apiKey;
284
+ let baseUrl = "";
27
285
  let headless = true;
28
286
  let browserType = "chromium";
29
287
  for (let i = 0; i < args.length; i++) {
@@ -40,10 +298,6 @@ function parseArgs() {
40
298
  browserType = args[++i];
41
299
  }
42
300
  }
43
- if (!apiKey) {
44
- console.error("Usage: qa-agent --api-key <key> [--base-url <url>] [--headed] [--browser chromium|firefox|webkit]");
45
- process.exit(1);
46
- }
47
301
  return { apiKey, baseUrl, headless, browser: browserType };
48
302
  }
49
303
  // ---------------------------------------------------------------------------
@@ -52,18 +306,76 @@ function parseArgs() {
52
306
  const consoleLogs = [];
53
307
  const MAX_LOGS = 500;
54
308
  // ---------------------------------------------------------------------------
55
- // Boot
309
+ // Boot — resolve auth from CLI > config file > null (local-only mode)
56
310
  // ---------------------------------------------------------------------------
57
- const config = parseArgs();
58
- const orgSlug = config.apiKey.split("_")[1] ?? "default";
311
+ const cliArgs = parseArgs();
312
+ const globalCfg = loadGlobalConfig();
313
+ // Resolution: CLI --api-key wins, then config file, then undefined
314
+ const resolvedApiKey = cliArgs.apiKey || globalCfg.api_key || undefined;
315
+ const resolvedBaseUrl = cliArgs.baseUrl || globalCfg.base_url || "https://api.fasttest.ai";
316
+ const orgSlug = resolvedApiKey ? (resolvedApiKey.split("_")[1] ?? "default") : "default";
59
317
  const browserMgr = new BrowserManager({
60
- browserType: config.browser,
61
- headless: config.headless,
318
+ browserType: cliArgs.browser,
319
+ headless: cliArgs.headless,
62
320
  orgSlug,
63
321
  });
64
- const cloud = new CloudClient({ apiKey: config.apiKey, baseUrl: config.baseUrl });
322
+ // cloud is null until auth is available (lazy initialization)
323
+ let cloud = resolvedApiKey
324
+ ? new CloudClient({ apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl })
325
+ : null;
326
+ // ---------------------------------------------------------------------------
327
+ // Cloud guard — returns CloudClient or throws a user-friendly error
328
+ // ---------------------------------------------------------------------------
329
+ function requireCloud() {
330
+ if (!cloud) {
331
+ throw new Error("Not connected to FastTest cloud. Run the `setup` tool first to create an organization.");
332
+ }
333
+ return cloud;
334
+ }
335
+ const FASTTEST_CONFIG = ".fasttest.json";
336
+ function configPath() {
337
+ return join(process.cwd(), FASTTEST_CONFIG);
338
+ }
339
+ function loadConfig() {
340
+ const p = configPath();
341
+ if (!existsSync(p))
342
+ return null;
343
+ try {
344
+ return JSON.parse(readFileSync(p, "utf-8"));
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ function saveConfig(cfg) {
351
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
352
+ }
353
+ /**
354
+ * Resolve a project_id. Priority:
355
+ * 1. .fasttest.json in cwd (cached from previous run)
356
+ * 2. Explicit project name from the LLM → resolve via cloud API
357
+ * 3. null (no project scoping)
358
+ */
359
+ async function resolveProjectId(projectName) {
360
+ // 1. Check .fasttest.json
361
+ const cached = loadConfig();
362
+ if (cached?.project_id)
363
+ return cached.project_id;
364
+ // 2. If LLM provided a project name, resolve it via cloud
365
+ if (projectName && cloud) {
366
+ try {
367
+ const resolved = await cloud.resolveProject(projectName);
368
+ saveConfig({ project_id: resolved.id, project_name: resolved.name });
369
+ return resolved.id;
370
+ }
371
+ catch (err) {
372
+ console.error(`Failed to resolve project "${projectName}": ${err}`);
373
+ }
374
+ }
375
+ return undefined;
376
+ }
65
377
  const server = new McpServer({
66
- name: "qa-agent",
378
+ name: "fasttest",
67
379
  version: "0.1.0",
68
380
  });
69
381
  // ---------------------------------------------------------------------------
@@ -185,64 +497,543 @@ server.tool("browser_evaluate", "Execute JavaScript in the page context and retu
185
497
  const result = await actions.evaluate(page, expression);
186
498
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
187
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
+ });
590
+ // ---------------------------------------------------------------------------
591
+ // Setup Tool — device auth flow (opens browser for secure authentication)
592
+ // ---------------------------------------------------------------------------
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 }) => {
616
+ if (cloud) {
617
+ return {
618
+ content: [{
619
+ type: "text",
620
+ text: "Already connected to FastTest cloud. To switch organizations, edit ~/.fasttest/config.json or pass --api-key on the CLI.",
621
+ }],
622
+ };
623
+ }
624
+ const targetBaseUrl = base_url ?? resolvedBaseUrl;
625
+ try {
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
683
+ return {
684
+ content: [{
685
+ type: "text",
686
+ text: [
687
+ ...lines,
688
+ "",
689
+ "Timed out waiting for browser confirmation. Run `setup` again to retry.",
690
+ ].join("\n"),
691
+ }],
692
+ };
693
+ }
694
+ catch (err) {
695
+ return {
696
+ content: [{
697
+ type: "text",
698
+ text: `Setup failed: ${String(err)}`,
699
+ }],
700
+ };
701
+ }
702
+ });
188
703
  // ---------------------------------------------------------------------------
189
704
  // Cloud-forwarding Tools
190
705
  // ---------------------------------------------------------------------------
191
706
  server.tool("test", "Start a conversational test session. Describe what you want to test.", {
192
707
  description: z.string().describe("What to test (natural language)"),
193
708
  url: z.string().optional().describe("App URL to test against"),
194
- }, async ({ description, url }) => {
195
- const result = await cloud.startConversation({ description, url });
196
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
709
+ project: z.string().optional().describe("Project name (e.g. 'My SaaS App'). Auto-saved to .fasttest.json for future runs."),
710
+ }, async ({ description, url, project }) => {
711
+ // Always use local mode: host AI drives browser tools directly.
712
+ // Cloud LLM is never used from the MCP server — the host AI (Claude Code,
713
+ // Codex, etc.) follows our prompt with its own reasoning capability.
714
+ const lines = [];
715
+ if (url) {
716
+ const page = await browserMgr.ensureBrowser();
717
+ attachConsoleListener(page);
718
+ await actions.navigate(page, url);
719
+ const snapshot = await actions.getSnapshot(page);
720
+ lines.push("## Page Snapshot");
721
+ lines.push("```json");
722
+ lines.push(JSON.stringify(snapshot, null, 2));
723
+ lines.push("```");
724
+ lines.push("");
725
+ }
726
+ lines.push("## Test Request");
727
+ lines.push(description);
728
+ lines.push("");
729
+ lines.push("## Instructions");
730
+ lines.push(LOCAL_TEST_PROMPT);
731
+ if (!cloud) {
732
+ lines.push("");
733
+ lines.push("---");
734
+ lines.push("*Running in local-only mode. Run the `setup` tool to enable persistent test suites, CI/CD, and the dashboard.*");
735
+ }
736
+ return { content: [{ type: "text", text: lines.join("\n") }] };
197
737
  });
198
- server.tool("answer", "Respond to the agent's clarifying questions", {
199
- session_id: z.string().describe("Session ID from the test call"),
200
- answers: z.string().describe("Your responses to the agent's questions"),
201
- }, async ({ session_id, answers }) => {
202
- const result = await cloud.answerConversation(session_id, answers);
203
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
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.", {
742
+ suite_name: z.string().describe("Name for the test suite (e.g. 'Login Flow', 'Checkout Tests')"),
743
+ description: z.string().optional().describe("What this suite tests"),
744
+ project: z.string().optional().describe("Project name (auto-resolved or created)"),
745
+ test_cases: z.array(z.object({
746
+ name: z.string().describe("Test case name"),
747
+ description: z.string().optional().describe("What this test verifies"),
748
+ 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?}]. " +
750
+ "Use {{VAR_NAME}} placeholders for sensitive values (e.g. value: '{{TEST_PASSWORD}}')"),
751
+ assertions: z.array(z.record(z.string(), z.unknown())).describe("Assertions: [{type, selector?, text?, url?, count?}]"),
752
+ tags: z.array(z.string()).optional().describe("Tags for categorization"),
753
+ })).describe("Array of test cases to save"),
754
+ }, async ({ suite_name, description, project, test_cases }) => {
755
+ const c = requireCloud();
756
+ // Resolve project
757
+ const projectId = await resolveProjectId(project);
758
+ let finalProjectId = projectId;
759
+ if (!finalProjectId) {
760
+ const resolved = await c.resolveProject(project ?? "Default");
761
+ finalProjectId = resolved.id;
762
+ saveConfig({ project_id: resolved.id, project_name: resolved.name });
763
+ }
764
+ // Create suite
765
+ const suite = await c.createSuite(finalProjectId, {
766
+ name: suite_name,
767
+ description,
768
+ auto_generated: true,
769
+ test_type: "functional",
770
+ });
771
+ // Create test cases linked to the suite
772
+ const savedCases = [];
773
+ for (const tc of test_cases) {
774
+ const created = await c.createTestCase({
775
+ name: tc.name,
776
+ description: tc.description,
777
+ priority: tc.priority ?? "medium",
778
+ steps: tc.steps,
779
+ assertions: tc.assertions,
780
+ tags: tc.tags ?? [],
781
+ test_suite_ids: [suite.id],
782
+ auto_generated: true,
783
+ generated_by_agent: true,
784
+ natural_language_source: suite_name,
785
+ });
786
+ savedCases.push(` - ${created.name} (${created.id})`);
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
+ }
814
+ return {
815
+ content: [{ type: "text", text: lines.join("\n") }],
816
+ };
204
817
  });
205
- server.tool("approve", "Approve the generated test plan and start execution", {
206
- session_id: z.string().describe("Session ID"),
207
- exclude: z.array(z.number()).optional().describe("Test case numbers to skip"),
208
- }, async ({ session_id, exclude }) => {
209
- const result = await cloud.approveConversation(session_id, exclude);
210
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
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.", {
820
+ suite_id: z.string().optional().describe("Suite ID to update (provide this OR suite_name)"),
821
+ suite_name: z.string().optional().describe("Suite name to update (resolved automatically)"),
822
+ test_cases: z.array(z.object({
823
+ id: z.string().optional().describe("Existing test case ID to update (omit to add a new case)"),
824
+ name: z.string().describe("Test case name"),
825
+ description: z.string().optional(),
826
+ priority: z.enum(["high", "medium", "low"]).optional(),
827
+ steps: z.array(z.record(z.string(), z.unknown())).describe("Updated test steps"),
828
+ assertions: z.array(z.record(z.string(), z.unknown())).describe("Updated assertions"),
829
+ tags: z.array(z.string()).optional(),
830
+ })).describe("Test cases to update or add"),
831
+ }, async ({ suite_id, suite_name, test_cases }) => {
832
+ const c = requireCloud();
833
+ // Resolve suite ID
834
+ let resolvedSuiteId = suite_id;
835
+ if (!resolvedSuiteId && suite_name) {
836
+ const resolved = await c.resolveSuite(suite_name);
837
+ resolvedSuiteId = resolved.id;
838
+ }
839
+ if (!resolvedSuiteId) {
840
+ return {
841
+ content: [{ type: "text", text: "Either suite_id or suite_name is required." }],
842
+ };
843
+ }
844
+ const updated = [];
845
+ const created = [];
846
+ for (const tc of test_cases) {
847
+ if (tc.id) {
848
+ // Update existing test case
849
+ const result = await c.updateTestCase(tc.id, {
850
+ name: tc.name,
851
+ description: tc.description,
852
+ priority: tc.priority,
853
+ steps: tc.steps,
854
+ assertions: tc.assertions,
855
+ tags: tc.tags,
856
+ });
857
+ updated.push(` - ${result.name} (${result.id})`);
858
+ }
859
+ else {
860
+ // Create new test case and link to suite
861
+ const result = await c.createTestCase({
862
+ name: tc.name,
863
+ description: tc.description,
864
+ priority: tc.priority ?? "medium",
865
+ steps: tc.steps,
866
+ assertions: tc.assertions,
867
+ tags: tc.tags ?? [],
868
+ test_suite_ids: [resolvedSuiteId],
869
+ auto_generated: true,
870
+ generated_by_agent: true,
871
+ });
872
+ created.push(` - ${result.name} (${result.id})`);
873
+ }
874
+ }
875
+ const lines = [`Suite "${resolvedSuiteId}" updated.`];
876
+ if (updated.length > 0) {
877
+ lines.push(`Updated (${updated.length}):`);
878
+ lines.push(...updated);
879
+ }
880
+ if (created.length > 0) {
881
+ lines.push(`Added (${created.length}):`);
882
+ lines.push(...created);
883
+ }
884
+ return {
885
+ content: [{ type: "text", text: lines.join("\n") }],
886
+ };
211
887
  });
212
888
  server.tool("explore", "Autonomously explore a web application and discover testable flows", {
213
889
  url: z.string().describe("Starting URL"),
214
890
  max_pages: z.number().optional().describe("Max pages to explore (default 20)"),
215
891
  focus: z.enum(["forms", "navigation", "errors", "all"]).optional().describe("Exploration focus"),
216
892
  }, async ({ url, max_pages, focus }) => {
217
- // Step 1: Navigate locally to get initial snapshot
893
+ // Always local-first: navigate, snapshot, return prompt for host AI
218
894
  const page = await browserMgr.ensureBrowser();
219
895
  attachConsoleListener(page);
220
896
  await actions.navigate(page, url);
221
897
  const snapshot = await actions.getSnapshot(page);
222
898
  const screenshotB64 = await actions.screenshot(page, false);
223
- // Step 2: Send to cloud for AI analysis
224
- const result = await cloud.startExploration({
225
- url,
226
- max_pages: max_pages ?? 20,
227
- focus: focus ?? "all",
228
- });
899
+ const lines = [
900
+ "## Page Snapshot",
901
+ "```json",
902
+ JSON.stringify(snapshot, null, 2),
903
+ "```",
904
+ "",
905
+ "## Exploration Request",
906
+ `URL: ${url}`,
907
+ `Focus: ${focus ?? "all"}`,
908
+ `Max pages: ${max_pages ?? 20}`,
909
+ "",
910
+ "## Instructions",
911
+ LOCAL_EXPLORE_PROMPT,
912
+ ];
913
+ if (!cloud) {
914
+ lines.push("");
915
+ lines.push("---");
916
+ lines.push("*Running in local-only mode. Run the `setup` tool to enable persistent test suites and CI/CD.*");
917
+ }
918
+ return {
919
+ content: [
920
+ { type: "text", text: lines.join("\n") },
921
+ { type: "image", data: screenshotB64, mimeType: "image/jpeg" },
922
+ ],
923
+ };
924
+ });
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
+ }
229
962
  return {
230
963
  content: [
231
- { type: "text", text: JSON.stringify({ ...result, initial_snapshot: snapshot }, null, 2) },
964
+ { type: "text", text: lines.join("\n") },
232
965
  { type: "image", data: screenshotB64, mimeType: "image/jpeg" },
233
966
  ],
234
967
  };
235
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
+ });
236
1008
  // ---------------------------------------------------------------------------
237
1009
  // Execution Tools (Phase 3)
238
1010
  // ---------------------------------------------------------------------------
239
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.", {
240
- suite_id: z.string().describe("Test suite ID to run"),
1012
+ suite_id: z.string().optional().describe("Test suite ID to run (provide this OR suite_name)"),
1013
+ suite_name: z.string().optional().describe("Test suite name to run (resolved to ID automatically). Example: 'checkout flow'"),
241
1014
  test_case_ids: z.array(z.string()).optional().describe("Specific test case IDs to run (default: all in suite)"),
242
1015
  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)"),
243
- }, async ({ suite_id, test_case_ids, pr_url }) => {
244
- const summary = await executeRun(browserMgr, cloud, {
245
- suiteId: suite_id,
1016
+ }, async ({ suite_id, suite_name, test_case_ids, pr_url }) => {
1017
+ // Resolve suite_id from suite_name if needed
1018
+ let resolvedSuiteId = suite_id;
1019
+ if (!resolvedSuiteId && suite_name) {
1020
+ try {
1021
+ const resolved = await requireCloud().resolveSuite(suite_name);
1022
+ resolvedSuiteId = resolved.id;
1023
+ }
1024
+ catch {
1025
+ return {
1026
+ content: [{ type: "text", text: `Could not find a suite matching "${suite_name}". Use \`list_suites\` to see available suites.` }],
1027
+ };
1028
+ }
1029
+ }
1030
+ if (!resolvedSuiteId) {
1031
+ return {
1032
+ content: [{ type: "text", text: "Either suite_id or suite_name is required. Use `list_suites` to find available suites." }],
1033
+ };
1034
+ }
1035
+ const summary = await executeRun(browserMgr, requireCloud(), {
1036
+ suiteId: resolvedSuiteId,
246
1037
  testCaseIds: test_case_ids,
247
1038
  }, consoleLogs);
248
1039
  // Format a human-readable summary
@@ -270,10 +1061,21 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
270
1061
  lines.push(` Strategy: ${h.strategy} (${Math.round(h.confidence * 100)}% confidence)`);
271
1062
  }
272
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
+ }
273
1075
  // Post PR comment if pr_url was provided
274
1076
  if (pr_url) {
275
1077
  try {
276
- const prResult = await cloud.postPrComment({
1078
+ const prResult = await requireCloud().postPrComment({
277
1079
  pr_url,
278
1080
  execution_id: summary.execution_id,
279
1081
  status: summary.status,
@@ -293,6 +1095,7 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
293
1095
  strategy: h.strategy,
294
1096
  confidence: h.confidence,
295
1097
  })),
1098
+ flaky_retries: flakyRetries.length > 0 ? flakyRetries : undefined,
296
1099
  });
297
1100
  const commentUrl = prResult.comment_url;
298
1101
  lines.push("");
@@ -310,27 +1113,45 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
310
1113
  server.tool("github_token", "Set the GitHub personal access token for PR integration", {
311
1114
  token: z.string().describe("GitHub personal access token (PAT) with repo scope"),
312
1115
  }, async ({ token }) => {
313
- await cloud.setGithubToken(token);
1116
+ await requireCloud().setGithubToken(token);
314
1117
  return { content: [{ type: "text", text: "GitHub token stored securely." }] };
315
1118
  });
316
1119
  server.tool("status", "Check the status of a test execution", {
317
1120
  execution_id: z.string().describe("Execution ID to check"),
318
1121
  }, async ({ execution_id }) => {
319
- const result = await cloud.getExecutionStatus(execution_id);
1122
+ const result = await requireCloud().getExecutionStatus(execution_id);
320
1123
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
321
1124
  });
322
1125
  server.tool("cancel", "Cancel a running test execution", {
323
1126
  execution_id: z.string().describe("Execution ID to cancel"),
324
1127
  }, async ({ execution_id }) => {
325
- const result = await cloud.cancelExecution(execution_id);
1128
+ const result = await requireCloud().cancelExecution(execution_id);
326
1129
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
327
1130
  });
328
1131
  server.tool("list_projects", "List all QA projects in the organization", {}, async () => {
329
- const result = await cloud.listProjects();
1132
+ const result = await requireCloud().listProjects();
330
1133
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
331
1134
  });
332
- server.tool("health", "Check if the QA agent backend is reachable", {}, async () => {
333
- const result = await cloud.health();
1135
+ server.tool("list_suites", "List test suites across all projects. Use this to find suite IDs for the `run` tool.", {
1136
+ search: z.string().optional().describe("Filter suites by name (e.g. 'checkout')"),
1137
+ }, async ({ search }) => {
1138
+ const suites = await requireCloud().listSuites(search);
1139
+ if (!Array.isArray(suites) || suites.length === 0) {
1140
+ return { content: [{ type: "text", text: "No test suites found." }] };
1141
+ }
1142
+ const lines = ["# Test Suites", ""];
1143
+ for (const s of suites) {
1144
+ lines.push(`- **${s.name}**`);
1145
+ lines.push(` ID: \`${s.id}\``);
1146
+ lines.push(` Project: ${s.project_name} | Type: ${s.test_type}`);
1147
+ if (s.description)
1148
+ lines.push(` ${s.description}`);
1149
+ lines.push("");
1150
+ }
1151
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1152
+ });
1153
+ server.tool("health", "Check if the FastTest Agent backend is reachable", {}, async () => {
1154
+ const result = await requireCloud().health();
334
1155
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
335
1156
  });
336
1157
  // ---------------------------------------------------------------------------
@@ -343,13 +1164,14 @@ server.tool("heal", "Attempt to heal a broken selector by trying alternative loc
343
1164
  }, async ({ selector, page_url, error_message }) => {
344
1165
  const page = await browserMgr.getPage();
345
1166
  const url = page_url ?? page.url();
1167
+ // Try local deterministic strategies first (no cloud needed)
346
1168
  const result = await healSelector(page, cloud, selector, "ELEMENT_NOT_FOUND", error_message ?? "Element not found", url);
347
1169
  if (result.healed) {
348
1170
  return {
349
1171
  content: [{
350
1172
  type: "text",
351
1173
  text: [
352
- `🔧 Selector healed!`,
1174
+ `Selector healed!`,
353
1175
  ` Original: ${selector}`,
354
1176
  ` New: ${result.newSelector}`,
355
1177
  ` Strategy: ${result.strategy} (${Math.round((result.confidence ?? 0) * 100)}% confidence)`,
@@ -357,19 +1179,36 @@ server.tool("heal", "Attempt to heal a broken selector by trying alternative loc
357
1179
  }],
358
1180
  };
359
1181
  }
1182
+ // Local strategies exhausted — return snapshot + prompt for host AI to reason
1183
+ const snapshot = await actions.getSnapshot(page);
1184
+ const healPrompt = LOCAL_HEAL_PROMPT
1185
+ .replace("{selector}", selector)
1186
+ .replace("{error_message}", error_message ?? "Element not found")
1187
+ .replace("{page_url}", url);
360
1188
  return {
361
1189
  content: [{
362
1190
  type: "text",
363
- text: `❌ Could not heal selector: ${selector}\n ${result.error ?? "All strategies exhausted"}`,
1191
+ text: [
1192
+ `Local healing strategies could not fix: ${selector}`,
1193
+ "",
1194
+ "## Page Snapshot",
1195
+ "```json",
1196
+ JSON.stringify(snapshot, null, 2),
1197
+ "```",
1198
+ "",
1199
+ "## Instructions",
1200
+ healPrompt,
1201
+ ].join("\n"),
364
1202
  }],
365
1203
  };
366
1204
  });
367
1205
  server.tool("healing_history", "View healing patterns and statistics for the organization", {
368
1206
  limit: z.number().optional().describe("Max patterns to return (default 20)"),
369
1207
  }, async ({ limit }) => {
1208
+ const c = requireCloud();
370
1209
  const [patterns, stats] = await Promise.all([
371
- cloud.get(`/qa/healing/patterns?limit=${limit ?? 20}`),
372
- cloud.get("/qa/healing/statistics"),
1210
+ c.get(`/qa/healing/patterns?limit=${limit ?? 20}`),
1211
+ c.get("/qa/healing/statistics"),
373
1212
  ]);
374
1213
  const lines = [
375
1214
  `# Healing Statistics`,