@fasttest-ai/qa-agent 0.1.3 → 0.2.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
@@ -6,24 +6,189 @@
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
+ * - 16 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";
15
18
  import { BrowserManager } from "./browser.js";
16
19
  import { CloudClient } from "./cloud.js";
17
20
  import * as actions from "./actions.js";
18
21
  import { executeRun } from "./runner.js";
19
22
  import { healSelector } from "./healer.js";
23
+ import { loadGlobalConfig, saveGlobalConfig } from "./config.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Local-mode test prompt (used when no cloud is connected)
26
+ // ---------------------------------------------------------------------------
27
+ const LOCAL_TEST_PROMPT = `\
28
+ You are executing QA tests by driving a real browser via tools. The page \
29
+ snapshot above shows the current state of the page. Follow this methodology:
30
+
31
+ ## Execution loop (repeat for each test scenario)
32
+
33
+ 1. **Plan**: Read the page snapshot. Identify the elements you need to \
34
+ interact with. Pick the most stable selectors (data-testid > aria-label \
35
+ > role > text > CSS).
36
+ 2. **Act**: Execute steps using browser tools:
37
+ - browser_navigate — load a URL
38
+ - browser_click — click elements (use CSS selectors from the snapshot)
39
+ - browser_fill — type into inputs
40
+ - browser_press_key — keyboard actions (Enter, Tab, Escape)
41
+ - browser_select_option — select dropdown values
42
+ - browser_wait — wait for elements or a timeout
43
+ 3. **Verify**: After each significant action, use browser_assert to check \
44
+ the expected outcome. Available assertion types: element_visible, \
45
+ element_hidden, text_contains, text_equals, url_contains, url_equals, \
46
+ element_count, attribute_value.
47
+ 4. **Snapshot**: After actions that change the page (form submit, navigation, \
48
+ modal open), use browser_snapshot to get the updated page state before \
49
+ continuing.
50
+ 5. **Evidence**: Use browser_screenshot after key assertions to capture proof.
51
+
52
+ ## Error recovery
53
+
54
+ - If browser_click or browser_fill fails with "element not found", use the \
55
+ \`heal\` tool with the broken selector. It will try multiple strategies \
56
+ to find the element.
57
+ - If heal also fails, take a browser_snapshot and analyze the page state — \
58
+ the element may be behind a loading spinner, inside an iframe, or require \
59
+ scrolling.
60
+ - If an assertion fails, do NOT retry the same assertion. Report it as a \
61
+ failure — it may be a real bug.
62
+
63
+ ## What to test
64
+
65
+ Based on the test request above, cover these scenarios in order:
66
+ 1. **Happy path**: The primary flow as described. This is the most important.
67
+ 2. **Input validation**: If the flow has form fields, test empty submission \
68
+ and one invalid input format.
69
+ 3. **Error states**: If the flow involves API calls or actions that can fail, \
70
+ test one failure scenario.
71
+
72
+ Only test scenarios that are relevant to the request. Don't force edge cases \
73
+ that don't apply.
74
+
75
+ ## Output format
76
+
77
+ After testing, provide a clear summary:
78
+ - List each scenario tested with PASS or FAIL
79
+ - For failures, include what was expected vs. what happened
80
+ - If any selectors were healed during testing, note the original and new \
81
+ selectors
82
+
83
+ 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.`;
85
+ const LOCAL_EXPLORE_PROMPT = `\
86
+ You are autonomously exploring a web application to discover testable flows. \
87
+ The page snapshot and screenshot above show your starting point.
88
+
89
+ ## Exploration methodology
90
+
91
+ Use a breadth-first approach: survey the app's structure before diving deep.
92
+
93
+ ### Phase 1: Survey (explore broadly)
94
+ 1. Read the current page snapshot. Note every navigation link, button, and form.
95
+ 2. Click through the main navigation to discover all top-level pages.
96
+ 3. For each new page, use browser_snapshot to capture its structure.
97
+ 4. Keep a mental map of pages visited and their URLs — do NOT revisit pages \
98
+ you've already seen.
99
+
100
+ ### Phase 2: Catalog (go deeper on high-value pages)
101
+ For pages that have forms, CRUD operations, or multi-step flows:
102
+ 1. Identify the form fields and their types.
103
+ 2. Note any authentication requirements (login walls, role-based access).
104
+ 3. Look for state-changing actions (create, edit, delete).
105
+
106
+ ## Stopping criteria
107
+ - Stop after visiting the number of pages specified in "Max pages" above.
108
+ - Stop if you encounter a login wall and don't have credentials.
109
+ - Stop if you've visited all reachable pages from the main navigation.
110
+ - Do NOT explore: external links, social media, terms/privacy pages, \
111
+ documentation, or links that would leave the application domain.
112
+
113
+ ## Tools to use
114
+ - browser_click — navigate to pages, open menus, expand sections
115
+ - browser_navigate — go to a specific URL
116
+ - browser_snapshot — capture the accessibility tree of the current page
117
+ - browser_screenshot — capture visual evidence of interesting pages
118
+ - browser_go_back — return to the previous page
119
+
120
+ ## Output format
121
+
122
+ After exploring, present a structured summary:
123
+
124
+ **Pages discovered:**
125
+ | URL | Page type | Key elements |
126
+ |-----|-----------|--------------|
127
+
128
+ **Testable flows discovered:**
129
+ 1. Flow name — brief description (pages involved)
130
+ 2. Flow name — brief description (pages involved)
131
+
132
+ **Forms found:**
133
+ - Page URL: field names and types
134
+
135
+ Then ask: "Which flows would you like me to test? I can run them now with \
136
+ the \`test\` tool, or save them as a reusable suite with \`save_suite\`."`;
137
+ const LOCAL_HEAL_PROMPT = `\
138
+ A test step failed because a CSS selector no longer matches any element. \
139
+ Four automated repair strategies (data-testid matching, ARIA label matching, \
140
+ text content matching, structural matching) have already been tried and failed.
141
+
142
+ You are the last resort. Use your reasoning to diagnose and fix this.
143
+
144
+ ## Broken selector details
145
+ - Selector: {selector}
146
+ - Error: {error_message}
147
+ - Page URL: {page_url}
148
+
149
+ ## Diagnosis steps
150
+
151
+ 1. **Understand the intent**: What element was the selector trying to target? \
152
+ Parse the selector to determine: is it a button, input, link, container? \
153
+ What was its purpose in the test?
154
+
155
+ 2. **Search the snapshot**: The page snapshot is below. Look for elements \
156
+ that match the INTENT of the original selector, not its syntax. Search by:
157
+ - Role (button, textbox, link, heading)
158
+ - Label or visible text
159
+ - Position in the page structure (e.g., "the submit button in the login form")
160
+
161
+ 3. **Determine root cause**: Why did the selector break?
162
+ - **Renamed**: Element exists but with a different ID/class/attribute
163
+ - **Moved**: Element exists but in a different part of the DOM
164
+ - **Replaced**: Old element removed, new one added with different markup
165
+ - **Hidden**: Element exists but is not visible (behind a modal, in a \
166
+ collapsed section, requires scrolling)
167
+ - **Removed**: Element genuinely doesn't exist — this is a REAL BUG
168
+
169
+ 4. **Construct a new selector**: Build the most stable selector possible.
170
+ Priority: [data-testid] > [aria-label] > role-based > text-based > \
171
+ structural.
172
+
173
+ 5. **Verify**: Use browser_assert with type "element_visible" and your new \
174
+ selector. If it passes, report the fix. If it fails, try your next best \
175
+ candidate.
176
+
177
+ ## Important
178
+
179
+ - If the element genuinely doesn't exist on the page (not renamed, not \
180
+ moved, not hidden), report it as a REAL BUG. Say: "This appears to be a \
181
+ real bug — the [element description] is missing from the page."
182
+ - Do NOT suggest fragile selectors (nth-child, auto-generated CSS classes).
183
+ - Do NOT suggest more than 3 candidates — if none of them work after \
184
+ verification, the element is likely gone.`;
20
185
  // ---------------------------------------------------------------------------
