@fasttest-ai/qa-agent 0.1.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.js +3 -0
- package/dist/actions.d.ts +35 -0
- package/dist/actions.js +145 -0
- package/dist/actions.js.map +1 -0
- package/dist/browser.d.ts +26 -0
- package/dist/browser.js +116 -0
- package/dist/browser.js.map +1 -0
- package/dist/cloud.d.ts +119 -0
- package/dist/cloud.js +93 -0
- package/dist/cloud.js.map +1 -0
- package/dist/healer.d.ts +27 -0
- package/dist/healer.js +258 -0
- package/dist/healer.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +394 -0
- package/dist/index.js.map +1 -0
- package/dist/runner.d.ts +64 -0
- package/dist/runner.js +293 -0
- package/dist/runner.js.map +1 -0
- package/package.json +31 -0
package/dist/healer.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing module — locator repair cascade.
|
|
3
|
+
*
|
|
4
|
+
* When a test step fails with a selector error, the healer tries:
|
|
5
|
+
* 1. data-testid (confidence: 98%)
|
|
6
|
+
* 2. ARIA labels (confidence: 95%)
|
|
7
|
+
* 3. Text content (confidence: 90%)
|
|
8
|
+
* 4. Structural (confidence: 85%)
|
|
9
|
+
* 5. AI-generated (confidence: 75%) — via Cloud LLM call
|
|
10
|
+
*
|
|
11
|
+
* After a successful heal the pattern is stored in the Cloud DB so
|
|
12
|
+
* future failures with the same signature are fixed instantly.
|
|
13
|
+
*/
|
|
14
|
+
import * as actions from "./actions.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Strategy confidence map
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const CONFIDENCE = {
|
|
19
|
+
data_testid: 0.98,
|
|
20
|
+
aria: 0.95,
|
|
21
|
+
text: 0.90,
|
|
22
|
+
structural: 0.85,
|
|
23
|
+
ai: 0.75,
|
|
24
|
+
};
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Main entry point
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Attempt to heal a broken selector by trying 5 strategies in order.
|
|
30
|
+
* Returns the first working selector with its confidence score.
|
|
31
|
+
*/
|
|
32
|
+
export async function healSelector(page, cloud, originalSelector, failureType, errorMessage, pageUrl) {
|
|
33
|
+
// 1. Check Cloud for a stored pattern first
|
|
34
|
+
try {
|
|
35
|
+
const classification = await cloud.post("/qa/healing/classify", {
|
|
36
|
+
failure_type: failureType,
|
|
37
|
+
selector: originalSelector,
|
|
38
|
+
page_url: pageUrl,
|
|
39
|
+
error_message: errorMessage,
|
|
40
|
+
});
|
|
41
|
+
if (classification.is_real_bug) {
|
|
42
|
+
return { healed: false, error: classification.reason ?? "Classified as real bug" };
|
|
43
|
+
}
|
|
44
|
+
if (classification.pattern) {
|
|
45
|
+
// Validate the stored pattern still works
|
|
46
|
+
const found = await testSelector(page, classification.pattern.healed_value);
|
|
47
|
+
if (found) {
|
|
48
|
+
return {
|
|
49
|
+
healed: true,
|
|
50
|
+
newSelector: classification.pattern.healed_value,
|
|
51
|
+
strategy: classification.pattern.strategy,
|
|
52
|
+
confidence: classification.pattern.confidence,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Non-fatal — continue with local strategies
|
|
59
|
+
}
|
|
60
|
+
// 2. Try local repair strategies in order
|
|
61
|
+
const strategies = [
|
|
62
|
+
{ name: "data_testid", fn: () => tryDataTestId(page, originalSelector) },
|
|
63
|
+
{ name: "aria", fn: () => tryAria(page, originalSelector) },
|
|
64
|
+
{ name: "text", fn: () => tryTextContent(page, originalSelector) },
|
|
65
|
+
{ name: "structural", fn: () => tryStructural(page, originalSelector) },
|
|
66
|
+
];
|
|
67
|
+
for (const strategy of strategies) {
|
|
68
|
+
const candidate = await strategy.fn();
|
|
69
|
+
if (candidate) {
|
|
70
|
+
// Report the pattern to Cloud for future reuse
|
|
71
|
+
await storePatternQuietly(cloud, failureType, originalSelector, candidate, strategy.name, CONFIDENCE[strategy.name] ?? 0.8, pageUrl);
|
|
72
|
+
return {
|
|
73
|
+
healed: true,
|
|
74
|
+
newSelector: candidate,
|
|
75
|
+
strategy: strategy.name,
|
|
76
|
+
confidence: CONFIDENCE[strategy.name],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 3. AI-generated — ask the Cloud LLM
|
|
81
|
+
try {
|
|
82
|
+
const snapshot = await actions.getSnapshot(page);
|
|
83
|
+
const suggestions = await cloud.post("/qa/healing/ai-suggest", {
|
|
84
|
+
original_selector: originalSelector,
|
|
85
|
+
page_snapshot: JSON.stringify(snapshot),
|
|
86
|
+
error_message: errorMessage,
|
|
87
|
+
page_url: pageUrl,
|
|
88
|
+
});
|
|
89
|
+
for (const suggestion of suggestions.suggestions ?? []) {
|
|
90
|
+
const found = await testSelector(page, suggestion.selector);
|
|
91
|
+
if (found) {
|
|
92
|
+
await storePatternQuietly(cloud, failureType, originalSelector, suggestion.selector, "ai", suggestion.confidence, pageUrl);
|
|
93
|
+
return {
|
|
94
|
+
healed: true,
|
|
95
|
+
newSelector: suggestion.selector,
|
|
96
|
+
strategy: "ai",
|
|
97
|
+
confidence: suggestion.confidence,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Non-fatal
|
|
104
|
+
}
|
|
105
|
+
return { healed: false, error: "All healing strategies exhausted" };
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Strategy implementations
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
async function testSelector(page, selector) {
|
|
111
|
+
try {
|
|
112
|
+
const el = await page.$(selector);
|
|
113
|
+
return el !== null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Strategy 1: Look for a data-testid attribute near the original selector.
|
|
121
|
+
*/
|
|
122
|
+
async function tryDataTestId(page, original) {
|
|
123
|
+
try {
|
|
124
|
+
// Extract a meaningful name from the original selector
|
|
125
|
+
const name = extractName(original);
|
|
126
|
+
if (!name)
|
|
127
|
+
return null;
|
|
128
|
+
const candidates = [
|
|
129
|
+
`[data-testid="${name}"]`,
|
|
130
|
+
`[data-testid*="${name}"]`,
|
|
131
|
+
`[data-test="${name}"]`,
|
|
132
|
+
`[data-test-id="${name}"]`,
|
|
133
|
+
];
|
|
134
|
+
for (const sel of candidates) {
|
|
135
|
+
if (await testSelector(page, sel))
|
|
136
|
+
return sel;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Strategy 2: Look for ARIA labels matching the original element.
|
|
146
|
+
*/
|
|
147
|
+
async function tryAria(page, original) {
|
|
148
|
+
try {
|
|
149
|
+
const name = extractName(original);
|
|
150
|
+
if (!name)
|
|
151
|
+
return null;
|
|
152
|
+
const candidates = [
|
|
153
|
+
`[aria-label="${name}"]`,
|
|
154
|
+
`[aria-label*="${name}"]`,
|
|
155
|
+
`[role][aria-label*="${name}"]`,
|
|
156
|
+
];
|
|
157
|
+
for (const sel of candidates) {
|
|
158
|
+
if (await testSelector(page, sel))
|
|
159
|
+
return sel;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Strategy 3: Use text content to find the element.
|
|
169
|
+
*/
|
|
170
|
+
async function tryTextContent(page, original) {
|
|
171
|
+
try {
|
|
172
|
+
const name = extractName(original);
|
|
173
|
+
if (!name)
|
|
174
|
+
return null;
|
|
175
|
+
// Try Playwright's text-based selectors
|
|
176
|
+
const candidates = [
|
|
177
|
+
`text="${name}"`,
|
|
178
|
+
`:has-text("${name}")`,
|
|
179
|
+
`button:has-text("${name}")`,
|
|
180
|
+
`a:has-text("${name}")`,
|
|
181
|
+
];
|
|
182
|
+
for (const sel of candidates) {
|
|
183
|
+
if (await testSelector(page, sel))
|
|
184
|
+
return sel;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Strategy 4: Use structural/parent-child relationships.
|
|
194
|
+
*/
|
|
195
|
+
async function tryStructural(page, original) {
|
|
196
|
+
try {
|
|
197
|
+
// Try tag-based selectors derived from original
|
|
198
|
+
const tagMatch = original.match(/^([a-z]+)/i);
|
|
199
|
+
const tag = tagMatch?.[1] ?? "";
|
|
200
|
+
const name = extractName(original);
|
|
201
|
+
if (!tag && !name)
|
|
202
|
+
return null;
|
|
203
|
+
const candidates = [];
|
|
204
|
+
if (tag && name) {
|
|
205
|
+
candidates.push(`${tag}[name="${name}"]`);
|
|
206
|
+
candidates.push(`${tag}[id*="${name}"]`);
|
|
207
|
+
candidates.push(`${tag}[class*="${name}"]`);
|
|
208
|
+
candidates.push(`form ${tag}[type="submit"]`);
|
|
209
|
+
}
|
|
210
|
+
else if (tag) {
|
|
211
|
+
candidates.push(`form >> ${tag}[type="submit"]`);
|
|
212
|
+
}
|
|
213
|
+
for (const sel of candidates) {
|
|
214
|
+
if (await testSelector(page, sel))
|
|
215
|
+
return sel;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Helpers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* Extract a meaningful name from a CSS selector.
|
|
228
|
+
* e.g. "#login-btn" → "login-btn", ".submit-button" → "submit-button"
|
|
229
|
+
*/
|
|
230
|
+
function extractName(selector) {
|
|
231
|
+
// Strip leading # or .
|
|
232
|
+
let name = selector.replace(/^[#.]/, "");
|
|
233
|
+
// Strip attribute selectors
|
|
234
|
+
name = name.replace(/\[.*\]/, "");
|
|
235
|
+
// Strip pseudo selectors
|
|
236
|
+
name = name.replace(/:.*/, "");
|
|
237
|
+
// Strip combinators
|
|
238
|
+
name = name.split(/\s+/)[0] ?? "";
|
|
239
|
+
// Strip tag names followed by class/id
|
|
240
|
+
name = name.replace(/^[a-z]+[#.]/, "");
|
|
241
|
+
return name || null;
|
|
242
|
+
}
|
|
243
|
+
async function storePatternQuietly(cloud, failureType, originalValue, healedValue, strategy, confidence, pageUrl) {
|
|
244
|
+
try {
|
|
245
|
+
await cloud.post("/qa/healing/patterns", {
|
|
246
|
+
failure_type: failureType,
|
|
247
|
+
original_value: originalValue,
|
|
248
|
+
healed_value: healedValue,
|
|
249
|
+
strategy,
|
|
250
|
+
confidence,
|
|
251
|
+
page_url: pageUrl,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Non-fatal — pattern storage failure shouldn't block test execution
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=healer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"healer.js","sourceRoot":"","sources":["../src/healer.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* QA Agent — MCP server (stdio transport).
|
|
4
|
+
*
|
|
5
|
+
* This is the ONLY MCP server in the architecture.
|
|
6
|
+
* Flow: Claude Code → MCP → Local Skill → HTTPS → Cloud API
|
|
7
|
+
*
|
|
8
|
+
* Exposes:
|
|
9
|
+
* - 10 browser tools (Playwright, runs locally)
|
|
10
|
+
* - Cloud-forwarding tools (test, answer, approve, explore, run, status, cancel, etc.)
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* QA Agent — MCP server (stdio transport).
|
|
4
|
+
*
|
|
5
|
+
* This is the ONLY MCP server in the architecture.
|
|
6
|
+
* Flow: Claude Code → MCP → Local Skill → HTTPS → Cloud API
|
|
7
|
+
*
|
|
8
|
+
* Exposes:
|
|
9
|
+
* - 10 browser tools (Playwright, runs locally)
|
|
10
|
+
* - Cloud-forwarding tools (test, answer, approve, explore, run, status, cancel, etc.)
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { BrowserManager } from "./browser.js";
|
|
16
|
+
import { CloudClient } from "./cloud.js";
|
|
17
|
+
import * as actions from "./actions.js";
|
|
18
|
+
import { executeRun } from "./runner.js";
|
|
19
|
+
import { healSelector } from "./healer.js";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// CLI arg parsing
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function parseArgs() {
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
let apiKey = "";
|
|
26
|
+
let baseUrl = "http://localhost:9000";
|
|
27
|
+
let headless = true;
|
|
28
|
+
let browserType = "chromium";
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
if (args[i] === "--api-key" && args[i + 1]) {
|
|
31
|
+
apiKey = args[++i];
|
|
32
|
+
}
|
|
33
|
+
else if (args[i] === "--base-url" && args[i + 1]) {
|
|
34
|
+
baseUrl = args[++i];
|
|
35
|
+
}
|
|
36
|
+
else if (args[i] === "--headed") {
|
|
37
|
+
headless = false;
|
|
38
|
+
}
|
|
39
|
+
else if (args[i] === "--browser" && args[i + 1]) {
|
|
40
|
+
browserType = args[++i];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
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
|
+
return { apiKey, baseUrl, headless, browser: browserType };
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Console log collector
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
const consoleLogs = [];
|
|
53
|
+
const MAX_LOGS = 500;
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Boot
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const config = parseArgs();
|
|
58
|
+
const orgSlug = config.apiKey.split("_")[1] ?? "default";
|
|
59
|
+
const browserMgr = new BrowserManager({
|
|
60
|
+
browserType: config.browser,
|
|
61
|
+
headless: config.headless,
|
|
62
|
+
orgSlug,
|
|
63
|
+
});
|
|
64
|
+
const cloud = new CloudClient({ apiKey: config.apiKey, baseUrl: config.baseUrl });
|
|
65
|
+
const server = new McpServer({
|
|
66
|
+
name: "qa-agent",
|
|
67
|
+
version: "0.1.0",
|
|
68
|
+
});
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Browser Tools (local Playwright)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
server.tool("browser_navigate", "Navigate to a URL in the browser", { url: z.string().describe("URL to navigate to") }, async ({ url }) => {
|
|
73
|
+
const page = await browserMgr.ensureBrowser();
|
|
74
|
+
attachConsoleListener(page);
|
|
75
|
+
const result = await actions.navigate(page, url);
|
|
76
|
+
const snapshot = await actions.getSnapshot(page);
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: JSON.stringify({ ...result, snapshot }, null, 2) }],
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
server.tool("browser_click", "Click an element on the page", { selector: z.string().describe("CSS selector of the element to click") }, async ({ selector }) => {
|
|
82
|
+
const page = await browserMgr.getPage();
|
|
83
|
+
const result = await actions.click(page, selector);
|
|
84
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
85
|
+
});
|
|
86
|
+
server.tool("browser_fill", "Fill a form field with a value", {
|
|
87
|
+
selector: z.string().describe("CSS selector of the input"),
|
|
88
|
+
value: z.string().describe("Value to type"),
|
|
89
|
+
}, async ({ selector, value }) => {
|
|
90
|
+
const page = await browserMgr.getPage();
|
|
91
|
+
const result = await actions.fill(page, selector, value);
|
|
92
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
93
|
+
});
|
|
94
|
+
server.tool("browser_screenshot", "Capture a screenshot of the current page", { full_page: z.boolean().optional().describe("Capture full page (default false)") }, async ({ full_page }) => {
|
|
95
|
+
const page = await browserMgr.getPage();
|
|
96
|
+
const b64 = await actions.screenshot(page, full_page ?? false);
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "image", data: b64, mimeType: "image/jpeg" }],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
server.tool("browser_snapshot", "Get the accessibility tree of the current page", {}, async () => {
|
|
102
|
+
const page = await browserMgr.getPage();
|
|
103
|
+
const snapshot = await actions.getSnapshot(page);
|
|
104
|
+
return { content: [{ type: "text", text: JSON.stringify(snapshot, null, 2) }] };
|
|
105
|
+
});
|
|
106
|
+
server.tool("browser_assert", "Run an assertion against the live page", {
|
|
107
|
+
type: z.enum([
|
|
108
|
+
"element_visible", "element_hidden", "text_contains", "text_equals",
|
|
109
|
+
"url_contains", "url_equals", "element_count", "attribute_value",
|
|
110
|
+
]).describe("Assertion type"),
|
|
111
|
+
selector: z.string().optional().describe("CSS selector (for element assertions)"),
|
|
112
|
+
text: z.string().optional().describe("Expected text"),
|
|
113
|
+
url: z.string().optional().describe("Expected URL"),
|
|
114
|
+
count: z.number().optional().describe("Expected element count"),
|
|
115
|
+
attribute: z.string().optional().describe("Attribute name"),
|
|
116
|
+
value: z.string().optional().describe("Expected attribute value"),
|
|
117
|
+
}, async (params) => {
|
|
118
|
+
const page = await browserMgr.getPage();
|
|
119
|
+
const result = await actions.assertPage(page, params);
|
|
120
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
121
|
+
});
|
|
122
|
+
server.tool("browser_wait", "Wait for an element to appear or a timeout", {
|
|
123
|
+
selector: z.string().optional().describe("CSS selector to wait for"),
|
|
124
|
+
timeout_ms: z.number().optional().describe("Timeout in milliseconds (default 10000)"),
|
|
125
|
+
}, async ({ selector, timeout_ms }) => {
|
|
126
|
+
const page = await browserMgr.getPage();
|
|
127
|
+
if (selector) {
|
|
128
|
+
const result = await actions.waitFor(page, selector, timeout_ms ?? 10_000);
|
|
129
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
130
|
+
}
|
|
131
|
+
await new Promise((r) => setTimeout(r, timeout_ms ?? 1000));
|
|
132
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true }) }] };
|
|
133
|
+
});
|
|
134
|
+
server.tool("browser_console_logs", "Get captured console log messages from the page", {}, async () => {
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: "text", text: JSON.stringify(consoleLogs.slice(-100)) }],
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
server.tool("browser_save_session", "Save the current browser session (cookies, localStorage) for reuse", { name: z.string().describe("Session name (e.g. 'admin', 'user')") }, async ({ name }) => {
|
|
140
|
+
const filePath = await browserMgr.saveSession(name);
|
|
141
|
+
return { content: [{ type: "text", text: `Session saved: ${filePath}` }] };
|
|
142
|
+
});
|
|
143
|
+
server.tool("browser_restore_session", "Restore a previously saved browser session", { name: z.string().describe("Session name to restore") }, async ({ name }) => {
|
|
144
|
+
const page = await browserMgr.restoreSession(name);
|
|
145
|
+
attachConsoleListener(page);
|
|
146
|
+
return { content: [{ type: "text", text: `Session "${name}" restored` }] };
|
|
147
|
+
});
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Cloud-forwarding Tools
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
server.tool("test", "Start a conversational test session. Describe what you want to test.", {
|
|
152
|
+
description: z.string().describe("What to test (natural language)"),
|
|
153
|
+
url: z.string().optional().describe("App URL to test against"),
|
|
154
|
+
}, async ({ description, url }) => {
|
|
155
|
+
const result = await cloud.startConversation({ description, url });
|
|
156
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
157
|
+
});
|
|
158
|
+
server.tool("answer", "Respond to the agent's clarifying questions", {
|
|
159
|
+
session_id: z.string().describe("Session ID from the test call"),
|
|
160
|
+
answers: z.string().describe("Your responses to the agent's questions"),
|
|
161
|
+
}, async ({ session_id, answers }) => {
|
|
162
|
+
const result = await cloud.answerConversation(session_id, answers);
|
|
163
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
164
|
+
});
|
|
165
|
+
server.tool("approve", "Approve the generated test plan and start execution", {
|
|
166
|
+
session_id: z.string().describe("Session ID"),
|
|
167
|
+
exclude: z.array(z.number()).optional().describe("Test case numbers to skip"),
|
|
168
|
+
}, async ({ session_id, exclude }) => {
|
|
169
|
+
const result = await cloud.approveConversation(session_id, exclude);
|
|
170
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
171
|
+
});
|
|
172
|
+
server.tool("explore", "Autonomously explore a web application and discover testable flows", {
|
|
173
|
+
url: z.string().describe("Starting URL"),
|
|
174
|
+
max_pages: z.number().optional().describe("Max pages to explore (default 20)"),
|
|
175
|
+
focus: z.enum(["forms", "navigation", "errors", "all"]).optional().describe("Exploration focus"),
|
|
176
|
+
}, async ({ url, max_pages, focus }) => {
|
|
177
|
+
// Step 1: Navigate locally to get initial snapshot
|
|
178
|
+
const page = await browserMgr.ensureBrowser();
|
|
179
|
+
attachConsoleListener(page);
|
|
180
|
+
await actions.navigate(page, url);
|
|
181
|
+
const snapshot = await actions.getSnapshot(page);
|
|
182
|
+
const screenshotB64 = await actions.screenshot(page, false);
|
|
183
|
+
// Step 2: Send to cloud for AI analysis
|
|
184
|
+
const result = await cloud.startExploration({
|
|
185
|
+
url,
|
|
186
|
+
max_pages: max_pages ?? 20,
|
|
187
|
+
focus: focus ?? "all",
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{ type: "text", text: JSON.stringify({ ...result, initial_snapshot: snapshot }, null, 2) },
|
|
192
|
+
{ type: "image", data: screenshotB64, mimeType: "image/jpeg" },
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Execution Tools (Phase 3)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
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.", {
|
|
200
|
+
suite_id: z.string().describe("Test suite ID to run"),
|
|
201
|
+
test_case_ids: z.array(z.string()).optional().describe("Specific test case IDs to run (default: all in suite)"),
|
|
202
|
+
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)"),
|
|
203
|
+
}, async ({ suite_id, test_case_ids, pr_url }) => {
|
|
204
|
+
const summary = await executeRun(browserMgr, cloud, {
|
|
205
|
+
suiteId: suite_id,
|
|
206
|
+
testCaseIds: test_case_ids,
|
|
207
|
+
}, consoleLogs);
|
|
208
|
+
// Format a human-readable summary
|
|
209
|
+
const lines = [
|
|
210
|
+
`# Test Run ${summary.status === "passed" ? "✅ PASSED" : "❌ FAILED"}`,
|
|
211
|
+
`Execution ID: ${summary.execution_id}`,
|
|
212
|
+
`Total: ${summary.total} | Passed: ${summary.passed} | Failed: ${summary.failed} | Skipped: ${summary.skipped}`,
|
|
213
|
+
`Duration: ${(summary.duration_ms / 1000).toFixed(1)}s`,
|
|
214
|
+
"",
|
|
215
|
+
];
|
|
216
|
+
for (const r of summary.results) {
|
|
217
|
+
const icon = r.status === "passed" ? "✅" : r.status === "failed" ? "❌" : "⏭️";
|
|
218
|
+
lines.push(`${icon} ${r.name} (${r.duration_ms}ms)`);
|
|
219
|
+
if (r.error) {
|
|
220
|
+
lines.push(` Error: ${r.error}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Show healing summary if any heals occurred
|
|
224
|
+
if (summary.healed.length > 0) {
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(`## Self-Healed: ${summary.healed.length} selector(s)`);
|
|
227
|
+
for (const h of summary.healed) {
|
|
228
|
+
lines.push(` 🔧 "${h.test_case}" step ${h.step_index + 1}`);
|
|
229
|
+
lines.push(` ${h.original_selector} → ${h.new_selector}`);
|
|
230
|
+
lines.push(` Strategy: ${h.strategy} (${Math.round(h.confidence * 100)}% confidence)`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Post PR comment if pr_url was provided
|
|
234
|
+
if (pr_url) {
|
|
235
|
+
try {
|
|
236
|
+
const prResult = await cloud.postPrComment({
|
|
237
|
+
pr_url,
|
|
238
|
+
execution_id: summary.execution_id,
|
|
239
|
+
status: summary.status,
|
|
240
|
+
total: summary.total,
|
|
241
|
+
passed: summary.passed,
|
|
242
|
+
failed: summary.failed,
|
|
243
|
+
skipped: summary.skipped,
|
|
244
|
+
duration_seconds: Math.round(summary.duration_ms / 1000),
|
|
245
|
+
test_results: summary.results.map((r) => ({
|
|
246
|
+
name: r.name,
|
|
247
|
+
status: r.status,
|
|
248
|
+
error: r.error,
|
|
249
|
+
})),
|
|
250
|
+
healed: summary.healed.map((h) => ({
|
|
251
|
+
original_selector: h.original_selector,
|
|
252
|
+
new_selector: h.new_selector,
|
|
253
|
+
strategy: h.strategy,
|
|
254
|
+
confidence: h.confidence,
|
|
255
|
+
})),
|
|
256
|
+
});
|
|
257
|
+
const commentUrl = prResult.comment_url;
|
|
258
|
+
lines.push("");
|
|
259
|
+
lines.push(`📝 PR comment posted: ${commentUrl ?? pr_url}`);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
lines.push("");
|
|
263
|
+
lines.push(`⚠️ Failed to post PR comment: ${err}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
server.tool("github_token", "Set the GitHub personal access token for PR integration", {
|
|
271
|
+
token: z.string().describe("GitHub personal access token (PAT) with repo scope"),
|
|
272
|
+
}, async ({ token }) => {
|
|
273
|
+
await cloud.setGithubToken(token);
|
|
274
|
+
return { content: [{ type: "text", text: "GitHub token stored securely." }] };
|
|
275
|
+
});
|
|
276
|
+
server.tool("status", "Check the status of a test execution", {
|
|
277
|
+
execution_id: z.string().describe("Execution ID to check"),
|
|
278
|
+
}, async ({ execution_id }) => {
|
|
279
|
+
const result = await cloud.getExecutionStatus(execution_id);
|
|
280
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
281
|
+
});
|
|
282
|
+
server.tool("cancel", "Cancel a running test execution", {
|
|
283
|
+
execution_id: z.string().describe("Execution ID to cancel"),
|
|
284
|
+
}, async ({ execution_id }) => {
|
|
285
|
+
const result = await cloud.cancelExecution(execution_id);
|
|
286
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
287
|
+
});
|
|
288
|
+
server.tool("list_projects", "List all QA projects in the organization", {}, async () => {
|
|
289
|
+
const result = await cloud.listProjects();
|
|
290
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
291
|
+
});
|
|
292
|
+
server.tool("health", "Check if the QA agent backend is reachable", {}, async () => {
|
|
293
|
+
const result = await cloud.health();
|
|
294
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
295
|
+
});
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Healing Tools (Phase 5)
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
server.tool("heal", "Attempt to heal a broken selector by trying alternative locator strategies", {
|
|
300
|
+
selector: z.string().describe("The broken CSS selector"),
|
|
301
|
+
page_url: z.string().optional().describe("URL where the selector broke (defaults to current page)"),
|
|
302
|
+
error_message: z.string().optional().describe("The error message from Playwright"),
|
|
303
|
+
}, async ({ selector, page_url, error_message }) => {
|
|
304
|
+
const page = await browserMgr.getPage();
|
|
305
|
+
const url = page_url ?? page.url();
|
|
306
|
+
const result = await healSelector(page, cloud, selector, "ELEMENT_NOT_FOUND", error_message ?? "Element not found", url);
|
|
307
|
+
if (result.healed) {
|
|
308
|
+
return {
|
|
309
|
+
content: [{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: [
|
|
312
|
+
`🔧 Selector healed!`,
|
|
313
|
+
` Original: ${selector}`,
|
|
314
|
+
` New: ${result.newSelector}`,
|
|
315
|
+
` Strategy: ${result.strategy} (${Math.round((result.confidence ?? 0) * 100)}% confidence)`,
|
|
316
|
+
].join("\n"),
|
|
317
|
+
}],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
content: [{
|
|
322
|
+
type: "text",
|
|
323
|
+
text: `❌ Could not heal selector: ${selector}\n ${result.error ?? "All strategies exhausted"}`,
|
|
324
|
+
}],
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
server.tool("healing_history", "View healing patterns and statistics for the organization", {
|
|
328
|
+
limit: z.number().optional().describe("Max patterns to return (default 20)"),
|
|
329
|
+
}, async ({ limit }) => {
|
|
330
|
+
const [patterns, stats] = await Promise.all([
|
|
331
|
+
cloud.get(`/qa/healing/patterns?limit=${limit ?? 20}`),
|
|
332
|
+
cloud.get("/qa/healing/statistics"),
|
|
333
|
+
]);
|
|
334
|
+
const lines = [
|
|
335
|
+
`# Healing Statistics`,
|
|
336
|
+
`Total healed: ${stats.total_healed ?? 0}`,
|
|
337
|
+
`Patterns stored: ${stats.patterns_count ?? 0}`,
|
|
338
|
+
`Avg confidence: ${Math.round((stats.avg_confidence ?? 0) * 100)}%`,
|
|
339
|
+
"",
|
|
340
|
+
];
|
|
341
|
+
const breakdown = stats.strategy_breakdown;
|
|
342
|
+
if (breakdown && Object.keys(breakdown).length > 0) {
|
|
343
|
+
lines.push("Strategy breakdown:");
|
|
344
|
+
for (const [strategy, count] of Object.entries(breakdown)) {
|
|
345
|
+
lines.push(` ${strategy}: ${count} heals`);
|
|
346
|
+
}
|
|
347
|
+
lines.push("");
|
|
348
|
+
}
|
|
349
|
+
if (Array.isArray(patterns) && patterns.length > 0) {
|
|
350
|
+
lines.push("Recent patterns:");
|
|
351
|
+
for (const p of patterns) {
|
|
352
|
+
lines.push(` ${p.original_value} → ${p.healed_value} (${p.strategy}, ${p.times_applied}x)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Helpers
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
const attachedPages = new WeakSet();
|
|
363
|
+
function attachConsoleListener(page) {
|
|
364
|
+
if (attachedPages.has(page))
|
|
365
|
+
return;
|
|
366
|
+
attachedPages.add(page);
|
|
367
|
+
page.on("console", (msg) => {
|
|
368
|
+
const entry = `[${msg.type()}] ${msg.text()}`;
|
|
369
|
+
consoleLogs.push(entry);
|
|
370
|
+
if (consoleLogs.length > MAX_LOGS)
|
|
371
|
+
consoleLogs.shift();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Start
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
async function main() {
|
|
378
|
+
const transport = new StdioServerTransport();
|
|
379
|
+
await server.connect(transport);
|
|
380
|
+
// Cleanup on exit
|
|
381
|
+
process.on("SIGINT", async () => {
|
|
382
|
+
await browserMgr.close();
|
|
383
|
+
process.exit(0);
|
|
384
|
+
});
|
|
385
|
+
process.on("SIGTERM", async () => {
|
|
386
|
+
await browserMgr.close();
|
|
387
|
+
process.exit(0);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
main().catch((err) => {
|
|
391
|
+
console.error("Fatal:", err);
|
|
392
|
+
process.exit(1);
|
|
393
|
+
});
|
|
394
|
+
//# sourceMappingURL=index.js.map
|