@browserstack/mcp-server 1.2.17-beta.1 → 1.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/selfheal-utils/fetch-test-code.d.ts +60 -0
- package/dist/tools/selfheal-utils/fetch-test-code.js +258 -0
- package/dist/tools/selfheal-utils/selfheal.d.ts +45 -1
- package/dist/tools/selfheal-utils/selfheal.js +121 -25
- package/dist/tools/selfheal.d.ts +27 -2
- package/dist/tools/selfheal.js +471 -12
- package/dist/tools/testmanagement-utils/get-sub-testplan.d.ts +20 -0
- package/dist/tools/testmanagement-utils/get-sub-testplan.js +149 -0
- package/dist/tools/testmanagement-utils/list-sub-testplans.d.ts +17 -0
- package/dist/tools/testmanagement-utils/list-sub-testplans.js +83 -0
- package/dist/tools/testmanagement.d.ts +10 -0
- package/dist/tools/testmanagement.js +48 -0
- package/package.json +1 -1
package/dist/tools/selfheal.js
CHANGED
|
@@ -1,40 +1,448 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getSelfHealSelectors } from "./selfheal-utils/selfheal.js";
|
|
2
|
+
import { getSelfHealSelectors, fetchSelfHealingReportByBuild, } from "./selfheal-utils/selfheal.js";
|
|
3
|
+
import { fetchTestCodeForSessions, formatTestCodeAsContext, describeTestCodeFetchIssues, } from "./selfheal-utils/fetch-test-code.js";
|
|
3
4
|
import logger from "../logger.js";
|
|
4
5
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
5
|
-
//
|
|
6
|
+
// Local helper: returns the server-configured BrowserStack credentials, or
|
|
7
|
+
// null when either is missing. Lives here because the self-heal tools need
|
|
8
|
+
// to degrade gracefully — `getBrowserStackAuth` throws, which is wrong for
|
|
9
|
+
// these flows. Credentials are sourced from the server config (env) only;
|
|
10
|
+
// they are deliberately NOT accepted as tool arguments, since pasting them
|
|
11
|
+
// in chat is a credential-leak vector.
|
|
12
|
+
function resolveBrowserStackAuth(config) {
|
|
13
|
+
const username = (config["browserstack-username"] || "").trim();
|
|
14
|
+
const accessKey = (config["browserstack-access-key"] || "").trim();
|
|
15
|
+
if (!username || !accessKey)
|
|
16
|
+
return null;
|
|
17
|
+
return { config };
|
|
18
|
+
}
|
|
19
|
+
const CREDS_PROMPT_TEXT = "BrowserStack credentials are not configured on the MCP server. " +
|
|
20
|
+
"Ask the user to set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY " +
|
|
21
|
+
"in the server environment (https://www.browserstack.com/accounts/profile/details), " +
|
|
22
|
+
"restart the MCP server, and retry. Do NOT ask the user to paste credentials in chat.";
|
|
23
|
+
function credsMissingResult() {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: CREDS_PROMPT_TEXT }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function friendlyApiError(error, context) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
if (/\b401\b|Unauthorized/i.test(message)) {
|
|
31
|
+
return `Authentication with BrowserStack failed while ${context}. The username/access key is incorrect or the user does not have access to this resource. Ask the user to re-check the credentials.`;
|
|
32
|
+
}
|
|
33
|
+
if (/\b404\b|Not Found/i.test(message)) {
|
|
34
|
+
return `BrowserStack returned 404 while ${context}. The identifier is likely invalid or the build/session has no self-healing data. Ask the user to verify the ID.`;
|
|
35
|
+
}
|
|
36
|
+
if (/\b403\b|Forbidden/i.test(message)) {
|
|
37
|
+
return `BrowserStack returned 403 while ${context}. The provided credentials do not have permission to access this resource.`;
|
|
38
|
+
}
|
|
39
|
+
return `Error ${context}: ${message}`;
|
|
40
|
+
}
|
|
41
|
+
function trimOrUndefined(value) {
|
|
42
|
+
if (typeof value !== "string")
|
|
43
|
+
return undefined;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Wraps per-status guidance in a HEAD-OF-RESPONSE banner the LLM is unlikely
|
|
49
|
+
* to paraphrase away. The banner must come BEFORE the plan JSON so the model
|
|
50
|
+
* anchors on it when composing its user-facing reply.
|
|
51
|
+
*/
|
|
52
|
+
function buildWarningBanner(body) {
|
|
53
|
+
return [
|
|
54
|
+
"## ATTENTION — test code fetch did not return usable source",
|
|
55
|
+
"",
|
|
56
|
+
"Read this block BEFORE composing your reply to the user. When relaying " +
|
|
57
|
+
"this to the user, quote the provided phrasings below as closely as " +
|
|
58
|
+
"possible — do NOT compress multiple statuses into a generic " +
|
|
59
|
+
"'credentials or session issue' message. If the status below is " +
|
|
60
|
+
"`non_sdk_build`, it is definitely NOT a credentials problem.",
|
|
61
|
+
"",
|
|
62
|
+
body,
|
|
63
|
+
"",
|
|
64
|
+
"---",
|
|
65
|
+
"",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Emits the same warning banner for the fetch-selectors tool (session and
|
|
70
|
+
* build modes) when any session's test code came back unusable.
|
|
71
|
+
*/
|
|
72
|
+
function buildTestCodeFetchBanner(testCodeResults) {
|
|
73
|
+
const problematic = testCodeResults.filter((t) => t.status !== "ok");
|
|
74
|
+
if (problematic.length === 0)
|
|
75
|
+
return "";
|
|
76
|
+
return buildWarningBanner(describeTestCodeFetchIssues(problematic));
|
|
77
|
+
}
|
|
78
|
+
function isPlainObject(value) {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
function firstDefined(...values) {
|
|
82
|
+
for (const v of values) {
|
|
83
|
+
if (v !== undefined && v !== null && v !== "")
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Normalizes a locator reference to the canonical `{ strategy, value }` shape.
|
|
90
|
+
* Accepts alternate keys used by the BrowserStack healing report and common
|
|
91
|
+
* LLM/user mistakes (`type`, `using`). Passes the value through untouched if
|
|
92
|
+
* it is not an object so zod can still produce a clear error later.
|
|
93
|
+
*/
|
|
94
|
+
function normalizeLocatorRef(input) {
|
|
95
|
+
if (!isPlainObject(input))
|
|
96
|
+
return input;
|
|
97
|
+
const strategy = firstDefined(input.strategy, input.type, input.using, input.locatorType, input.by);
|
|
98
|
+
const value = firstDefined(input.value, input.selector, input.locator);
|
|
99
|
+
return { strategy, value };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Normalizes a single locator pair. Accepts:
|
|
103
|
+
* - `{ original, healed, thought }` (canonical)
|
|
104
|
+
* - `{ original_locator, healed_locator, healing_thought }` (report-native)
|
|
105
|
+
* - `{ from, to, reason }` (occasional LLM variant)
|
|
106
|
+
*/
|
|
107
|
+
function normalizeLocatorPair(input) {
|
|
108
|
+
if (!isPlainObject(input))
|
|
109
|
+
return input;
|
|
110
|
+
const original = normalizeLocatorRef(firstDefined(input.original, input.original_locator, input.from));
|
|
111
|
+
const healed = normalizeLocatorRef(firstDefined(input.healed, input.healed_locator, input.to));
|
|
112
|
+
const thought = firstDefined(input.thought, input.healing_thought, input.reason, input.note);
|
|
113
|
+
const out = { original, healed };
|
|
114
|
+
if (thought !== undefined)
|
|
115
|
+
out.thought = thought;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Normalizes a single session object. Accepts snake_case session ids and
|
|
120
|
+
* the healing-report-native `healed_selectors` array as an alias for
|
|
121
|
+
* `locators`.
|
|
122
|
+
*/
|
|
123
|
+
function normalizeSession(input) {
|
|
124
|
+
if (!isPlainObject(input))
|
|
125
|
+
return input;
|
|
126
|
+
const sessionId = firstDefined(input.sessionId, input.session_id, input.id, input.session_uuid, input.sessionUuid);
|
|
127
|
+
const sessionName = firstDefined(input.sessionName, input.session_name, input.name);
|
|
128
|
+
const rawLocators = Array.isArray(input.locators)
|
|
129
|
+
? input.locators
|
|
130
|
+
: Array.isArray(input.healed_selectors)
|
|
131
|
+
? input.healed_selectors
|
|
132
|
+
: Array.isArray(input.selectors)
|
|
133
|
+
? input.selectors
|
|
134
|
+
: [];
|
|
135
|
+
const locators = rawLocators.map(normalizeLocatorPair);
|
|
136
|
+
const out = { locators };
|
|
137
|
+
if (sessionId !== undefined)
|
|
138
|
+
out.sessionId = sessionId;
|
|
139
|
+
if (sessionName !== undefined)
|
|
140
|
+
out.sessionName = sessionName;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Normalizes the `sessions` argument. Accepts:
|
|
145
|
+
* - an array of sessions (canonical)
|
|
146
|
+
* - a single session object (wrapped in an array)
|
|
147
|
+
* - an envelope like `{ action, sessions: [...] }` (unwrap)
|
|
148
|
+
* - the raw healing report shape `{ healing_logs: [...] }` (map to sessions)
|
|
149
|
+
*/
|
|
150
|
+
function normalizeSessionsInput(input) {
|
|
151
|
+
if (input === undefined || input === null)
|
|
152
|
+
return input;
|
|
153
|
+
if (Array.isArray(input)) {
|
|
154
|
+
return input.map(normalizeSession);
|
|
155
|
+
}
|
|
156
|
+
if (isPlainObject(input)) {
|
|
157
|
+
if (Array.isArray(input.sessions)) {
|
|
158
|
+
return input.sessions.map(normalizeSession);
|
|
159
|
+
}
|
|
160
|
+
if (Array.isArray(input.healing_logs)) {
|
|
161
|
+
return input.healing_logs.map(normalizeSession);
|
|
162
|
+
}
|
|
163
|
+
// Looks like a lone session payload — wrap it.
|
|
164
|
+
if ("locators" in input ||
|
|
165
|
+
"healed_selectors" in input ||
|
|
166
|
+
"sessionId" in input ||
|
|
167
|
+
"session_id" in input) {
|
|
168
|
+
return [normalizeSession(input)];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Unknown shape — let zod surface a descriptive error.
|
|
172
|
+
return input;
|
|
173
|
+
}
|
|
6
174
|
export async function fetchSelfHealSelectorTool(args, config) {
|
|
175
|
+
const sessionId = trimOrUndefined(args.sessionId);
|
|
176
|
+
const buildUuid = trimOrUndefined(args.buildUuid);
|
|
177
|
+
if ((sessionId && buildUuid) || (!sessionId && !buildUuid)) {
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: "text",
|
|
182
|
+
text: "Please provide exactly one of `sessionId` or `buildUuid`. " +
|
|
183
|
+
"Use `buildUuid` when the user shares a build UUID (the tool will " +
|
|
184
|
+
"fetch the full self-healing report and test code for every " +
|
|
185
|
+
"session in the build). Use `sessionId` when the user wants to " +
|
|
186
|
+
"inspect a single Automate/App-Automate session.",
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const resolved = resolveBrowserStackAuth(config);
|
|
192
|
+
if (!resolved)
|
|
193
|
+
return credsMissingResult();
|
|
194
|
+
const effectiveConfig = resolved.config;
|
|
7
195
|
try {
|
|
8
|
-
|
|
196
|
+
if (sessionId) {
|
|
197
|
+
const [selectors, testCodeResults] = await Promise.all([
|
|
198
|
+
getSelfHealSelectors(sessionId, effectiveConfig, args.sessionType ?? "automate"),
|
|
199
|
+
fetchTestCodeForSessions([sessionId], effectiveConfig),
|
|
200
|
+
]);
|
|
201
|
+
const testCodeContext = formatTestCodeAsContext(testCodeResults);
|
|
202
|
+
const banner = buildTestCodeFetchBanner(testCodeResults);
|
|
203
|
+
return {
|
|
204
|
+
content: [
|
|
205
|
+
{
|
|
206
|
+
type: "text",
|
|
207
|
+
text: banner +
|
|
208
|
+
"Self-heal selectors fetched successfully (sessionId log-parse mode):\n" +
|
|
209
|
+
JSON.stringify(selectors, null, 2) +
|
|
210
|
+
(testCodeContext
|
|
211
|
+
? "\n\nThe following test code was found for this session. " +
|
|
212
|
+
"Use it to understand the test intent and make more accurate " +
|
|
213
|
+
"selector replacements:\n" +
|
|
214
|
+
testCodeContext
|
|
215
|
+
: ""),
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const report = await fetchSelfHealingReportByBuild(buildUuid, effectiveConfig);
|
|
221
|
+
// Extract unique session IDs from the healing report and fetch test code
|
|
222
|
+
const sessionIds = Array.from(new Set((report.healing_logs ?? [])
|
|
223
|
+
.map((log) => log.session_id)
|
|
224
|
+
.filter(Boolean)));
|
|
225
|
+
const testCodeResults = sessionIds.length > 0
|
|
226
|
+
? await fetchTestCodeForSessions(sessionIds, effectiveConfig)
|
|
227
|
+
: [];
|
|
228
|
+
const testCodeContext = formatTestCodeAsContext(testCodeResults);
|
|
229
|
+
const banner = buildTestCodeFetchBanner(testCodeResults);
|
|
9
230
|
return {
|
|
10
231
|
content: [
|
|
11
232
|
{
|
|
12
233
|
type: "text",
|
|
13
|
-
text:
|
|
14
|
-
|
|
234
|
+
text: banner +
|
|
235
|
+
"Self-healing report fetched successfully (buildUuid mode). " +
|
|
236
|
+
"Work session-by-session: for each entry in `healing_logs[]`, " +
|
|
237
|
+
"pass `healed_selectors[]` to `prepareSelfHealingPlan` so the " +
|
|
238
|
+
"calling LLM can apply the edits with its own file-editing " +
|
|
239
|
+
"tools (this server never writes files).\n" +
|
|
240
|
+
JSON.stringify(report, null, 2) +
|
|
241
|
+
(testCodeContext
|
|
242
|
+
? "\n\nThe following test code was found for sessions in this build. " +
|
|
243
|
+
"Use it to understand the test intent, locate the exact files " +
|
|
244
|
+
"containing the selectors, and make more accurate replacements:\n" +
|
|
245
|
+
testCodeContext
|
|
246
|
+
: ""),
|
|
15
247
|
},
|
|
16
248
|
],
|
|
17
249
|
};
|
|
18
250
|
}
|
|
19
251
|
catch (error) {
|
|
20
252
|
logger.error("Error fetching self-heal selector suggestions", error);
|
|
21
|
-
const
|
|
253
|
+
const context = sessionId
|
|
254
|
+
? `fetching self-heal selectors for sessionId=${sessionId}`
|
|
255
|
+
: `fetching self-healing report for buildUuid=${buildUuid}`;
|
|
256
|
+
return {
|
|
257
|
+
content: [{ type: "text", text: friendlyApiError(error, context) }],
|
|
258
|
+
isError: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const PLAN_INSTRUCTIONS = [
|
|
263
|
+
"## How to use this plan",
|
|
264
|
+
"",
|
|
265
|
+
"This tool does NOT modify any files. It returns the healed-locator plan",
|
|
266
|
+
"and per-session test source code so that YOU (the calling LLM) can make",
|
|
267
|
+
"surgical edits with your own file-editing tools.",
|
|
268
|
+
"",
|
|
269
|
+
"For each session in the plan:",
|
|
270
|
+
" 1. Read each `tests[].code` to understand the test intent — especially",
|
|
271
|
+
" the step the healed locator belongs to.",
|
|
272
|
+
" 2. Locate the exact call site(s) in the user's local project that",
|
|
273
|
+
" correspond to the original locator. Use `tests[].filename` as the",
|
|
274
|
+
" first place to look.",
|
|
275
|
+
" 3. Edit ONLY the call sites that belong to this session's failing step.",
|
|
276
|
+
" A single id / class value may appear in many places; do NOT blindly",
|
|
277
|
+
" find/replace across the repo (e.g. two different elements both using",
|
|
278
|
+
' id="foo" must be resolved individually).',
|
|
279
|
+
" 4. The healing report speaks in CSS/xpath, but the source often uses",
|
|
280
|
+
" `By.id(...)`, `By.name(...)`, `By.css(...)` wrappers. Translate as",
|
|
281
|
+
" needed — e.g. `*[id=\"email-field\"]` → `By.id('email-field')`, or",
|
|
282
|
+
' `input[id="user-email-input"]` → `By.css(\'input[id="user-email-input"]\')`.',
|
|
283
|
+
" 5. Confirm ambiguous file paths with the user before editing.",
|
|
284
|
+
"",
|
|
285
|
+
"If `tests[]` is empty for a session, the BrowserStack API did not return",
|
|
286
|
+
"test code (credentials missing, or the session has no associated test",
|
|
287
|
+
"runs). Ask the user to point you at the right file before editing.",
|
|
288
|
+
].join("\n");
|
|
289
|
+
export async function prepareSelfHealingPlanTool(args, config) {
|
|
290
|
+
const rawArgs = args;
|
|
291
|
+
// Normalize flexible input shapes so bare handler calls are just as lenient
|
|
292
|
+
// as schema-validated MCP calls. The normalizer also looks inside alternate
|
|
293
|
+
// top-level keys (e.g. the user pasted the whole args object into the
|
|
294
|
+
// `sessions` field by accident).
|
|
295
|
+
const sessionsRaw = firstDefined(args.sessions, rawArgs.healing_logs, rawArgs.sessionsList);
|
|
296
|
+
const normalized = normalizeSessionsInput(sessionsRaw);
|
|
297
|
+
const sessions = (Array.isArray(normalized) ? normalized : []);
|
|
298
|
+
if (sessions.length === 0) {
|
|
22
299
|
return {
|
|
23
300
|
content: [
|
|
24
301
|
{
|
|
25
302
|
type: "text",
|
|
26
|
-
text:
|
|
303
|
+
text: "No sessions provided. Pass `sessions: [{ sessionId, locators: " +
|
|
304
|
+
"[{ original, healed, thought? }] }]` — typically copied from the " +
|
|
305
|
+
"self-healing report returned by `fetchSelfHealedSelectors`.",
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
// Keep only locator pairs where both sides are non-empty and distinct; the
|
|
311
|
+
// rest are surfaced as `skipped` so the caller can see what was ignored.
|
|
312
|
+
const plannedSessions = [];
|
|
313
|
+
const skipped = [];
|
|
314
|
+
for (const session of sessions) {
|
|
315
|
+
const locators = Array.isArray(session?.locators) ? session.locators : [];
|
|
316
|
+
const validLocators = [];
|
|
317
|
+
for (const loc of locators) {
|
|
318
|
+
const originalValue = loc?.original?.value?.trim?.();
|
|
319
|
+
const healedValue = loc?.healed?.value?.trim?.();
|
|
320
|
+
if (!originalValue || !healedValue) {
|
|
321
|
+
skipped.push({
|
|
322
|
+
reason: "missing original.value or healed.value",
|
|
323
|
+
sessionId: session?.sessionId,
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (originalValue === healedValue) {
|
|
328
|
+
skipped.push({
|
|
329
|
+
reason: "original and healed values are identical",
|
|
330
|
+
sessionId: session?.sessionId,
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
validLocators.push({
|
|
335
|
+
original: {
|
|
336
|
+
strategy: loc.original.strategy,
|
|
337
|
+
value: loc.original.value,
|
|
338
|
+
},
|
|
339
|
+
healed: {
|
|
340
|
+
strategy: loc.healed.strategy,
|
|
341
|
+
value: loc.healed.value,
|
|
342
|
+
},
|
|
343
|
+
...(loc.thought ? { thought: loc.thought } : {}),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (validLocators.length > 0) {
|
|
347
|
+
plannedSessions.push({
|
|
348
|
+
sessionId: session.sessionId,
|
|
349
|
+
sessionName: session.sessionName,
|
|
350
|
+
locators: validLocators,
|
|
351
|
+
tests: [],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (plannedSessions.length === 0) {
|
|
356
|
+
return {
|
|
357
|
+
content: [
|
|
358
|
+
{
|
|
359
|
+
type: "text",
|
|
360
|
+
text: "No applicable locator pairs found in the provided sessions. " +
|
|
361
|
+
"Every entry was missing values or had identical original/healed " +
|
|
362
|
+
"locators.\n" +
|
|
363
|
+
JSON.stringify({ skipped }, null, 2),
|
|
27
364
|
},
|
|
28
365
|
],
|
|
29
|
-
isError: true,
|
|
30
366
|
};
|
|
31
367
|
}
|
|
368
|
+
// Try to enrich the plan with test code per session. Missing credentials
|
|
369
|
+
// are not fatal — the plan is still useful to the caller.
|
|
370
|
+
const resolved = resolveBrowserStackAuth(config);
|
|
371
|
+
const sessionIds = plannedSessions
|
|
372
|
+
.map((s) => trimOrUndefined(s?.sessionId))
|
|
373
|
+
.filter((id) => Boolean(id));
|
|
374
|
+
let warningBanner = "";
|
|
375
|
+
if (resolved && sessionIds.length > 0) {
|
|
376
|
+
try {
|
|
377
|
+
const testCodeResults = await fetchTestCodeForSessions(sessionIds, resolved.config);
|
|
378
|
+
const resultBySessionId = new Map(testCodeResults.map((t) => [t.sessionId, t]));
|
|
379
|
+
for (const session of plannedSessions) {
|
|
380
|
+
if (session.sessionId && resultBySessionId.has(session.sessionId)) {
|
|
381
|
+
session.tests = resultBySessionId.get(session.sessionId)?.tests ?? [];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const problematic = testCodeResults.filter((t) => t.status !== "ok");
|
|
385
|
+
if (problematic.length > 0) {
|
|
386
|
+
warningBanner = buildWarningBanner(describeTestCodeFetchIssues(problematic));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
logger.warn("Failed to fetch test code during plan preparation", error);
|
|
391
|
+
warningBanner = buildWarningBanner(`### Transport error while fetching test code\n\nDiagnosis: ${error instanceof Error ? error.message : String(error)}. This is not an auth issue.\n\nSay this to the user: "I hit a transport error fetching test code from BrowserStack. Want me to retry, or would you rather share the local test file directly so I can apply the healed locators?"`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (!resolved && sessionIds.length > 0) {
|
|
395
|
+
warningBanner = buildWarningBanner([
|
|
396
|
+
"### BrowserStack credentials not provided",
|
|
397
|
+
"",
|
|
398
|
+
"Diagnosis: BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY are not " +
|
|
399
|
+
"configured on the MCP server, so test code could not be fetched. " +
|
|
400
|
+
"The plan was still generated from the locator pairs you sent.",
|
|
401
|
+
"",
|
|
402
|
+
"Say this to the user: \"I couldn't pull the test source " +
|
|
403
|
+
"automatically because BrowserStack credentials aren't configured " +
|
|
404
|
+
"on the MCP server. Please set BROWSERSTACK_USERNAME and " +
|
|
405
|
+
"BROWSERSTACK_ACCESS_KEY in the server environment and restart " +
|
|
406
|
+
"the MCP server, or point me at the local test file directly so " +
|
|
407
|
+
'I can apply the healed locators."',
|
|
408
|
+
"",
|
|
409
|
+
"Do NOT ask the user to paste credentials in chat.",
|
|
410
|
+
].join("\n"));
|
|
411
|
+
}
|
|
412
|
+
const skippedNote = skipped.length > 0
|
|
413
|
+
? `\n\n## Skipped locator pairs\n\n${JSON.stringify(skipped, null, 2)}`
|
|
414
|
+
: "";
|
|
415
|
+
return {
|
|
416
|
+
content: [
|
|
417
|
+
{
|
|
418
|
+
type: "text",
|
|
419
|
+
text: warningBanner +
|
|
420
|
+
`${PLAN_INSTRUCTIONS}\n\n## Plan\n\n${JSON.stringify(plannedSessions, null, 2)}` +
|
|
421
|
+
skippedNote,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};
|
|
32
425
|
}
|
|
33
426
|
// Registers the fetchSelfHealSelector tool with the MCP server
|
|
34
427
|
export default function addSelfHealTools(server, config) {
|
|
35
428
|
const tools = {};
|
|
36
|
-
tools.fetchSelfHealedSelectors = server.tool("fetchSelfHealedSelectors", "
|
|
37
|
-
|
|
429
|
+
tools.fetchSelfHealedSelectors = server.tool("fetchSelfHealedSelectors", "Fetch BrowserStack self-healed selectors plus the test source code for " +
|
|
430
|
+
"the run. Provide exactly one of `sessionId` (single Automate / " +
|
|
431
|
+
"App-Automate session) or `buildUuid` (full self-healing report for a " +
|
|
432
|
+
"build). Pass the returned locator pairs to `prepareSelfHealingPlan` " +
|
|
433
|
+
"to plan edits.", {
|
|
434
|
+
sessionId: z
|
|
435
|
+
.string()
|
|
436
|
+
.describe("Session ID. Mutually exclusive with buildUuid.")
|
|
437
|
+
.optional(),
|
|
438
|
+
sessionType: z
|
|
439
|
+
.enum(["automate", "app-automate"])
|
|
440
|
+
.describe("Used with sessionId. Defaults to automate.")
|
|
441
|
+
.optional(),
|
|
442
|
+
buildUuid: z
|
|
443
|
+
.string()
|
|
444
|
+
.describe("Build UUID. Fetches the build's self-healing report.")
|
|
445
|
+
.optional(),
|
|
38
446
|
}, async (args) => {
|
|
39
447
|
try {
|
|
40
448
|
trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion(), undefined, config);
|
|
@@ -42,12 +450,63 @@ export default function addSelfHealTools(server, config) {
|
|
|
42
450
|
}
|
|
43
451
|
catch (error) {
|
|
44
452
|
trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion(), error, config);
|
|
45
|
-
const
|
|
453
|
+
const context = args.sessionId
|
|
454
|
+
? `fetching self-heal selectors for sessionId=${args.sessionId}`
|
|
455
|
+
: args.buildUuid
|
|
456
|
+
? `fetching self-healing report for buildUuid=${args.buildUuid}`
|
|
457
|
+
: "fetching self-heal suggestions";
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text: friendlyApiError(error, context) }],
|
|
460
|
+
isError: true,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
const locatorRefSchema = z.object({
|
|
465
|
+
strategy: z
|
|
466
|
+
.string()
|
|
467
|
+
.describe("Locator strategy, e.g. 'css selector', 'xpath'."),
|
|
468
|
+
value: z.string().describe("The locator string itself."),
|
|
469
|
+
});
|
|
470
|
+
const sessionLocatorSchema = z.object({
|
|
471
|
+
original: locatorRefSchema,
|
|
472
|
+
healed: locatorRefSchema,
|
|
473
|
+
thought: z.string().optional(),
|
|
474
|
+
});
|
|
475
|
+
const sessionPayloadSchema = z.object({
|
|
476
|
+
sessionId: z.string().describe("BrowserStack session ID."),
|
|
477
|
+
sessionName: z.string().optional(),
|
|
478
|
+
locators: z
|
|
479
|
+
.array(sessionLocatorSchema)
|
|
480
|
+
.min(1)
|
|
481
|
+
.describe("Healed locator pairs for this session."),
|
|
482
|
+
});
|
|
483
|
+
// Flexible entry point for the `sessions` argument. See normalizers above
|
|
484
|
+
// for the accepted alternate shapes.
|
|
485
|
+
const sessionsFieldSchema = z.preprocess(normalizeSessionsInput, z.array(sessionPayloadSchema).min(1));
|
|
486
|
+
tools.prepareSelfHealingPlan = server.tool("prepareSelfHealingPlan", "Build a self-healing edit plan that bundles locator pairs with test " +
|
|
487
|
+
"source code for the calling LLM to apply with its own editing tools. " +
|
|
488
|
+
"This tool does NOT modify files — it returns structured context so " +
|
|
489
|
+
"the LLM can edit only the relevant call sites (avoids blind " +
|
|
490
|
+
"find/replace across shared ids/classes). `sessions` accepts the " +
|
|
491
|
+
"canonical shape `[{sessionId, locators: [{original, healed, " +
|
|
492
|
+
"thought?}]}]`, a single session object, an envelope `{sessions: " +
|
|
493
|
+
"[...]}`, the raw report `{healing_logs: [...]}` (with " +
|
|
494
|
+
"`healed_selectors` aliasing `locators`), and snake_case keys " +
|
|
495
|
+
"(`session_id`, `original_locator`, `healed_locator`, " +
|
|
496
|
+
"`healing_thought`).", {
|
|
497
|
+
sessions: sessionsFieldSchema.describe("Sessions to plan edits for. See tool description for accepted shapes."),
|
|
498
|
+
}, async (args) => {
|
|
499
|
+
try {
|
|
500
|
+
trackMCP("prepareSelfHealingPlan", server.server.getClientVersion(), undefined, config);
|
|
501
|
+
return await prepareSelfHealingPlanTool(args, config);
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
trackMCP("prepareSelfHealingPlan", server.server.getClientVersion(), error, config);
|
|
46
505
|
return {
|
|
47
506
|
content: [
|
|
48
507
|
{
|
|
49
508
|
type: "text",
|
|
50
|
-
text:
|
|
509
|
+
text: friendlyApiError(error, "preparing self-healing plan"),
|
|
51
510
|
},
|
|
52
511
|
],
|
|
53
512
|
isError: true,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Schema for fetching a single sub-test-plan by identifier under a parent test
|
|
6
|
+
* plan, including its linked test runs.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GetSubTestPlanSchema: z.ZodObject<{
|
|
9
|
+
project_identifier: z.ZodString;
|
|
10
|
+
parent_test_plan_identifier: z.ZodString;
|
|
11
|
+
sub_test_plan_identifier: z.ZodString;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export type GetSubTestPlanArgs = z.infer<typeof GetSubTestPlanSchema>;
|
|
14
|
+
/**
|
|
15
|
+
* Fetches a sub-test-plan by identifier and best-effort-enriches it with its
|
|
16
|
+
* linked test runs. The enrichment call is fail-soft: any failure (thrown
|
|
17
|
+
* AxiosError or non-success response) is swallowed into an empty runs list
|
|
18
|
+
* without flipping `isError` on the primary response.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getSubTestPlan(args: GetSubTestPlanArgs, config: BrowserStackConfig): Promise<CallToolResult>;
|