diff --git a/src/tools/rca-agent-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index a474b2b..233a1e0 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -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) { if (node.details?.observability_url) { const idMatch = node.details.observability_url.match(/details=(\d+)/); if (idMatch) { diff --git a/src/tools/rca-agent-utils/list-build-id.ts b/src/tools/rca-agent-utils/list-build-id.ts new file mode 100644 index 0000000..0f37af4 --- /dev/null +++ b/src/tools/rca-agent-utils/list-build-id.ts @@ -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 { + 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; +} diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 1d6e670..1102d69 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -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"; @@ -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 { + 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[] }, @@ -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 { @@ -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 { @@ -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", diff --git a/tests/tools/extract-failed-test-ids.test.ts b/tests/tools/extract-failed-test-ids.test.ts new file mode 100644 index 0000000..226971a --- /dev/null +++ b/tests/tools/extract-failed-test-ids.test.ts @@ -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" }, + ]); + }); +}); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index a7a38d8..c5b7d60 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -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"; @@ -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(), })); @@ -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" });