Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/tools/rca-agent-utils/get-failed-test-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,14 @@ export async function getTestIds(
}
}

// Recursive function to extract failed test IDs from hierarchy
function extractFailedTestIds(
export function extractFailedTestIds(
hierarchy: TestDetails[],
status?: TestStatus,
): FailedTestInfo[] {
let failedTests: FailedTestInfo[] = [];

for (const node of hierarchy) {
if (node.details?.status === status && node.details?.run_count) {
if (node.details?.status === status) {
Comment thread
SavioBS629 marked this conversation as resolved.
if (node.details?.observability_url) {
const idMatch = node.details.observability_url.match(/details=(\d+)/);
if (idMatch) {
Expand Down
35 changes: 35 additions & 0 deletions src/tools/rca-agent-utils/list-build-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Same as getBuildId, but without the user_name filter — returns the latest
* build for a project + build name across all users (not just the caller's).
*/
export async function listBuildId(
projectName: string,
buildName: string,
username: string,
accessKey: string,
): Promise<string> {
const url = new URL(
"https://api-automation.browserstack.com/ext/v1/builds/latest",
);
url.searchParams.append("project_name", projectName);
url.searchParams.append("build_name", buildName);

const authHeader =
"Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64");

const response = await fetch(url.toString(), {
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
});

if (!response.ok) {
throw new Error(
`Failed to fetch build ID: ${response.status} ${response.statusText}`,
);
}

const data = await response.json();
return data.build_id;
}
66 changes: 64 additions & 2 deletions src/tools/rca-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import logger from "../logger.js";
import { BrowserStackConfig } from "../lib/types.js";
import { getBrowserStackAuth } from "../lib/get-auth.js";
import { getBuildId } from "./rca-agent-utils/get-build-id.js";
import { listBuildId } from "./rca-agent-utils/list-build-id.js";
import { getTestIds } from "./rca-agent-utils/get-failed-test-id.js";
import { getRCAData } from "./rca-agent-utils/rca-data.js";
import { formatRCAData } from "./rca-agent-utils/format-rca.js";
Expand Down Expand Up @@ -59,6 +60,48 @@ export async function getBuildIdTool(
}
}

// Tool function to fetch the build ID across all users (no user scoping)
export async function listBuildIdTool(
args: BuildIdArgs,
config: BrowserStackConfig,
): Promise<CallToolResult> {
try {
const { browserStackProjectName, browserStackBuildName } = args;

const authString = getBrowserStackAuth(config);
const [username, accessKey] = authString.split(":");

const buildId = await listBuildId(
browserStackProjectName,
browserStackBuildName,
username,
accessKey,
);

return {
content: [
{
type: "text",
text: buildId,
},
],
};
} catch (error) {
logger.error("Error fetching build ID", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return {
content: [
{
type: "text",
text: `Error fetching build ID: ${errorMessage}`,
},
],
isError: true,
};
}
}

// Tool function that fetches RCA data
export async function fetchRCADataTool(
args: { testId: number[] },
Expand Down Expand Up @@ -144,7 +187,7 @@ export default function addRCATools(

tools.fetchRCA = server.tool(
"fetchRCA",
"Fetch AI Root Cause Analysis for failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval.",
"Fetch AI Root Cause Analysis for the current user's failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval.",
FETCH_RCA_PARAMS,
async (args) => {
try {
Expand All @@ -163,7 +206,7 @@ export default function addRCATools(

tools.getBuildId = server.tool(
"getBuildId",
"Get the BrowserStack build ID for a given project and build name.",
"Get the BrowserStack build ID for a given project and build name, scoped to the current user's builds.",
GET_BUILD_ID_PARAMS,
async (args) => {
try {
Expand All @@ -180,6 +223,25 @@ export default function addRCATools(
},
);

tools.listBuildId = server.tool(
"listBuildId",
"Get the latest build ID for a project and build name, across all users (no user filter).",
GET_BUILD_ID_PARAMS,
async (args) => {
try {
trackMCP(
"listBuildId",
server.server.getClientVersion()!,
undefined,
config,
);
return await listBuildIdTool(args, config);
} catch (error) {
return handleMCPError("listBuildId", server, config, error);
}
},
);

tools.listTestIds = server.tool(
"listTestIds",
"List test IDs from a BrowserStack Automate build, optionally filtered by status",
Expand Down
82 changes: 82 additions & 0 deletions tests/tools/extract-failed-test-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { extractFailedTestIds } from "../../src/tools/rca-agent-utils/get-failed-test-id";
import { TestStatus } from "../../src/tools/rca-agent-utils/types";

const node = (details: any, display_name?: string, children: any[] = []) =>
({ details, display_name, children }) as any;

describe("extractFailedTestIds", () => {
it("includes a status match even when run_count is 0 (the regression this fixes)", () => {
const hierarchy = [
node(
{
status: TestStatus.FAILED,
run_count: 0,
observability_url: "https://observability.bs.com/x?details=111",
},
"zero-run failure",
),
];

const result = extractFailedTestIds(hierarchy, TestStatus.FAILED);

expect(result).toEqual([
{ test_id: "111", test_name: "zero-run failure" },
]);
});

it("skips nodes with a non-matching status", () => {
const hierarchy = [
node({
status: TestStatus.PASSED,
run_count: 3,
observability_url: "https://observability.bs.com/x?details=222",
}),
];

expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]);
});

it("does not crash and skips a node with missing details", () => {
const hierarchy = [node(undefined, "no details"), node(null, "null")];

expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]);
});

it("skips a status match that has no observability_url (no id to extract)", () => {
const hierarchy = [node({ status: TestStatus.FAILED }, "no url")];

expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([]);
});

it("recurses into children and collects nested matches", () => {
const hierarchy = [
node({ status: TestStatus.PASSED }, "parent", [
node(
{
status: TestStatus.FAILED,
observability_url: "https://observability.bs.com/x?details=333",
},
"nested failure",
),
]),
];

expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([
{ test_id: "333", test_name: "nested failure" },
]);
});

it("falls back to a generated name when display_name is absent", () => {
const hierarchy = [
node({
status: TestStatus.FAILED,
observability_url: "https://observability.bs.com/x?details=444",
}),
];

expect(extractFailedTestIds(hierarchy, TestStatus.FAILED)).toEqual([
{ test_id: "444", test_name: "Test 444" },
]);
});
});
38 changes: 38 additions & 0 deletions tests/tools/rcaAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
getBuildIdTool,
fetchRCADataTool,
listTestIdsTool,
listBuildIdTool,
} from "../../src/tools/rca-agent";
import { getBuildId } from "../../src/tools/rca-agent-utils/get-build-id";
import { listBuildId } from "../../src/tools/rca-agent-utils/list-build-id";
import { getTestIds } from "../../src/tools/rca-agent-utils/get-failed-test-id";
import { getRCAData } from "../../src/tools/rca-agent-utils/rca-data";
import { formatRCAData } from "../../src/tools/rca-agent-utils/format-rca";
Expand All @@ -13,6 +15,9 @@ import { getBrowserStackAuth } from "../../src/lib/get-auth";
vi.mock("../../src/tools/rca-agent-utils/get-build-id", () => ({
getBuildId: vi.fn(),
}));
vi.mock("../../src/tools/rca-agent-utils/list-build-id", () => ({
listBuildId: vi.fn(),
}));
vi.mock("../../src/tools/rca-agent-utils/get-failed-test-id", () => ({
getTestIds: vi.fn(),
}));
Expand Down Expand Up @@ -98,6 +103,39 @@ describe("RCA Agent Tools", () => {
});
});

describe("listBuildIdTool", () => {
it("SUCCESS: returns build ID string (all users, no user filter)", async () => {
(listBuildId as Mock).mockResolvedValue("build-xyz-789");

const result = await listBuildIdTool(
{
browserStackProjectName: "MyProject",
browserStackBuildName: "MyBuild",
},
mockConfig,
);

expect(result.isError).toBeFalsy();
expect(result.content[0].text).toBe("build-xyz-789");
expect(getBrowserStackAuth).toHaveBeenCalledWith(mockConfig);
});

it("FAIL: returns isError on API failure", async () => {
(listBuildId as Mock).mockRejectedValue(new Error("Not found"));

const result = await listBuildIdTool(
{
browserStackProjectName: "Bad",
browserStackBuildName: "Bad",
},
mockConfig,
);

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Error fetching build ID");
});
});

describe("fetchRCADataTool", () => {
it("SUCCESS: returns formatted RCA data", async () => {
(getRCAData as Mock).mockResolvedValue({ analysis: "root cause" });
Expand Down