21
186
  // CLI arg parsing
22
187
  // ---------------------------------------------------------------------------
23
188
  function parseArgs() {
24
189
  const args = process.argv.slice(2);
25
- let apiKey = "";
26
- let baseUrl = "http://localhost:9000";
190
+ let apiKey;
191
+ let baseUrl = "";
27
192
  let headless = true;
28
193
  let browserType = "chromium";
29
194
  for (let i = 0; i < args.length; i++) {
@@ -40,10 +205,6 @@ function parseArgs() {
40
205
  browserType = args[++i];
41
206
  }
42
207
  }
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
208
  return { apiKey, baseUrl, headless, browser: browserType };
48
209
  }
49
210
  // ---------------------------------------------------------------------------
@@ -52,16 +213,74 @@ function parseArgs() {
52
213
  const consoleLogs = [];
53
214
  const MAX_LOGS = 500;
54
215
  // ---------------------------------------------------------------------------
55
- // Boot
216
+ // Boot — resolve auth from CLI > config file > null (local-only mode)
56
217
  // ---------------------------------------------------------------------------
57
- const config = parseArgs();
58
- const orgSlug = config.apiKey.split("_")[1] ?? "default";
218
+ const cliArgs = parseArgs();
219
+ const globalCfg = loadGlobalConfig();
220
+ // Resolution: CLI --api-key wins, then config file, then undefined
221
+ const resolvedApiKey = cliArgs.apiKey || globalCfg.api_key || undefined;
222
+ const resolvedBaseUrl = cliArgs.baseUrl || globalCfg.base_url || "https://api.qa-agent.dev";
223
+ const orgSlug = resolvedApiKey ? (resolvedApiKey.split("_")[1] ?? "default") : "default";
59
224
  const browserMgr = new BrowserManager({
60
- browserType: config.browser,
61
- headless: config.headless,
225
+ browserType: cliArgs.browser,
226
+ headless: cliArgs.headless,
62
227
  orgSlug,
63
228
  });
64
- const cloud = new CloudClient({ apiKey: config.apiKey, baseUrl: config.baseUrl });
229
+ // cloud is null until auth is available (lazy initialization)
230
+ let cloud = resolvedApiKey
231
+ ? new CloudClient({ apiKey: resolvedApiKey, baseUrl: resolvedBaseUrl })
232
+ : null;
233
+ // ---------------------------------------------------------------------------
234
+ // Cloud guard — returns CloudClient or throws a user-friendly error
235
+ // ---------------------------------------------------------------------------
236
+ function requireCloud() {
237
+ if (!cloud) {
238
+ throw new Error("Not connected to QA Agent cloud. Run the `setup` tool first to create an organization.");
239
+ }
240
+ return cloud;
241
+ }
242
+ const FASTTEST_CONFIG = ".fasttest.json";
243
+ function configPath() {
244
+ return join(process.cwd(), FASTTEST_CONFIG);
245
+ }
246
+ function loadConfig() {
247
+ const p = configPath();
248
+ if (!existsSync(p))
249
+ return null;
250
+ try {
251
+ return JSON.parse(readFileSync(p, "utf-8"));
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ function saveConfig(cfg) {
258
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
259
+ }
260
+ /**
261
+ * Resolve a project_id. Priority:
262
+ * 1. .fasttest.json in cwd (cached from previous run)
263
+ * 2. Explicit project name from the LLM → resolve via cloud API
264
+ * 3. null (no project scoping)
265
+ */
266
+ async function resolveProjectId(projectName) {
267
+ // 1. Check .fasttest.json
268
+ const cached = loadConfig();
269
+ if (cached?.project_id)
270
+ return cached.project_id;
271
+ // 2. If LLM provided a project name, resolve it via cloud
272
+ if (projectName && cloud) {
273
+ try {
274
+ const resolved = await cloud.resolveProject(projectName);
275
+ saveConfig({ project_id: resolved.id, project_name: resolved.name });
276
+ return resolved.id;
277
+ }
278
+ catch (err) {
279
+ console.error(`Failed to resolve project "${projectName}": ${err}`);
280
+ }
281
+ }
282
+ return undefined;
283
+ }
65
284
  const server = new McpServer({
66
285
  name: "qa-agent",
67
286
  version: "0.1.0",
@@ -186,49 +405,273 @@ server.tool("browser_evaluate", "Execute JavaScript in the page context and retu
186
405
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
187
406
  });
188
407
  // ---------------------------------------------------------------------------
408
+ // Setup Tool — first-time onboarding
409
+ // ---------------------------------------------------------------------------
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 }) => {
415
+ if (cloud) {
416
+ return {
417
+ content: [{
418
+ 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.",
420
+ }],
421
+ };
422
+ }
423
+ const slug = org_slug ?? org_name
424
+ .toLowerCase()
425
+ .replace(/[^a-z0-9]+/g, "-")
426
+ .replace(/^-|-$/g, "");
427
+ const targetBaseUrl = base_url ?? resolvedBaseUrl;
428
+ 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 });
438
+ return {
439
+ content: [{
440
+ type: "text",
441
+ 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.`,
449
+ ].join("\n"),
450
+ }],
451
+ };
452
+ }
453
+ 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
+ return {
469
+ content: [{
470
+ type: "text",
471
+ text: `Failed to create organization: ${msg}`,
472
+ }],
473
+ };
474
+ }
475
+ });
476
+ // ---------------------------------------------------------------------------
189
477
  // Cloud-forwarding Tools
190
478
  // ---------------------------------------------------------------------------
191
479
  server.tool("test", "Start a conversational test session. Describe what you want to test.", {
192
480
  description: z.string().describe("What to test (natural language)"),
193
481
  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) }] };
482
+ project: z.string().optional().describe("Project name (e.g. 'My SaaS App'). Auto-saved to .fasttest.json for future runs."),
483
+ }, async ({ description, url, project }) => {
484
+ // Always use local mode: host AI drives browser tools directly.
485
+ // Cloud LLM is never used from the MCP server — the host AI (Claude Code,
486
+ // Codex, etc.) follows our prompt with its own reasoning capability.
487
+ const lines = [];
488
+ if (url) {
489
+ const page = await browserMgr.ensureBrowser();
490
+ attachConsoleListener(page);
491
+ await actions.navigate(page, url);
492
+ const snapshot = await actions.getSnapshot(page);
493
+ lines.push("## Page Snapshot");
494
+ lines.push("```json");
495
+ lines.push(JSON.stringify(snapshot, null, 2));
496
+ lines.push("```");
497
+ lines.push("");
498
+ }
499
+ lines.push("## Test Request");
500
+ lines.push(description);
501
+ lines.push("");
502
+ lines.push("## Instructions");
503
+ lines.push(LOCAL_TEST_PROMPT);
504
+ if (!cloud) {
505
+ lines.push("");
506
+ lines.push("---");
507
+ lines.push("*Running in local-only mode. Run the `setup` tool to enable persistent test suites, CI/CD, and the dashboard.*");
508
+ }
509
+ return { content: [{ type: "text", text: lines.join("\n") }] };
197
510
  });
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) }] };
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.", {
512
+ suite_name: z.string().describe("Name for the test suite (e.g. 'Login Flow', 'Checkout Tests')"),
513
+ description: z.string().optional().describe("What this suite tests"),
514
+ project: z.string().optional().describe("Project name (auto-resolved or created)"),
515
+ test_cases: z.array(z.object({
516
+ name: z.string().describe("Test case name"),
517
+ description: z.string().optional().describe("What this test verifies"),
518
+ 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?}]"),
520
+ assertions: z.array(z.record(z.string(), z.unknown())).describe("Assertions: [{type, selector?, text?, url?, count?}]"),
521
+ tags: z.array(z.string()).optional().describe("Tags for categorization"),
522
+ })).describe("Array of test cases to save"),
523
+ }, async ({ suite_name, description, project, test_cases }) => {
524
+ const c = requireCloud();
525
+ // Resolve project
526
+ const projectId = await resolveProjectId(project);
527
+ let finalProjectId = projectId;
528
+ if (!finalProjectId) {
529
+ const resolved = await c.resolveProject(project ?? "Default");
530
+ finalProjectId = resolved.id;
531
+ saveConfig({ project_id: resolved.id, project_name: resolved.name });
532
+ }
533
+ // Create suite
534
+ const suite = await c.createSuite(finalProjectId, {
535
+ name: suite_name,
536
+ description,
537
+ auto_generated: true,
538
+ test_type: "functional",
539
+ });
540
+ // Create test cases linked to the suite
541
+ const savedCases = [];
542
+ for (const tc of test_cases) {
543
+ const created = await c.createTestCase({
544
+ name: tc.name,
545
+ description: tc.description,
546
+ priority: tc.priority ?? "medium",
547
+ steps: tc.steps,
548
+ assertions: tc.assertions,
549
+ tags: tc.tags ?? [],
550
+ test_suite_ids: [suite.id],
551
+ auto_generated: true,
552
+ generated_by_agent: true,
553
+ natural_language_source: suite_name,
554
+ });
555
+ savedCases.push(` - ${created.name} (${created.id})`);
556
+ }
557
+ 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
+ }],
571
+ };
204
572
  });
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) }] };
573
+ server.tool("update_suite", "Update test cases in an existing suite. Use this when the app has changed and tests need updating.", {
574
+ suite_id: z.string().optional().describe("Suite ID to update (provide this OR suite_name)"),
575
+ suite_name: z.string().optional().describe("Suite name to update (resolved automatically)"),
576
+ test_cases: z.array(z.object({
577
+ id: z.string().optional().describe("Existing test case ID to update (omit to add a new case)"),
578
+ name: z.string().describe("Test case name"),
579
+ description: z.string().optional(),
580
+ priority: z.enum(["high", "medium", "low"]).optional(),
581
+ steps: z.array(z.record(z.string(), z.unknown())).describe("Updated test steps"),
582
+ assertions: z.array(z.record(z.string(), z.unknown())).describe("Updated assertions"),
583
+ tags: z.array(z.string()).optional(),
584
+ })).describe("Test cases to update or add"),
585
+ }, async ({ suite_id, suite_name, test_cases }) => {
586
+ const c = requireCloud();
587
+ // Resolve suite ID
588
+ let resolvedSuiteId = suite_id;
589
+ if (!resolvedSuiteId && suite_name) {
590
+ const resolved = await c.resolveSuite(suite_name);
591
+ resolvedSuiteId = resolved.id;
592
+ }
593
+ if (!resolvedSuiteId) {
594
+ return {
595
+ content: [{ type: "text", text: "Either suite_id or suite_name is required." }],
596
+ };
597
+ }
598
+ const updated = [];
599
+ const created = [];
600
+ for (const tc of test_cases) {
601
+ if (tc.id) {
602
+ // Update existing test case
603
+ const result = await c.updateTestCase(tc.id, {
604
+ name: tc.name,
605
+ description: tc.description,
606
+ priority: tc.priority,
607
+ steps: tc.steps,
608
+ assertions: tc.assertions,
609
+ tags: tc.tags,
610
+ });
611
+ updated.push(` - ${result.name} (${result.id})`);
612
+ }
613
+ else {
614
+ // Create new test case and link to suite
615
+ const result = await c.createTestCase({
616
+ name: tc.name,
617
+ description: tc.description,
618
+ priority: tc.priority ?? "medium",
619
+ steps: tc.steps,
620
+ assertions: tc.assertions,
621
+ tags: tc.tags ?? [],
622
+ test_suite_ids: [resolvedSuiteId],
623
+ auto_generated: true,
624
+ generated_by_agent: true,
625
+ });
626
+ created.push(` - ${result.name} (${result.id})`);
627
+ }
628
+ }
629
+ const lines = [`Suite "${resolvedSuiteId}" updated.`];
630
+ if (updated.length > 0) {
631
+ lines.push(`Updated (${updated.length}):`);
632
+ lines.push(...updated);
633
+ }
634
+ if (created.length > 0) {
635
+ lines.push(`Added (${created.length}):`);
636
+ lines.push(...created);
637
+ }
638
+ return {
639
+ content: [{ type: "text", text: lines.join("\n") }],
640
+ };
211
641
  });
