Skip to content
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
34 changes: 34 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,34 @@
import { apiClient } from "../../lib/apiClient.js";

/**
* 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 authHeader =
"Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64");

const response = await apiClient.get({
url: "https://api-automation.browserstack.com/ext/v1/builds/latest",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
params: {
project_name: projectName,
build_name: buildName,
},
});

const buildId = response.data?.build_id;
if (!buildId) {
throw new Error(
`No build found for project "${projectName}" and build "${buildName}"`,
);
}
return buildId;
}
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
Loading