Skip to content

Commit 6061950

Browse files
Merge pull request #282 from vipin-bs/self-heal-session
Feat : Expands the self-heal tooling to support build-scoped self-healing reports and enrich responses
2 parents de09b4b + fc969ec commit 6061950

4 files changed

Lines changed: 1518 additions & 71 deletions

File tree

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
2+
import { BrowserStackConfig } from "../../lib/types.js";
3+
import { apiClient } from "../../lib/apiClient.js";
4+
import logger from "../../logger.js";
5+
6+
const OBSERVABILITY_API_BASE = "https://api-automation.browserstack.com/ext/v1";
7+
8+
export interface TestCodeEntry {
9+
testRunId: number;
10+
code: string | null;
11+
filename: string | null;
12+
url: string | null;
13+
language?: string | null;
14+
type?: string | null;
15+
}
16+
17+
interface TestCodeApiResponse {
18+
sessionId?: string;
19+
buildUuid?: string;
20+
tests?: TestCodeEntry[];
21+
}
22+
23+
/**
24+
* Why a session's test code fetch succeeded or failed, so downstream callers
25+
* can phrase their "please give me the file" prompt correctly:
26+
* - ok: entries returned with real `code`/`filename`.
27+
* - non_sdk_build: HTTP 200, entries present but every entry has
28+
* `code === null && filename === null`. Classic signature
29+
* of a non-SDK Automate build — the source is not
30+
* introspectable via the API, so ask the user for the
31+
* file path.
32+
* - empty: HTTP 200 with no entries — session had no tests.
33+
* - unauthorized: 401 — credentials are wrong/expired. Offer retry.
34+
* - forbidden: 403 — credentials valid but no access to this session.
35+
* - not_found: 404 — session id is wrong.
36+
* - error: any other transport/network failure.
37+
*/
38+
export type TestCodeFetchStatus =
39+
| "ok"
40+
| "non_sdk_build"
41+
| "empty"
42+
| "unauthorized"
43+
| "forbidden"
44+
| "not_found"
45+
| "error";
46+
47+
export interface SessionTestCode {
48+
sessionId: string;
49+
tests: TestCodeEntry[];
50+
status: TestCodeFetchStatus;
51+
httpStatus?: number;
52+
errorMessage?: string;
53+
}
54+
55+
/**
56+
* Normalizes the testCode response. The Observability API used to return a
57+
* bare array of TestCodeEntry, but now wraps it as
58+
* `{ sessionId, buildUuid, tests: TestCodeEntry[] }`. Accept both shapes so
59+
* the integration is resilient to either deployment.
60+
*/
61+
function extractTests(
62+
data: TestCodeApiResponse | TestCodeEntry[] | null | undefined,
63+
): TestCodeEntry[] {
64+
if (!data) return [];
65+
if (Array.isArray(data)) return data;
66+
return Array.isArray(data.tests) ? data.tests : [];
67+
}
68+
69+
function classifyByHttpStatus(status: number): TestCodeFetchStatus {
70+
if (status === 401) return "unauthorized";
71+
if (status === 403) return "forbidden";
72+
if (status === 404) return "not_found";
73+
return "error";
74+
}
75+
76+
function classifyOkResponse(
77+
tests: TestCodeEntry[],
78+
): Exclude<
79+
TestCodeFetchStatus,
80+
"unauthorized" | "forbidden" | "not_found" | "error"
81+
> {
82+
if (tests.length === 0) return "empty";
83+
const allNullCode = tests.every(
84+
(t) => t.code === null && t.filename === null,
85+
);
86+
return allNullCode ? "non_sdk_build" : "ok";
87+
}
88+
89+
/**
90+
* Fetches the test code for all tests in a given session from the
91+
* Observability API.
92+
*
93+
* Endpoint: GET /ext/v1/sessions/{sessionId}/testCode
94+
* Returns: Array of { testRunId, code, filename, url }
95+
*/
96+
export async function fetchTestCodeBySession(
97+
sessionId: string,
98+
config: BrowserStackConfig,
99+
): Promise<SessionTestCode> {
100+
if (!sessionId) {
101+
throw new Error("sessionId is required to fetch test code");
102+
}
103+
104+
const authString = getBrowserStackAuth(config);
105+
const auth = Buffer.from(authString).toString("base64");
106+
107+
const url = `${OBSERVABILITY_API_BASE}/sessions/${encodeURIComponent(sessionId)}/testCode`;
108+
109+
try {
110+
const response = await apiClient.get<TestCodeApiResponse | TestCodeEntry[]>(
111+
{
112+
url,
113+
headers: {
114+
"Content-Type": "application/json",
115+
Authorization: `Basic ${auth}`,
116+
},
117+
raise_error: false,
118+
},
119+
);
120+
121+
if (!response.ok) {
122+
logger.warn(
123+
`Failed to fetch test code for session ${sessionId}: HTTP ${response.status}`,
124+
);
125+
return {
126+
sessionId,
127+
tests: [],
128+
status: classifyByHttpStatus(response.status),
129+
httpStatus: response.status,
130+
};
131+
}
132+
133+
const tests = extractTests(response.data);
134+
return {
135+
sessionId,
136+
tests,
137+
status: classifyOkResponse(tests),
138+
httpStatus: response.status,
139+
};
140+
} catch (error) {
141+
const message = error instanceof Error ? error.message : String(error);
142+
logger.warn(
143+
`Error fetching test code for session ${sessionId}: ${message}`,
144+
);
145+
return {
146+
sessionId,
147+
tests: [],
148+
status: "error",
149+
errorMessage: message,
150+
};
151+
}
152+
}
153+
154+
/**
155+
* Builds a directive guidance block for the calling LLM when one or more
156+
* sessions did not return usable test code. Each status block contains:
157+
* - A diagnosis (what happened, per BrowserStack)
158+
* - An explicit "do NOT paraphrase this as X" constraint
159+
* - A ready-to-say phrasing the LLM can quote when replying to the user
160+
*
161+
* Returns empty string when every session returned `status: ok`.
162+
*/
163+
export function describeTestCodeFetchIssues(
164+
sessionTestCodes: SessionTestCode[],
165+
): string {
166+
if (sessionTestCodes.length === 0) return "";
167+
168+
const byStatus = new Map<TestCodeFetchStatus, string[]>();
169+
for (const s of sessionTestCodes) {
170+
if (!byStatus.has(s.status)) byStatus.set(s.status, []);
171+
byStatus.get(s.status)!.push(s.sessionId);
172+
}
173+
174+
const notes: string[] = [];
175+
176+
const nonSdk = byStatus.get("non_sdk_build");
177+
if (nonSdk && nonSdk.length > 0) {
178+
const ids = nonSdk.join(", ");
179+
notes.push(
180+
[
181+
`### Non-SDK build — session(s): ${ids}`,
182+
"",
183+
"Diagnosis: BrowserStack's test-code API returned HTTP 200 with rows " +
184+
"where `code` and `filename` are null. This is the KNOWN signature " +
185+
"of a BrowserStack run that was NOT instrumented with the " +
186+
"BrowserStack SDK / Observability. The API literally has no source " +
187+
"code to return for these sessions — this is by design.",
188+
"",
189+
"DO NOT tell the user any of the following (all are wrong for this " +
190+
"status): 'credentials issue', 'credentials or session issue', " +
191+
"'could not fetch from the API due to auth', '401', 'unauthorized'. " +
192+
"The credentials are fine; the build just isn't SDK-enabled.",
193+
"",
194+
"Say this (or very close to it) to the user, then wait for their reply:",
195+
' "This is a non-SDK BrowserStack build, so the test-code API has ' +
196+
"no source to return for it (this is expected, not a credentials " +
197+
"problem). Can you tell me the local file path where these tests live? " +
198+
"Once I have the file, I'll apply the healed locators listed in the plan.\"",
199+
].join("\n"),
200+
);
201+
}
202+
203+
const unauthorized = byStatus.get("unauthorized");
204+
if (unauthorized && unauthorized.length > 0) {
205+
const ids = unauthorized.join(", ");
206+
notes.push(
207+
[
208+
`### Unauthorized (HTTP 401) — session(s): ${ids}`,
209+
"",
210+
"Diagnosis: BrowserStack's test-code API rejected the credentials " +
211+
"(HTTP 401). This IS an authentication problem for this specific " +
212+
"API — the healing report endpoint and test-code endpoint use the " +
213+
"same BrowserStack auth, so if the report fetch above succeeded " +
214+
"with the same creds, suspect that the access key configured on " +
215+
"the MCP server was rotated.",
216+
"",
217+
"DO NOT say 'credentials or session issue' (that hides the real cause). " +
218+
"Name the 401 explicitly and offer the user a choice. Do NOT ask " +
219+
"the user to paste a BrowserStack username or access key in chat — " +
220+
"the MCP server reads credentials from its own environment.",
221+
"",
222+
"Say this (or very close to it) to the user, then wait for their reply:",
223+
' "The BrowserStack test-code API returned 401 Unauthorized for ' +
224+
`session(s) ${ids}. Would you like to: (a) update the BrowserStack ` +
225+
"username and access key on the MCP server (BROWSERSTACK_USERNAME / " +
226+
"BROWSERSTACK_ACCESS_KEY) and restart it, or (b) skip the API and " +
227+
'point me at the local test file so I can apply the healed locators there?"',
228+
].join("\n"),
229+
);
230+
}
231+
232+
const forbidden = byStatus.get("forbidden");
233+
if (forbidden && forbidden.length > 0) {
234+
const ids = forbidden.join(", ");
235+
notes.push(
236+
[
237+
`### Forbidden (HTTP 403) — session(s): ${ids}`,
238+
"",
239+
"Diagnosis: BrowserStack's test-code API accepted the credentials " +
240+
"but denied access to the session's source (HTTP 403). Typically " +
241+
"the user does not own this session, or the account does not have " +
242+
"Observability enabled.",
243+
"",
244+
"Do NOT blame the credentials broadly — say 'access denied for this " +
245+
"session' and move on.",
246+
"",
247+
"Say this (or very close to it) to the user:",
248+
` "BrowserStack denied access (HTTP 403) to session(s) ${ids}. ` +
249+
"This usually means the account does not own these sessions or " +
250+
"does not have Observability enabled. Can you either share the " +
251+
'local test file path, or confirm which account these sessions belong to?"',
252+
].join("\n"),
253+
);
254+
}
255+
256+
const notFound = byStatus.get("not_found");
257+
if (notFound && notFound.length > 0) {
258+
const ids = notFound.join(", ");
259+
notes.push(
260+
[
261+
`### Not found (HTTP 404) — session(s): ${ids}`,
262+
"",
263+
"Diagnosis: BrowserStack returned HTTP 404 — the session id is most " +
264+
"likely wrong, or the session has been purged.",
265+
"",
266+
"Do NOT say 'credentials'. Ask the user to verify the id.",
267+
"",
268+
"Say this (or very close to it) to the user:",
269+
` "I couldn't find session(s) ${ids} on BrowserStack (HTTP 404). ` +
270+
"Can you double-check the session id(s), or share the local test " +
271+
'file directly so I can apply the healed locators?"',
272+
].join("\n"),
273+
);
274+
}
275+
276+
const empty = byStatus.get("empty");
277+
if (empty && empty.length > 0) {
278+
const ids = empty.join(", ");
279+
notes.push(
280+
[
281+
`### No test runs recorded — session(s): ${ids}`,
282+
"",
283+
"Diagnosis: HTTP 200 with an empty array — the session executed but " +
284+
"no test code was captured (e.g. a raw Selenium session not linked " +
285+
"to a test framework).",
286+
"",
287+
"Say this (or very close to it) to the user:",
288+
` "BrowserStack has no test code recorded for session(s) ${ids}. ` +
289+
'Can you point me at the local test file where these locators live?"',
290+
].join("\n"),
291+
);
292+
}
293+
294+
const errored = byStatus.get("error");
295+
if (errored && errored.length > 0) {
296+
const details = sessionTestCodes
297+
.filter((s) => s.status === "error")
298+
.map((s) => `${s.sessionId}: ${s.errorMessage ?? "unknown error"}`)
299+
.join("; ");
300+
notes.push(
301+
[
302+
`### Transport error — session(s): ${errored.join(", ")}`,
303+
"",
304+
`Diagnosis: network/transport failure — ${details}. This is not an ` +
305+
"auth issue.",
306+
"",
307+
"Say this (or very close to it) to the user:",
308+
` "I hit a transport error fetching test code from BrowserStack ` +
309+
`(${details}). Want me to retry, or would you prefer to share the ` +
310+
'local test file directly?"',
311+
].join("\n"),
312+
);
313+
}
314+
315+
return notes.join("\n\n");
316+
}
317+
318+
/**
319+
* Fetches test code for multiple sessions in parallel.
320+
* Failures for individual sessions are logged and skipped (partial results).
321+
*/
322+
export async function fetchTestCodeForSessions(
323+
sessionIds: string[],
324+
config: BrowserStackConfig,
325+
): Promise<SessionTestCode[]> {
326+
const results = await Promise.allSettled(
327+
sessionIds.map((id) => fetchTestCodeBySession(id, config)),
328+
);
329+
330+
return results
331+
.filter(
332+
(r): r is PromiseFulfilledResult<SessionTestCode> =>
333+
r.status === "fulfilled",
334+
)
335+
.map((r) => r.value);
336+
}
337+
338+
/**
339+
* Formats test code entries into a concise context string suitable for
340+
* inclusion in an LLM prompt. Groups by file, includes code snippets.
341+
*/
342+
export function formatTestCodeAsContext(
343+
sessionTestCodes: SessionTestCode[],
344+
): string {
345+
const sections: string[] = [];
346+
347+
for (const session of sessionTestCodes) {
348+
const testsWithCode = session.tests.filter((t) => t.code);
349+
if (testsWithCode.length === 0) continue;
350+
351+
const testLines = testsWithCode.map((t) => {
352+
const fileInfo = t.filename ? `File: ${t.filename}` : "File: unknown";
353+
const urlInfo = t.url ? `URL: ${t.url}` : "";
354+
const header = [fileInfo, urlInfo].filter(Boolean).join("\n");
355+
return `${header}\n\`\`\`\n${t.code}\n\`\`\``;
356+
});
357+
358+
sections.push(`Session: ${session.sessionId}\n${testLines.join("\n\n")}`);
359+
}
360+
361+
if (sections.length === 0) {
362+
return "";
363+
}
364+
365+
return `\n--- Test Code Context ---\n${sections.join("\n\n")}\n--- End Test Code Context ---\n`;
366+
}

0 commit comments

Comments
 (0)