212
642
  server.tool("explore", "Autonomously explore a web application and discover testable flows", {
213
643
  url: z.string().describe("Starting URL"),
214
644
  max_pages: z.number().optional().describe("Max pages to explore (default 20)"),
215
645
  focus: z.enum(["forms", "navigation", "errors", "all"]).optional().describe("Exploration focus"),
216
646
  }, async ({ url, max_pages, focus }) => {
217
- // Step 1: Navigate locally to get initial snapshot
647
+ // Always local-first: navigate, snapshot, return prompt for host AI
218
648
  const page = await browserMgr.ensureBrowser();
219
649
  attachConsoleListener(page);
220
650
  await actions.navigate(page, url);
221
651
  const snapshot = await actions.getSnapshot(page);
222
652
  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
- });
653
+ const lines = [
654
+ "## Page Snapshot",
655
+ "```json",
656
+ JSON.stringify(snapshot, null, 2),
657
+ "```",
658
+ "",
659
+ "## Exploration Request",
660
+ `URL: ${url}`,
661
+ `Focus: ${focus ?? "all"}`,
662
+ `Max pages: ${max_pages ?? 20}`,
663
+ "",
664
+ "## Instructions",
665
+ LOCAL_EXPLORE_PROMPT,
666
+ ];
667
+ if (!cloud) {
668
+ lines.push("");
669
+ lines.push("---");
670
+ lines.push("*Running in local-only mode. Run the `setup` tool to enable persistent test suites and CI/CD.*");
671
+ }
229
672
  return {
230
673
  content: [
231
- { type: "text", text: JSON.stringify({ ...result, initial_snapshot: snapshot }, null, 2) },
674
+ { type: "text", text: lines.join("\n") },
232
675
  { type: "image", data: screenshotB64, mimeType: "image/jpeg" },
233
676
  ],
234
677
  };
