@fasttest-ai/qa-agent 0.1.2 → 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/bin/qa-agent-ci.js +3 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud.d.ts +89 -6
- package/dist/cloud.js +61 -10
- package/dist/cloud.js.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/healer.d.ts +1 -1
- package/dist/healer.js +34 -54
- package/dist/healer.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +549 -51
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +1 -1
- package/dist/runner.js.map +1 -1
- package/package.json +5 -3
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
|
-
* -
|
|
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 = "
|
|
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
|
|
58
|
-
const
|
|
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:
|
|
61
|
-
headless:
|
|
225
|
+
browserType: cliArgs.browser,
|
|
226
|
+
headless: cliArgs.headless,
|
|
62
227
|
orgSlug,
|
|
63
228
|
});
|
|
64
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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("
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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("
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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:
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
372
|
-
|
|
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`,
|