diff --git a/README.md b/README.md index 627b9b98..7782c6ca 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ As of now we support 45 tools. Get the Appium logs for App Automate session ID ``` - 20. `fetchBuildInsights` — Fetch insights about a BrowserStack build by combining build details and quality-gate results. Includes `hashed_id` (the hashed build id `listSessions` takes) when the build payload reports one. + 20. `fetchBuildInsights` — Fetch insights about a BrowserStack build by combining build details and quality-gate results. Includes `hashed_id` (the hashed build id `listSessions` takes) and `session_type`, resolved through the build's sessions when the build ran on Automate / App Automate. **Prompt example** ```text diff --git a/src/tools/automate-utils/list-session-ids.ts b/src/tools/automate-utils/list-session-ids.ts index 5b38ca3c..3204cce3 100644 --- a/src/tools/automate-utils/list-session-ids.ts +++ b/src/tools/automate-utils/list-session-ids.ts @@ -5,6 +5,14 @@ import { apiClient } from "../../lib/apiClient.js"; export const DEFAULT_SESSION_LIST_LIMIT = 10; +/** The REST session list returned 404: no Automate/App Automate build has this hashed id. */ +export class UnknownBuildError extends Error { + constructor(message: string) { + super(message); + this.name = "UnknownBuildError"; + } +} + export interface ListSessionIdsArgs { sessionType: SessionType; buildId: string; @@ -125,12 +133,11 @@ export async function listSessionIds( if (!response.ok) { if (response.status === 404) { - throw new Error( - `Invalid hashed build ID "${buildId}" for ${args.sessionType}. ` + - "Use the Automate/App Automate dashboard hashed build id " + - "(same family as App Automate getFailureLogs buildId), not the " + - "observability UUID from getBuildId or listBuildId. " + - "If you only have an observability UUID, call fetchBuildInsights and use hashed_id when present.", + throw new UnknownBuildError( + `No ${args.sessionType} build found for id "${buildId}". ` + + "Pass the Automate/App Automate dashboard hashed build id or the " + + "observability build id from getBuildId / listBuildId, and check that " + + "sessionType matches the product the build ran on.", ); } throw new Error( diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts new file mode 100644 index 00000000..578c93d0 --- /dev/null +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -0,0 +1,183 @@ +import { SessionType } from "../../lib/constants.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { apiClient } from "../../lib/apiClient.js"; +import logger from "../../logger.js"; +import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js"; +import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js"; +import { TestRun } from "../rca-agent-utils/types.js"; + +// Observability (Test Reporting & Analytics) build ids are usually UUIDs but +// some are 40-char hex, the same shape as Automate / App Automate "hashed ids". +// The two are never interchangeable, and the observability build API does not +// expose the hashed id. The deterministic bridge is any BrowserStack session that belongs to the +// build: the session detail endpoint reports its parent `build_hashed_id`. +const OBSERVABILITY_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HASHED_BUILD_ID_RE = /^[a-f0-9]{40}$/i; + +// Only the first session id is needed; most builds surface one on page one. +const MAX_TEST_RUN_PAGES = 5; + +export function isObservabilityBuildUuid(id: string): boolean { + return OBSERVABILITY_UUID_RE.test(id.trim()); +} + +export function isHashedBuildId(id: string): boolean { + return HASHED_BUILD_ID_RE.test(id.trim()); +} + +export function sessionDetailsUrl( + sessionType: SessionType, + sessionId: string, +): string { + const encoded = encodeURIComponent(sessionId); + switch (sessionType) { + case SessionType.Automate: + return `https://api.browserstack.com/automate/sessions/${encoded}.json`; + case SessionType.AppAutomate: + return `https://api.browserstack.com/app-automate/sessions/${encoded}.json`; + default: { + const _exhaustive: never = sessionType; + throw new Error(`Unsupported session type: ${_exhaustive}`); + } + } +} + +/** + * Resolve the hashed build id that a session belongs to via the Automate / + * App Automate session detail endpoint. Returns undefined when the session + * cannot be fetched or does not report a build. + */ +export async function resolveBuildIdFromSession( + sessionId: string, + sessionType: SessionType, + config: BrowserStackConfig, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + + const response = await apiClient.get({ + url: sessionDetailsUrl(sessionType, sessionId), + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + raise_error: false, + }); + + if (!response.ok) { + logger.warn( + `Could not resolve build id for ${sessionType} session ${sessionId}: ${response.status}`, + ); + return undefined; + } + + const session = (response.data as any)?.automation_session; + const buildId = session?.build_hashed_id; + return typeof buildId === "string" && buildId.trim() + ? buildId.trim() + : undefined; +} + +/** + * Find any BrowserStack session id attached to an observability build by + * walking its test runs. Returns undefined when no test reports a session + * (e.g. JUnit-uploaded builds that never ran on BrowserStack). + */ +export async function findSessionIdForObservabilityBuild( + observabilityBuildId: string, + config: BrowserStackConfig, +): Promise { + const authString = getBrowserStackAuth(config); + const auth = Buffer.from(authString).toString("base64"); + const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${encodeURIComponent(observabilityBuildId)}/testRuns`; + + let nextPage: string | undefined; + for (let page = 0; page < MAX_TEST_RUN_PAGES; page++) { + const response = await apiClient.get({ + url: baseUrl, + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${auth}`, + }, + ...(nextPage ? { params: { next_page: nextPage } } : {}), + raise_error: false, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch test runs for observability build "${observabilityBuildId}": ` + + `${response.status} ${response.statusText}`, + ); + } + + const data = response.data; + const withSession = extractTestIds(data?.hierarchy ?? []).find( + (test) => test.session_id, + ); + if (withSession?.session_id) { + return withSession.session_id; + } + + if (!data?.pagination?.has_next || !data.pagination.next_page) { + return undefined; + } + nextPage = data.pagination.next_page; + } + + logger.warn( + `resolveHashedBuildId: no session id in first ${MAX_TEST_RUN_PAGES} pages of build ${observabilityBuildId}`, + ); + return undefined; +} + +export interface ResolvedHashedBuildId { + hashedBuildId: string; + sessionId: string; + sessionType: SessionType; +} + +/** + * Convert an observability build UUID into the Automate / App Automate hashed + * build id in two deterministic API calls: pick any session of the build from + * its test runs, then read `build_hashed_id` from that session's details. + * + * When `sessionType` is omitted, Automate is tried first, then App Automate. + */ +export async function resolveHashedBuildId( + observabilityBuildId: string, + config: BrowserStackConfig, + sessionType?: SessionType, +): Promise { + const buildId = observabilityBuildId.trim(); + + const sessionId = await findSessionIdForObservabilityBuild(buildId, config); + if (!sessionId) { + throw new Error( + `No BrowserStack sessions found for observability build "${buildId}". ` + + "Only builds that ran on Automate or App Automate have sessions to list; " + + "uploaded-report builds (e.g. JUnit) do not.", + ); + } + + const candidates: SessionType[] = sessionType + ? [sessionType] + : [SessionType.Automate, SessionType.AppAutomate]; + + for (const candidate of candidates) { + const hashedBuildId = await resolveBuildIdFromSession( + sessionId, + candidate, + config, + ); + if (hashedBuildId) { + return { hashedBuildId, sessionId, sessionType: candidate }; + } + } + + throw new Error( + `Could not resolve the hashed build id for observability build "${buildId}" ` + + `from session "${sessionId}" (tried: ${candidates.join(", ")}).`, + ); +} diff --git a/src/tools/automate.ts b/src/tools/automate.ts index b8a0dcbb..5c1ae7e6 100644 --- a/src/tools/automate.ts +++ b/src/tools/automate.ts @@ -5,7 +5,12 @@ import { fetchAutomationScreenshots } from "./automate-utils/fetch-screenshots.j import { DEFAULT_SESSION_LIST_LIMIT, listSessionIds, + UnknownBuildError, } from "./automate-utils/list-session-ids.js"; +import { + isObservabilityBuildUuid, + resolveHashedBuildId, +} from "./automate-utils/resolve-hashed-build-id.js"; import { SessionType } from "../lib/constants.js"; import { trackMCP } from "../lib/instrumentation.js"; import logger from "../logger.js"; @@ -81,26 +86,59 @@ export async function listSessionIdsTool( config: BrowserStackConfig, ): Promise { try { - const sessions = await listSessionIds(args, config); - if (sessions.length === 0) { - return { - content: [ - { - type: "text", - text: "No sessions found for this hashed build ID.", - }, - ], - }; - } + // Accept the observability build id too. Observability ids are usually + // UUIDs but can also be 40-char hex like Automate hashed ids, so shape + // alone is not enough: try the REST list first and resolve on a miss. + const inputId = args.buildId.trim(); + let buildId = inputId; + let resolvedNote: string | undefined; - return { - content: [ - { - type: "text", - text: JSON.stringify(sessions, null, 2), - }, - ], + const resolve = async () => { + const resolved = await resolveHashedBuildId( + inputId, + config, + args.sessionType, + ); + buildId = resolved.hashedBuildId; + resolvedNote = `Resolved observability build ${inputId} to hashed build id ${buildId}.`; }; + + let sessions; + if (isObservabilityBuildUuid(inputId)) { + await resolve(); + sessions = await listSessionIds({ ...args, buildId }, config); + } else { + try { + sessions = await listSessionIds({ ...args, buildId }, config); + } catch (error) { + if (!(error instanceof UnknownBuildError)) throw error; + try { + await resolve(); + } catch (resolveError) { + logger.debug( + "listSessions: id is neither a known hashed build nor a resolvable observability build", + resolveError, + ); + throw error; + } + sessions = await listSessionIds({ ...args, buildId }, config); + } + } + + const content: CallToolResult["content"] = [ + { + type: "text", + text: + sessions.length === 0 + ? "No sessions found for this hashed build ID." + : JSON.stringify(sessions, null, 2), + }, + ]; + if (resolvedNote) { + content.push({ type: "text", text: resolvedNote }); + } + + return { content }; } catch (error) { logger.error("Error listing session IDs", error); throw error; @@ -173,7 +211,7 @@ export default function addAutomationTools( buildId: z .string() .describe( - "Dashboard hashed build id or fetchBuildInsights hashed_id — not the getBuildId UUID.", + "Hashed build id from the dashboard, or the observability build id from getBuildId.", ), limit: z .number() diff --git a/src/tools/build-insights.ts b/src/tools/build-insights.ts index f6586f3a..2f90a4f5 100644 --- a/src/tools/build-insights.ts +++ b/src/tools/build-insights.ts @@ -5,6 +5,7 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "../lib/types.js"; import { fetchFromBrowserStackAPI, handleMCPError } from "../lib/utils.js"; import { trackMCP } from "../lib/instrumentation.js"; +import { resolveHashedBuildId } from "./automate-utils/resolve-hashed-build-id.js"; // Tool function that fetches build insights from two APIs export async function fetchBuildInsightsTool( @@ -24,7 +25,11 @@ export async function fetchBuildInsightsTool( }), ]); - const hashed_id = extractHashedBuildId(buildData); + const { hashed_id, session_type } = await resolveInsightsHashedId( + args.buildId, + buildData, + config, + ); // Select useful fields for users const insights = { @@ -45,6 +50,7 @@ export async function fetchBuildInsightsTool( vcs_name: buildData.vcs_info?.name, quality_gate_result: qualityData?.quality_gate_result, ...(hashed_id ? { hashed_id } : {}), + ...(session_type ? { session_type } : {}), }; const qualityProfiles = qualityData?.quality_profiles?.map( @@ -74,6 +80,32 @@ export async function fetchBuildInsightsTool( } } +/** + * The observability build payload does not carry the Automate hashed build id + * today. Prefer it if the API ever adds one; otherwise resolve it through any + * session of the build (two deterministic REST calls). Never blocks insights. + */ +async function resolveInsightsHashedId( + observabilityBuildId: string, + buildData: unknown, + config: BrowserStackConfig, +): Promise<{ hashed_id?: string; session_type?: string }> { + const direct = extractHashedBuildId(buildData); + if (direct) { + return { hashed_id: direct }; + } + try { + const resolved = await resolveHashedBuildId(observabilityBuildId, config); + return { + hashed_id: resolved.hashedBuildId, + session_type: resolved.sessionType, + }; + } catch (error) { + logger.warn("Could not resolve hashed build id for build insights", error); + return {}; + } +} + function extractHashedBuildId(buildData: any): string | undefined { const candidates = [ buildData?.hashed_id, @@ -99,7 +131,7 @@ export default function addBuildInsightsTools( tools.fetchBuildInsights = server.tool( "fetchBuildInsights", - "Fetch build details and quality gate results. Includes hashed_id, the build id listSessions takes.", + "Fetch build details and quality gate results. Includes hashed_id and session_type for listSessions.", { buildId: z.string().describe("The build UUID of the BrowserStack build"), }, diff --git a/src/tools/failurelogs-utils/resolve-app-build-id.ts b/src/tools/failurelogs-utils/resolve-app-build-id.ts index 864919a2..a4159638 100644 --- a/src/tools/failurelogs-utils/resolve-app-build-id.ts +++ b/src/tools/failurelogs-utils/resolve-app-build-id.ts @@ -1,35 +1,10 @@ -import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { SessionType } from "../../lib/constants.js"; import { BrowserStackConfig } from "../../lib/types.js"; -import { apiClient } from "../../lib/apiClient.js"; -import logger from "../../logger.js"; +import { resolveBuildIdFromSession } from "../automate-utils/resolve-hashed-build-id.js"; export async function resolveAppAutomateBuildId( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/app-automate/sessions/${encodeURIComponent(sessionId)}.json`; - const authString = getBrowserStackAuth(config); - const auth = Buffer.from(authString).toString("base64"); - - const response = await apiClient.get({ - url, - headers: { - "Content-Type": "application/json", - Authorization: `Basic ${auth}`, - }, - raise_error: false, - }); - - if (!response.ok) { - logger.warn( - `Could not resolve build id for app-automate session ${sessionId}: ${response.status}`, - ); - return undefined; - } - - const session = (response.data as any)?.automation_session; - const buildId = session?.build_hashed_id; - return typeof buildId === "string" && buildId.trim() - ? buildId.trim() - : undefined; + return resolveBuildIdFromSession(sessionId, SessionType.AppAutomate, config); } diff --git a/tests/tools/buildInsights.test.ts b/tests/tools/buildInsights.test.ts index 6cdd2872..e94b0304 100644 --- a/tests/tools/buildInsights.test.ts +++ b/tests/tools/buildInsights.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { fetchBuildInsightsTool } from "../../src/tools/build-insights"; import { fetchFromBrowserStackAPI } from "../../src/lib/utils"; +import { resolveHashedBuildId } from "../../src/tools/automate-utils/resolve-hashed-build-id"; vi.mock("../../src/lib/utils", () => ({ fetchFromBrowserStackAPI: vi.fn(), handleMCPError: vi.fn(), })); vi.mock("../../src/logger", () => ({ - default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); vi.mock("../../src/lib/instrumentation", () => ({ trackMCP: vi.fn() })); +vi.mock("../../src/tools/automate-utils/resolve-hashed-build-id", () => ({ + resolveHashedBuildId: vi.fn(), +})); const mockConfig = { "browserstack-username": "fake-user", @@ -21,6 +25,10 @@ const HASHED_ID = "ca9cccc228cf0e3ff3cb90dd62e2e2bfb4b20bc7"; describe("fetchBuildInsightsTool", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: the build has no resolvable sessions; insights still succeed. + (resolveHashedBuildId as Mock).mockRejectedValue( + new Error("No BrowserStack sessions found"), + ); }); it("SUCCESS: returns build details and quality gates", async () => { @@ -70,9 +78,30 @@ describe("fetchBuildInsightsTool", () => { ); expect(result.content[0].text).toContain(`"hashed_id": "${HASHED_ID}"`); + expect(resolveHashedBuildId).not.toHaveBeenCalled(); + }); + + it("SUCCESS: resolves hashed_id and session_type through the build's sessions", async () => { + (fetchFromBrowserStackAPI as Mock) + .mockResolvedValueOnce({ name: "Test Build" }) + .mockResolvedValueOnce({}); + (resolveHashedBuildId as Mock).mockResolvedValue({ + hashedBuildId: HASHED_ID, + sessionId: "sess-1", + sessionType: "app-automate", + }); + + const result = await fetchBuildInsightsTool( + { buildId: "build-123" }, + mockConfig, + ); + + expect(resolveHashedBuildId).toHaveBeenCalledWith("build-123", mockConfig); + expect(result.content[0].text).toContain(`"hashed_id": "${HASHED_ID}"`); + expect(result.content[0].text).toContain('"session_type": "app-automate"'); }); - it("SUCCESS: omits hashed_id when the build payload has none", async () => { + it("SUCCESS: omits hashed_id when the build payload has none and resolution fails", async () => { (fetchFromBrowserStackAPI as Mock) .mockResolvedValueOnce({ name: "Test Build" }) .mockResolvedValueOnce({}); diff --git a/tests/tools/list-session-ids.test.ts b/tests/tools/list-session-ids.test.ts index 4b56bb44..81616315 100644 --- a/tests/tools/list-session-ids.test.ts +++ b/tests/tools/list-session-ids.test.ts @@ -8,6 +8,7 @@ import { sessionsListUrl, } from "../../src/tools/automate-utils/list-session-ids"; import { listSessionIdsTool } from "../../src/tools/automate"; +import { resolveHashedBuildId } from "../../src/tools/automate-utils/resolve-hashed-build-id"; vi.mock("../../src/lib/apiClient", () => ({ apiClient: { @@ -18,6 +19,18 @@ vi.mock("../../src/logger", () => ({ default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); vi.mock("../../src/lib/instrumentation", () => ({ trackMCP: vi.fn() })); +vi.mock( + "../../src/tools/automate-utils/resolve-hashed-build-id", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/tools/automate-utils/resolve-hashed-build-id") + >()), + resolveHashedBuildId: vi.fn(), + }), +); + +const OBS_UUID = "3f2c1a4e-9b7d-4c6e-8a1f-2d3e4f5a6b7c"; +const HASHED_BUILD = "001a4e3bced4a35275f5e39160a205fbcd2ba65b"; const mockConfig = { "browserstack-username": "fake-user", @@ -179,7 +192,7 @@ describe("listSessionIds", () => { { sessionType: SessionType.Automate, buildId: "bad" }, mockConfig, ), - ).rejects.toThrow(/Invalid hashed build ID/); + ).rejects.toThrow(/No automate build found/); }); it("throws on other HTTP errors", async () => { @@ -217,6 +230,109 @@ describe("listSessionIdsTool", () => { expect(result.isError).toBeFalsy(); const parsed = JSON.parse(result.content[0].text as string); expect(parsed[0].sessionId).toBe("sess-aaa"); + expect(resolveHashedBuildId).not.toHaveBeenCalled(); + }); + + it("resolves an observability build UUID to the hashed id before listing", async () => { + (resolveHashedBuildId as Mock).mockResolvedValue({ + hashedBuildId: HASHED_BUILD, + sessionId: "sess-aaa", + sessionType: SessionType.AppAutomate, + }); + (apiClient.get as Mock).mockResolvedValue({ + ok: true, + status: 200, + data: samplePayload, + }); + + const result = await listSessionIdsTool( + { sessionType: SessionType.AppAutomate, buildId: OBS_UUID }, + mockConfig, + ); + + expect(resolveHashedBuildId).toHaveBeenCalledWith( + OBS_UUID, + mockConfig, + SessionType.AppAutomate, + ); + expect(apiClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://api-cloud.browserstack.com/app-automate/builds/${HASHED_BUILD}/sessions.json`, + }), + ); + // Session list stays in content[0] (JSON-parseable); the note follows. + const parsed = JSON.parse(result.content[0].text as string); + expect(parsed[0].sessionId).toBe("sess-aaa"); + expect(result.content[1].text).toContain( + `Resolved observability build ${OBS_UUID} to hashed build id ${HASHED_BUILD}`, + ); + }); + + it("falls back to resolution when a 40-hex id is an observability id, not a hashed id", async () => { + const OBS_HEX = "419836ad9989011f736793ef058e802bac257be3"; + (apiClient.get as Mock) + .mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found", data: {} }) + .mockResolvedValueOnce({ ok: true, status: 200, data: samplePayload }); + (resolveHashedBuildId as Mock).mockResolvedValue({ + hashedBuildId: HASHED_BUILD, + sessionId: "sess-aaa", + sessionType: SessionType.Automate, + }); + + const result = await listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: OBS_HEX }, + mockConfig, + ); + + expect(resolveHashedBuildId).toHaveBeenCalledWith( + OBS_HEX, + mockConfig, + SessionType.Automate, + ); + expect((apiClient.get as Mock).mock.calls[0][0].url).toContain( + `/builds/${OBS_HEX}/sessions.json`, + ); + expect((apiClient.get as Mock).mock.calls[1][0].url).toContain( + `/builds/${HASHED_BUILD}/sessions.json`, + ); + expect(JSON.parse(result.content[0].text as string)[0].sessionId).toBe( + "sess-aaa", + ); + expect(result.content[1].text).toContain( + `Resolved observability build ${OBS_HEX} to hashed build id ${HASHED_BUILD}`, + ); + }); + + it("rethrows the original 404 when a 40-hex id resolves to nothing", async () => { + (apiClient.get as Mock).mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + data: {}, + }); + (resolveHashedBuildId as Mock).mockRejectedValue(new Error("No BrowserStack sessions found")); + + await expect( + listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: "deadbeef".repeat(5) }, + mockConfig, + ), + ).rejects.toThrow(/No automate build found/); + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + it("surfaces the resolver error for a UUID with no sessions", async () => { + (resolveHashedBuildId as Mock).mockRejectedValue( + new Error("No BrowserStack sessions found for observability build"), + ); + + await expect( + listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: OBS_UUID }, + mockConfig, + ), + ).rejects.toThrow(/No BrowserStack sessions found/); + expect(apiClient.get).not.toHaveBeenCalled(); }); it("returns success with a note when the list is empty", async () => { diff --git a/tests/tools/resolve-hashed-build-id.test.ts b/tests/tools/resolve-hashed-build-id.test.ts new file mode 100644 index 00000000..164e4389 --- /dev/null +++ b/tests/tools/resolve-hashed-build-id.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { SessionType } from "../../src/lib/constants"; +import { apiClient } from "../../src/lib/apiClient"; +import { + findSessionIdForObservabilityBuild, + isHashedBuildId, + isObservabilityBuildUuid, + resolveBuildIdFromSession, + resolveHashedBuildId, + sessionDetailsUrl, +} from "../../src/tools/automate-utils/resolve-hashed-build-id"; + +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { get: vi.fn() }, +})); +vi.mock("../../src/lib/get-auth", () => ({ + getBrowserStackAuth: () => "user:key", +})); +vi.mock("../../src/logger", () => ({ + default: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); +vi.mock("../../src/tools/rca-agent-utils/constants", () => ({ + getAutomationBaseUrl: () => "https://api-automation.browserstack.com", +})); + +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +}; + +const OBS_UUID = "3f2c1a4e-9b7d-4c6e-8a1f-2d3e4f5a6b7c"; +const HASHED_BUILD = "001a4e3bced4a35275f5e39160a205fbcd2ba65b"; +const SESSION_ID = "9f8e7d6c5b4a39281706f5e4d3c2b1a0f9e8d7c6"; + +function testRunsPage( + tests: Array<{ id: number; session_id?: string | null }>, + nextPage?: string, +) { + return { + ok: true, + status: 200, + data: { + hierarchy: tests.map((t) => ({ + display_name: `test ${t.id}`, + details: { + status: "failed", + observability_url: `https://observability.browserstack.com/x?details=${t.id}`, + session_id: t.session_id, + }, + })), + pagination: nextPage + ? { has_next: true, next_page: nextPage } + : { has_next: false, next_page: null }, + }, + }; +} + +function sessionDetails(buildHashedId?: string) { + return { + ok: true, + status: 200, + data: { + automation_session: buildHashedId + ? { build_hashed_id: buildHashedId } + : {}, + }, + }; +} + +describe("id shape helpers", () => { + it("recognises observability UUIDs", () => { + expect(isObservabilityBuildUuid(OBS_UUID)).toBe(true); + expect(isObservabilityBuildUuid(` ${OBS_UUID.toUpperCase()} `)).toBe(true); + expect(isObservabilityBuildUuid(HASHED_BUILD)).toBe(false); + expect(isObservabilityBuildUuid("not-an-id")).toBe(false); + }); + + it("recognises 40-char hashed build ids", () => { + expect(isHashedBuildId(HASHED_BUILD)).toBe(true); + expect(isHashedBuildId(OBS_UUID)).toBe(false); + }); +}); + +describe("sessionDetailsUrl", () => { + it("targets the Automate and App Automate session endpoints", () => { + expect(sessionDetailsUrl(SessionType.Automate, "s/1")).toBe( + "https://api.browserstack.com/automate/sessions/s%2F1.json", + ); + expect(sessionDetailsUrl(SessionType.AppAutomate, "s1")).toBe( + "https://api.browserstack.com/app-automate/sessions/s1.json", + ); + }); +}); + +describe("resolveBuildIdFromSession", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns build_hashed_id from the session payload", async () => { + (apiClient.get as Mock).mockResolvedValue(sessionDetails(HASHED_BUILD)); + + await expect( + resolveBuildIdFromSession(SESSION_ID, SessionType.Automate, config), + ).resolves.toBe(HASHED_BUILD); + expect((apiClient.get as Mock).mock.calls[0][0].url).toBe( + `https://api.browserstack.com/automate/sessions/${SESSION_ID}.json`, + ); + }); + + it("returns undefined on HTTP failure or a missing field", async () => { + (apiClient.get as Mock).mockResolvedValueOnce({ ok: false, status: 404 }); + await expect( + resolveBuildIdFromSession(SESSION_ID, SessionType.Automate, config), + ).resolves.toBeUndefined(); + + (apiClient.get as Mock).mockResolvedValueOnce(sessionDetails()); + await expect( + resolveBuildIdFromSession(SESSION_ID, SessionType.AppAutomate, config), + ).resolves.toBeUndefined(); + }); +}); + +describe("findSessionIdForObservabilityBuild", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the first session id in the test runs, skipping 'null'", async () => { + (apiClient.get as Mock).mockResolvedValue( + testRunsPage([ + { id: 1, session_id: "null" }, + { id: 2, session_id: SESSION_ID }, + { id: 3, session_id: "other" }, + ]), + ); + + await expect( + findSessionIdForObservabilityBuild(OBS_UUID, config), + ).resolves.toBe(SESSION_ID); + expect((apiClient.get as Mock).mock.calls[0][0].url).toBe( + `https://api-automation.browserstack.com/ext/v1/builds/${OBS_UUID}/testRuns`, + ); + }); + + it("follows pagination until a session id appears", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce(testRunsPage([{ id: 1, session_id: null }], "p2")) + .mockResolvedValueOnce(testRunsPage([{ id: 2, session_id: SESSION_ID }])); + + await expect( + findSessionIdForObservabilityBuild(OBS_UUID, config), + ).resolves.toBe(SESSION_ID); + expect((apiClient.get as Mock).mock.calls[1][0].params).toEqual({ + next_page: "p2", + }); + }); + + it("returns undefined when no test carries a session id", async () => { + (apiClient.get as Mock).mockResolvedValue( + testRunsPage([{ id: 1, session_id: null }]), + ); + + await expect( + findSessionIdForObservabilityBuild(OBS_UUID, config), + ).resolves.toBeUndefined(); + }); + + it("stops after the page cap even if more pages exist", async () => { + (apiClient.get as Mock).mockResolvedValue( + testRunsPage([{ id: 1, session_id: null }], "more"), + ); + + await expect( + findSessionIdForObservabilityBuild(OBS_UUID, config), + ).resolves.toBeUndefined(); + expect(apiClient.get).toHaveBeenCalledTimes(5); + }); + + it("throws when the test runs API fails", async () => { + (apiClient.get as Mock).mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + await expect( + findSessionIdForObservabilityBuild(OBS_UUID, config), + ).rejects.toThrow(/Failed to fetch test runs/); + }); +}); + +describe("resolveHashedBuildId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resolves UUID → session → hashed build id with an explicit session type", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce(testRunsPage([{ id: 1, session_id: SESSION_ID }])) + .mockResolvedValueOnce(sessionDetails(HASHED_BUILD)); + + await expect( + resolveHashedBuildId(OBS_UUID, config, SessionType.AppAutomate), + ).resolves.toEqual({ + hashedBuildId: HASHED_BUILD, + sessionId: SESSION_ID, + sessionType: SessionType.AppAutomate, + }); + expect(apiClient.get).toHaveBeenCalledTimes(2); + expect((apiClient.get as Mock).mock.calls[1][0].url).toContain( + "/app-automate/sessions/", + ); + }); + + it("falls back from Automate to App Automate when the type is unknown", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce(testRunsPage([{ id: 1, session_id: SESSION_ID }])) + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce(sessionDetails(HASHED_BUILD)); + + await expect(resolveHashedBuildId(OBS_UUID, config)).resolves.toEqual({ + hashedBuildId: HASHED_BUILD, + sessionId: SESSION_ID, + sessionType: SessionType.AppAutomate, + }); + expect((apiClient.get as Mock).mock.calls[1][0].url).toContain( + "/automate/sessions/", + ); + expect((apiClient.get as Mock).mock.calls[2][0].url).toContain( + "/app-automate/sessions/", + ); + }); + + it("explains when the build has no BrowserStack sessions", async () => { + (apiClient.get as Mock).mockResolvedValue( + testRunsPage([{ id: 1, session_id: null }]), + ); + + await expect(resolveHashedBuildId(OBS_UUID, config)).rejects.toThrow( + /No BrowserStack sessions found/, + ); + }); + + it("fails clearly when the session does not report a build", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce(testRunsPage([{ id: 1, session_id: SESSION_ID }])) + .mockResolvedValue(sessionDetails()); + + await expect( + resolveHashedBuildId(OBS_UUID, config, SessionType.Automate), + ).rejects.toThrow(/Could not resolve the hashed build id/); + }); +});