@@ -237,12 +680,31 @@ server.tool("explore", "Autonomously explore a web application and discover test
237
680
  // Execution Tools (Phase 3)
238
681
  // ---------------------------------------------------------------------------
239
682
  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"),
683
+ suite_id: z.string().optional().describe("Test suite ID to run (provide this OR suite_name)"),
684
+ suite_name: z.string().optional().describe("Test suite name to run (resolved to ID automatically). Example: 'checkout flow'"),
241
685
  test_case_ids: z.array(z.string()).optional().describe("Specific test case IDs to run (default: all in suite)"),
242
686
  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,
687
+ }, async ({ suite_id, suite_name, test_case_ids, pr_url }) => {
688
+ // Resolve suite_id from suite_name if needed
689
+ let resolvedSuiteId = suite_id;
690
+ if (!resolvedSuiteId && suite_name) {
691
+ try {
692
+ const resolved = await requireCloud().resolveSuite(suite_name);
693
+ resolvedSuiteId = resolved.id;
694
+ }
695
+ catch {
696
+ return {
697
+ content: [{ type: "text", text: `Could not find a suite matching "${suite_name}". Use \`list_suites\` to see available suites.` }],
698
+ };
699
+ }
700
+ }
701
+ if (!resolvedSuiteId) {
702
+ return {
703
+ content: [{ type: "text", text: "Either suite_id or suite_name is required. Use `list_suites` to find available suites." }],
704
+ };
705
+ }
706
+ const summary = await executeRun(browserMgr, requireCloud(), {
707
+ suiteId: resolvedSuiteId,
246
708
  testCaseIds: test_case_ids,
247
709
  }, consoleLogs);
248
710
  // Format a human-readable summary
@@ -273,7 +735,7 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
273
735
  // Post PR comment if pr_url was provided
274
736
  if (pr_url) {
275
737
  try {
276
- const prResult = await cloud.postPrComment({
738
+ const prResult = await requireCloud().postPrComment({
277
739
  pr_url,
278
740
  execution_id: summary.execution_id,
279
741
  status: summary.status,
@@ -310,27 +772,45 @@ server.tool("run", "Run a test suite. Executes all test cases in a real browser
310
772
  server.tool("github_token", "Set the GitHub personal access token for PR integration", {
311
773
  token: z.string().describe("GitHub personal access token (PAT) with repo scope"),
312
774
  }, async ({ token }) => {
313
- await cloud.setGithubToken(token);
775
+ await requireCloud().setGithubToken(token);
314
776
  return { content: [{ type: "text", text: "GitHub token stored securely." }] };
315
777
  });
316
778
  server.tool("status", "Check the status of a test execution", {
317
779
  execution_id: z.string().describe("Execution ID to check"),
318
780
  }, async ({ execution_id }) => {
319
- const result = await cloud.getExecutionStatus(execution_id);
781
+ const result = await requireCloud().getExecutionStatus(execution_id);
320
782
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
321
783
  });
322
784
  server.tool("cancel", "Cancel a running test execution", {
323
785
  execution_id: z.string().describe("Execution ID to cancel"),
324
786
  }, async ({ execution_id }) => {
325
- const result = await cloud.cancelExecution(execution_id);
787
+ const result = await requireCloud().cancelExecution(execution_id);
326
788
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
327
789
  });
328
790
  server.tool("list_projects", "List all QA projects in the organization", {}, async () => {
329
- const result = await cloud.listProjects();
791
+ const result = await requireCloud().listProjects();
330
792
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
331
793
  });
794
+ server.tool("list_suites", "List test suites across all projects. Use this to find suite IDs for the `run` tool.", {
795
+ search: z.string().optional().describe("Filter suites by name (e.g. 'checkout')"),
796
+ }, async ({ search }) => {
797
+ const suites = await requireCloud().listSuites(search);
798
+ if (!Array.isArray(suites) || suites.length === 0) {
799
+ return { content: [{ type: "text", text: "No test suites found." }] };
800
+ }
801
+ const lines = ["# Test Suites", ""];
802
+ for (const s of suites) {
803
+ lines.push(`- **${s.name}**`);
804
+ lines.push(` ID: \`${s.id}\``);
805
+ lines.push(` Project: ${s.project_name} | Type: ${s.test_type}`);
806
+ if (s.description)
807
+ lines.push(` ${s.description}`);
808
+ lines.push("");
809
+ }
810
+ return { content: [{ type: "text", text: lines.join("\n") }] };
811
+ });
332
812
  server.tool("health", "Check if the QA agent backend is reachable", {}, async () => {
333
- const result = await cloud.health();
813
+ const result = await requireCloud().health();
334
814
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
335
815
  });
336
816
  // ---------------------------------------------------------------------------
@@ -343,13 +823,14 @@ server.tool("heal", "Attempt to heal a broken selector by trying alternative loc
343
823
  }, async ({ selector, page_url, error_message }) => {
344
824
  const page = await browserMgr.getPage();
345
825
  const url = page_url ?? page.url();
826
+ // Try local deterministic strategies first (no cloud needed)
346
827
  const result = await healSelector(page, cloud, selector, "ELEMENT_NOT_FOUND", error_message ?? "Element not found", url);
347
828
  if (result.healed) {
348
829
  return {
349
830
  content: [{
350
831
  type: "text",
351
832
  text: [
352
- `🔧 Selector healed!`,
833
+ `Selector healed!`,
353
834
  ` Original: ${selector}`,
354
835
  ` New: ${result.newSelector}`,
355
836
  ` Strategy: ${result.strategy} (${Math.round((result.confidence ?? 0) * 100)}% confidence)`,
@@ -357,19 +838,36 @@ server.tool("heal", "Attempt to heal a broken selector by trying alternative loc
357
838
  }],
358
839
  };
359
840
  }
841
+ // Local strategies exhausted — return snapshot + prompt for host AI to reason
842
+ const snapshot = await actions.getSnapshot(page);
843
+ const healPrompt = LOCAL_HEAL_PROMPT
844
+ .replace("{selector}", selector)
845
+ .replace("{error_message}", error_message ?? "Element not found")
846
+ .replace("{page_url}", url);
360
847
  return {
361
848
  content: [{
362
849
  type: "text",
363
- text: `❌ Could not heal selector: ${selector}\n ${result.error ?? "All strategies exhausted"}`,
850
+ text: [
851
+ `Local healing strategies could not fix: ${selector}`,
852
+ "",
853
+ "## Page Snapshot",
854
+ "```json",
855
+ JSON.stringify(snapshot, null, 2),
856
+ "```",
857
+ "",
858
+ "## Instructions",
859
+ healPrompt,
860
+ ].join("\n"),
364
861
  }],
365
862
  };
366
863
  });
367
864
  server.tool("healing_history", "View healing patterns and statistics for the organization", {
368
865
  limit: z.number().optional().describe("Max patterns to return (default 20)"),
369
866
  }, async ({ limit }) => {
867
+ const c = requireCloud();
370
868
  const [patterns, stats] = await Promise.all([
371
- cloud.get(`/qa/healing/patterns?limit=${limit ?? 20}`),
372
- cloud.get("/qa/healing/statistics"),
869
+ c.get(`/qa/healing/patterns?limit=${limit ?? 20}`),
870
+ c.get("/qa/healing/statistics"),
373
871
  ]);
374
872
  const lines = [
375
873
  `# Healing Statistics`,