@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/bin/qa-agent-ci.js +3 -0
- package/dist/actions.d.ts +3 -0
- package/dist/actions.js +38 -4
- package/dist/actions.js.map +1 -1
- package/dist/browser.d.ts +30 -0
- package/dist/browser.js +120 -6
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud.d.ts +134 -6
- package/dist/cloud.js +150 -21
- package/dist/cloud.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +49 -0
- package/dist/config.js.map +1 -0
- package/dist/healer.d.ts +1 -1
- package/dist/healer.js +56 -68
- package/dist/healer.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +893 -54
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts +3 -0
- package/dist/runner.js +245 -19
- package/dist/runner.js.map +1 -1
- package/dist/variables.d.ts +30 -0
- package/dist/variables.js +104 -0
- package/dist/variables.js.map +1 -0
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -1,29 +1,287 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* FastTest Agent — MCP server (stdio transport).
|
|
4
4
|
*
|
|
5
5
|
* This is the ONLY MCP server in the architecture.
|
|
6
6
|
* Flow: Claude Code → MCP → Local Skill → HTTPS → Cloud API
|
|
7
7
|
*
|
|
8
8
|
* Exposes:
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
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 = "
|
|
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
|
|
58
|
-
const
|
|
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:
|
|
61
|
-
headless:
|
|
318
|
+
browserType: cliArgs.browser,
|
|
319
|
+
headless: cliArgs.headless,
|
|
62
320
|
orgSlug,
|
|
63
321
|
});
|
|
64
|
-
|
|
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: "
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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("
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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("
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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:
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1132
|
+
const result = await requireCloud().listProjects();
|
|
330
1133
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
331
1134
|
});
|
|
332
|
-
server.tool("
|
|
333
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
372
|
-
|
|
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`,
|