diff --git a/src/lib/error.ts b/src/lib/error.ts index f08b2d77..64922e1d 100644 --- a/src/lib/error.ts +++ b/src/lib/error.ts @@ -79,3 +79,19 @@ export function formatAxiosError( isError: true, }; } + +/** A failed HTTP request, keeping the status for callers to branch on. */ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "HttpError"; + } +} + +export function isNotFoundError(error: unknown): boolean { + if (error instanceof HttpError) return error.status === 404; + return error instanceof AxiosError && error.response?.status === 404; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fe9750c3..d5accdc0 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,7 @@ import { getBrowserStackAuth } from "./get-auth.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { trackMCP } from "../index.js"; +import { HttpError } from "./error.js"; export function sanitizeUrlParam(param: string): string { // Remove any characters that could be used for command injection @@ -33,15 +34,13 @@ export async function fetchFromBrowserStackAPI( const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const res = await fetch(url, { - headers: { - Authorization: `Basic ${auth}`, - }, - }); + const init = { headers: { Authorization: `Basic ${auth}` } }; + const res = await fetch(url, init).catch(() => fetch(url, init)); if (!res.ok) { - throw new Error( + throw new HttpError( `Failed to fetch from ${url}: ${res.status} ${res.statusText}`, + res.status, ); } diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts index 9d538500..8b2eb5c3 100644 --- a/src/tools/automate-utils/resolve-hashed-build-id.ts +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -1,3 +1,4 @@ +import { HttpError } from "../../lib/error.js"; import { SessionType } from "../../lib/constants.js"; import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; @@ -7,14 +8,12 @@ import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js"; import { TestRun } from "../rca-agent-utils/types.js"; import { getRegionalHost, sessionApiHost } from "../../lib/regional-host.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`. +// Automate build ids are hex; TRA build ids are base-36 (older ones UUIDs). A TRA +// id can be all hex by chance, so hex only means "probably Automate". 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; +const TRA_BUILD_ID_RE = /^[a-z0-9]{40}$/i; // Only the first session id is needed; most builds surface one on page one. const MAX_TEST_RUN_PAGES = 5; @@ -27,6 +26,29 @@ export function isHashedBuildId(id: string): boolean { return HASHED_BUILD_ID_RE.test(id.trim()); } +export function isTraBuildId(id: string): boolean { + const trimmed = id.trim(); + if (OBSERVABILITY_UUID_RE.test(trimmed)) return true; + return TRA_BUILD_ID_RE.test(trimmed) && !HASHED_BUILD_ID_RE.test(trimmed); +} + +export function otherSessionType(sessionType: SessionType): SessionType { + return sessionType === SessionType.Automate + ? SessionType.AppAutomate + : SessionType.Automate; +} + +export class NoSessionsError extends Error { + constructor(buildId: string) { + super( + `TRA build "${buildId}" has no BrowserStack sessions: its results were uploaded ` + + "(e.g. a JUnit report), so it has no Automate sessions, logs or video. " + + "Use listTestIds and fetchRCA for this build instead.", + ); + this.name = "NoSessionsError"; + } +} + export function sessionDetailsUrl( sessionType: SessionType, sessionId: string, @@ -45,10 +67,26 @@ export function sessionDetailsUrl( } } +export async function detectSession( + sessionId: string, + preferred: SessionType, + config: BrowserStackConfig, +): Promise<{ sessionType: SessionType; hashedBuildId: string } | undefined> { + for (const candidate of [preferred, otherSessionType(preferred)]) { + const hashedBuildId = await resolveBuildIdFromSession( + sessionId, + candidate, + config, + ); + if (hashedBuildId) return { sessionType: candidate, hashedBuildId }; + } + return undefined; +} + /** * 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. + * App Automate session detail endpoint. Returns undefined when the product does + * not know the session (404) or it reports no build; other failures throw. */ export async function resolveBuildIdFromSession( sessionId: string, @@ -67,11 +105,12 @@ export async function resolveBuildIdFromSession( raise_error: false, }); + if (response.status === 404) return undefined; if (!response.ok) { - logger.warn( - `Could not resolve build id for ${sessionType} session ${sessionId}: ${response.status}`, + throw new HttpError( + `Failed to read ${sessionType} session "${sessionId}": ${response.status} ${response.statusText}`.trim(), + response.status, ); - return undefined; } const session = (response.data as any)?.automation_session; @@ -107,9 +146,10 @@ export async function findSessionIdForObservabilityBuild( }); if (!response.ok) { - throw new Error( + throw new HttpError( `Failed to fetch test runs for observability build "${observabilityBuildId}": ` + `${response.status} ${response.statusText}`, + response.status, ); } @@ -144,7 +184,7 @@ export interface ResolvedHashedBuildId { * 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. + * The given `sessionType` is tried first (Automate when omitted), then the other. */ export async function resolveHashedBuildId( observabilityBuildId: string, @@ -155,16 +195,11 @@ export async function resolveHashedBuildId( 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.", - ); + throw new NoSessionsError(buildId); } - const candidates: SessionType[] = sessionType - ? [sessionType] - : [SessionType.Automate, SessionType.AppAutomate]; + const first = sessionType ?? SessionType.Automate; + const candidates: SessionType[] = [first, otherSessionType(first)]; for (const candidate of candidates) { const hashedBuildId = await resolveBuildIdFromSession( diff --git a/src/tools/automate-utils/resolve-tra-build-id.ts b/src/tools/automate-utils/resolve-tra-build-id.ts new file mode 100644 index 00000000..fedca044 --- /dev/null +++ b/src/tools/automate-utils/resolve-tra-build-id.ts @@ -0,0 +1,159 @@ +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 { HttpError, isNotFoundError } from "../../lib/error.js"; +import { fetchLatestBuildId } from "../rca-agent-utils/tra-builds.js"; +import { sessionsListUrl } from "./list-session-ids.js"; +import { + resolveHashedBuildId, + sessionDetailsUrl, +} from "./resolve-hashed-build-id.js"; + +export interface ResolvedTraBuild { + traBuildId: string; + hashedBuildId: string; + sessionType: SessionType; + projectName: string; + buildName: string; +} + +export class TraBuildNotFoundError extends Error { + readonly unknownToAutomate: boolean; + + constructor(message: string, unknownToAutomate = false) { + super(message); + this.unknownToAutomate = unknownToAutomate; + this.name = "TraBuildNotFoundError"; + } +} + +async function readAutomateBuild( + hashedBuildId: string, + sessionType: SessionType, + config: BrowserStackConfig, +): Promise<{ projectName?: string; buildName?: string } | undefined> { + const headers = { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(getBrowserStackAuth(config)).toString("base64")}`, + }; + const response = await apiClient.get({ + url: sessionsListUrl(sessionType, hashedBuildId), + headers, + params: { limit: 1 }, + raise_error: false, + }); + if (response.status === 404) return undefined; + if (!response.ok) { + throw new HttpError( + `Failed to read ${sessionType} build "${hashedBuildId}": ${response.status} ${response.statusText}`.trim(), + response.status, + ); + } + const items = Array.isArray(response.data) ? response.data : []; + let session = items[0]?.automation_session; + if (!session) return {}; + + if ((!session.project_name || !session.build_name) && session.hashed_id) { + const detail = await apiClient.get({ + url: sessionDetailsUrl(sessionType, session.hashed_id), + headers, + raise_error: false, + }); + if (!detail.ok && detail.status !== 404) { + throw new HttpError( + `Failed to read ${sessionType} session "${session.hashed_id}": ${detail.status} ${detail.statusText}`.trim(), + detail.status, + ); + } + if (detail.ok) session = detail.data?.automation_session ?? session; + } + return { + projectName: session.project_name ?? undefined, + buildName: session.build_name ?? undefined, + }; +} + +export async function resolveTraBuildIdFromHashed( + hashedBuildId: string, + config: BrowserStackConfig, + preferred: SessionType = SessionType.Automate, +): Promise { + const id = hashedBuildId.trim(); + const order = [ + preferred, + preferred === SessionType.Automate + ? SessionType.AppAutomate + : SessionType.Automate, + ]; + + for (const sessionType of order) { + const build = await readAutomateBuild(id, sessionType, config); + if (!build) continue; + if (!build.projectName || !build.buildName) { + throw new TraBuildNotFoundError( + `${sessionType} build "${id}" does not report a project and build name, ` + + "so its TRA build cannot be found. Use listSessions with this id for its sessions.", + ); + } + + const traBuildId = await fetchLatestBuildId( + build.projectName, + build.buildName, + config, + ); + if (!traBuildId) { + throw new TraBuildNotFoundError( + `${sessionType} build "${id}" (project "${build.projectName}", build ` + + `"${build.buildName}") has no TRA build: only runs through the BrowserStack SDK ` + + "report tests to TRA. Use listSessions and getFailureLogs with this id instead.", + ); + } + + const confirmed = await resolveHashedBuildId( + traBuildId, + config, + sessionType, + ).catch((error) => { + if (error instanceof HttpError && !isNotFoundError(error)) throw error; + return undefined; + }); + if (confirmed?.hashedBuildId !== id) { + throw new TraBuildNotFoundError( + `${sessionType} build "${id}" is not the latest TRA build named ` + + `"${build.buildName}" in project "${build.projectName}". Call listBuildId with ` + + "that project, build name and the build number from the dashboard.", + ); + } + + return { + traBuildId, + hashedBuildId: id, + sessionType: confirmed.sessionType, + projectName: build.projectName, + buildName: build.buildName, + }; + } + + throw new TraBuildNotFoundError( + `No Automate or App Automate build has id "${id}". Use the TRA build id from listBuildId.`, + true, + ); +} + +export async function mapHashedToTra( + hashedBuildId: string, + original: unknown, + config: BrowserStackConfig, +): Promise { + try { + return await resolveTraBuildIdFromHashed(hashedBuildId, config); + } catch (error) { + if (!(error instanceof TraBuildNotFoundError)) throw error; + if (!error.unknownToAutomate) throw error; + throw new Error( + `${original instanceof Error ? original.message : String(original)}. ` + + "No TRA build has this id; get one with listBuildId.", + ); + } +} diff --git a/src/tools/automate.ts b/src/tools/automate.ts index 963a7c1e..54fda09f 100644 --- a/src/tools/automate.ts +++ b/src/tools/automate.ts @@ -1,3 +1,4 @@ +import { HttpError, isNotFoundError } from "../lib/error.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; @@ -8,7 +9,9 @@ import { UnknownBuildError, } from "./automate-utils/list-session-ids.js"; import { - isObservabilityBuildUuid, + isTraBuildId, + NoSessionsError, + otherSessionType, resolveHashedBuildId, } from "./automate-utils/resolve-hashed-build-id.js"; import { SessionType } from "../lib/constants.js"; @@ -86,42 +89,74 @@ export async function listSessionIdsTool( config: BrowserStackConfig, ): Promise { try { - // 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(); + const notes: string[] = []; + let sessionType = args.sessionType; let buildId = inputId; - let resolvedNote: string | undefined; + let sessions: Awaited> | undefined; - const resolve = async () => { + const switched = (actual: SessionType) => { + if (actual !== args.sessionType) { + notes.push( + `This build ran on ${actual}, not ${args.sessionType}; use sessionType "${actual}" for its sessions.`, + ); + } + sessionType = actual; + }; + + const fromTraBuild = async () => { const resolved = await resolveHashedBuildId( inputId, config, args.sessionType, ); buildId = resolved.hashedBuildId; - resolvedNote = `Resolved observability build ${inputId} to hashed build id ${buildId}.`; + switched(resolved.sessionType); + notes.push( + `Resolved TRA build ${inputId} to hashed build id ${buildId}.`, + ); + return listSessionIds({ ...args, sessionType, buildId }, config); }; - let sessions; - if (isObservabilityBuildUuid(inputId)) { - await resolve(); - sessions = await listSessionIds({ ...args, buildId }, config); + if (isTraBuildId(inputId)) { + sessions = await fromTraBuild(); } else { - try { - sessions = await listSessionIds({ ...args, buildId }, config); - } catch (error) { - if (!(error instanceof UnknownBuildError)) throw error; + for (const candidate of [ + args.sessionType, + otherSessionType(args.sessionType), + ]) { try { - await resolve(); + sessions = await listSessionIds( + { ...args, sessionType: candidate, buildId }, + config, + ); + switched(candidate); + break; + } catch (error) { + if (!(error instanceof UnknownBuildError)) throw error; + } + } + if (!sessions) { + // An all-hex id can still be an older TRA build id. + try { + sessions = await fromTraBuild(); } catch (resolveError) { + if (resolveError instanceof NoSessionsError) throw resolveError; + if ( + resolveError instanceof HttpError && + !isNotFoundError(resolveError) + ) { + throw resolveError; + } logger.debug( - "listSessions: id is neither a known hashed build nor a resolvable observability build", + "listSessions: id is not a TRA build either", resolveError, ); - throw error; + throw new UnknownBuildError( + `No Automate, App Automate or TRA build has id "${inputId}". Pass the id from ` + + "the dashboard build link, or the TRA build id from listBuildId.", + ); } - sessions = await listSessionIds({ ...args, buildId }, config); } } @@ -134,8 +169,8 @@ export async function listSessionIdsTool( : JSON.stringify(sessions, null, 2), }, ]; - if (resolvedNote) { - content.push({ type: "text", text: resolvedNote }); + for (const note of notes) { + content.push({ type: "text", text: note }); } return { content }; @@ -158,10 +193,10 @@ export default function addAutomationTools( { sessionId: z .string() - .describe("The BrowserStack session ID to fetch screenshots from"), + .describe("Session id from listSessions or listTestIds."), sessionType: z .enum([SessionType.Automate, SessionType.AppAutomate]) - .describe("Type of BrowserStack session"), + .describe("Product the session ran on."), }, { title: "Fetch Automation Screenshots", @@ -203,16 +238,14 @@ export default function addAutomationTools( tools.listSessions = server.tool( "listSessions", - "List sessions for a hashed Automate/App Automate build: session IDs, status, OS, browser/device, dashboard URL.", + "List a build's sessions: session IDs, status, OS, browser/device, dashboard URL. Takes an Automate or a TRA build id.", { sessionType: z .enum([SessionType.Automate, SessionType.AppAutomate]) - .describe("Type of BrowserStack session"), + .describe("Product the build ran on; the other is also tried."), buildId: z .string() - .describe( - "Hashed build id from the dashboard, or the observability build id from getBuildId.", - ), + .describe("Automate hashed build id, or TRA id from listBuildId."), limit: z .number() .int() diff --git a/src/tools/build-insights.ts b/src/tools/build-insights.ts index 4287647b..d8edb243 100644 --- a/src/tools/build-insights.ts +++ b/src/tools/build-insights.ts @@ -1,3 +1,5 @@ +import { SessionType } from "../lib/constants.js"; +import { isNotFoundError } from "../lib/error.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; @@ -5,7 +7,12 @@ 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"; +import { + isHashedBuildId, + NoSessionsError, + resolveHashedBuildId, +} from "./automate-utils/resolve-hashed-build-id.js"; +import { mapHashedToTra } from "./automate-utils/resolve-tra-build-id.js"; import { getRegionalHost } from "../lib/regional-host.js"; // Tool function that fetches build insights from two APIs @@ -14,24 +21,29 @@ export async function fetchBuildInsightsTool( config: BrowserStackConfig, ): Promise { try { - const host = getRegionalHost("automationApi"); - const buildUrl = `${host}/ext/v1/builds/${args.buildId}`; - const qualityGateUrl = `${host}/ext/v1/quality-gates/${args.buildId}`; + const inputId = args.buildId.trim(); + let traBuildId = inputId; + let mappedSessionType: SessionType | undefined; + let fetched; + try { + fetched = await fetchInsightsData(traBuildId, config); + } catch (error) { + if (!isNotFoundError(error) || !isHashedBuildId(inputId)) throw error; + const mapped = await mapHashedToTra(inputId, error, config); + traBuildId = mapped.traBuildId; + mappedSessionType = mapped.sessionType; + fetched = await fetchInsightsData(traBuildId, config); + } + const { buildData, qualityData } = fetched; - // Quality gate data is optional — a failure there should not block build insights - const [buildData, qualityData] = await Promise.all([ - fetchFromBrowserStackAPI(buildUrl, config), - fetchFromBrowserStackAPI(qualityGateUrl, config).catch((error) => { - logger.warn("Failed to fetch quality gate data", error); - return null; - }), - ]); - - const { hashed_id, session_type } = await resolveInsightsHashedId( - args.buildId, - buildData, - config, - ); + const { hashed_id, session_type, sessions } = + traBuildId === inputId + ? await resolveInsightsHashedId(traBuildId, buildData, config) + : { + hashed_id: inputId, + session_type: mappedSessionType, + sessions: undefined, + }; // Select useful fields for users const insights = { @@ -51,8 +63,10 @@ export async function fetchBuildInsightsTool( commit_sha: buildData.vcs_info?.sha, vcs_name: buildData.vcs_info?.name, quality_gate_result: qualityData?.quality_gate_result, + ...(traBuildId !== inputId ? { tra_build_id: traBuildId } : {}), ...(hashed_id ? { hashed_id } : {}), ...(session_type ? { session_type } : {}), + ...(sessions ? { sessions } : {}), }; const qualityProfiles = qualityData?.quality_profiles?.map( @@ -82,6 +96,23 @@ export async function fetchBuildInsightsTool( } } +async function fetchInsightsData(buildId: string, config: BrowserStackConfig) { + const host = getRegionalHost("automationApi"); + const encoded = encodeURIComponent(buildId); + // Quality gate data is optional — a failure there should not block build insights + const [buildData, qualityData] = await Promise.all([ + fetchFromBrowserStackAPI(`${host}/ext/v1/builds/${encoded}`, config), + fetchFromBrowserStackAPI( + `${host}/ext/v1/quality-gates/${encoded}`, + config, + ).catch((error) => { + logger.warn("Failed to fetch quality gate data", error); + return null; + }), + ]); + return { buildData, qualityData }; +} + /** * 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 @@ -91,7 +122,7 @@ async function resolveInsightsHashedId( observabilityBuildId: string, buildData: unknown, config: BrowserStackConfig, -): Promise<{ hashed_id?: string; session_type?: string }> { +): Promise<{ hashed_id?: string; session_type?: string; sessions?: string }> { const direct = extractHashedBuildId(buildData); if (direct) { return { hashed_id: direct }; @@ -103,6 +134,12 @@ async function resolveInsightsHashedId( session_type: resolved.sessionType, }; } catch (error) { + if (error instanceof NoSessionsError) { + return { + sessions: + "none: results were uploaded (e.g. JUnit), so there are no Automate sessions, logs or video", + }; + } logger.warn("Could not resolve hashed build id for build insights", error); return {}; } @@ -133,9 +170,11 @@ export default function addBuildInsightsTools( tools.fetchBuildInsights = server.tool( "fetchBuildInsights", - "Fetch build details and quality gate results. Includes hashed_id and session_type for listSessions.", + "Fetch build details and quality gate results for a TRA build. Includes hashed_id and session_type for listSessions.", { - buildId: z.string().describe("The build UUID of the BrowserStack build"), + buildId: z + .string() + .describe("TRA build id from listBuildId (Automate ids are mapped)."), }, { title: "Fetch Build Insights", diff --git a/src/tools/capability-registry/call.ts b/src/tools/capability-registry/call.ts new file mode 100644 index 00000000..3c71d4b9 --- /dev/null +++ b/src/tools/capability-registry/call.ts @@ -0,0 +1,78 @@ +import { BrowserStackConfig } from "../../lib/types.js"; +import { GroupedArguments } from "./bind.js"; +import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js"; +import { fetchTransport } from "./egress.js"; +import { CapabilityRegistry, productFromPath } from "./index-loader.js"; +import { invoke, InvokeResult } from "./resolve.js"; + +/** The capability registry is switched off or its index is missing. */ +export class CapabilityUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "CapabilityUnavailableError"; + } +} + +// Static product metadata shipped with the package, not user data, so one copy per +// process is safe across tenants. Only the products a tool calls are loaded. +const loaded = new Map(); + +function registry(product: string): CapabilityRegistry { + if (!isEnabled()) { + throw new CapabilityUnavailableError( + "the capability registry is disabled on this server", + ); + } + let reg = loaded.get(product); + if (!reg) { + const files = indexPaths().filter( + (file) => productFromPath(file) === product, + ); + if (files.length === 0) { + throw new CapabilityUnavailableError( + `the ${product} capability index is not installed`, + ); + } + reg = CapabilityRegistry.fromFiles(files); + loaded.set(product, reg); + } + return reg; +} + +/** + * Call one named capability from inside a tool, through the same binding, auth and + * host resolution as invokeCapability, so its endpoint stays defined in the index only. + */ +export async function callCapability( + product: string, + name: string, + args: GroupedArguments, + config: BrowserStackConfig, +): Promise { + const reg = registry(product); + const { capability } = reg.byNameLookup(name, product); + const productIndex = reg.index.products[product]; + return invoke( + capability, + args, + await resolveBaseUrl( + product, + config, + productIndex, + undefined, + capability.host, + ), + { + username: config["browserstack-username"], + accessKey: config["browserstack-access-key"], + }, + fetchTransport(), + productIndex?.auth, + product, + ); +} + +/** Test hook: forget the loaded index. */ +export function resetCapabilityRegistryForTests(): void { + loaded.clear(); +} diff --git a/src/tools/get-failure-logs.ts b/src/tools/get-failure-logs.ts index b0e60141..99cf47d6 100644 --- a/src/tools/get-failure-logs.ts +++ b/src/tools/get-failure-logs.ts @@ -18,6 +18,10 @@ import { } from "./failurelogs-utils/app-automate.js"; import { resolveAppAutomateBuildId } from "./failurelogs-utils/resolve-app-build-id.js"; +import { + detectSession, + isTraBuildId, +} from "./automate-utils/resolve-hashed-build-id.js"; import { retrieveSessionVideo } from "./failurelogs-utils/video.js"; import { @@ -30,6 +34,20 @@ import { type LogType = AutomateLogType | AppAutomateLogType; type SessionTypeValues = SessionType; +function fitsSessionType(logType: LogType, sessionType: SessionType): boolean { + const types: readonly string[] = + sessionType === SessionType.Automate + ? Object.values(AutomateLogType) + : Object.values(AppAutomateLogType); + return types.includes(logType); +} + +// Only true equivalents: other log types are a different kind of evidence. +const EQUIVALENT_LOG_TYPE: Partial> = { + [AutomateLogType.SessionLogs]: AppAutomateLogType.AppiumLogs, + [AppAutomateLogType.AppiumLogs]: AutomateLogType.SessionLogs, +}; + // Main log fetcher function export async function getFailureLogs( args: { @@ -42,77 +60,98 @@ export async function getFailureLogs( ): Promise { const results: CallToolResult["content"] = []; const errors: string[] = []; - let validLogTypes: LogType[] = []; + const notes: string[] = []; if (!args.sessionId) { throw new Error("Session ID is required"); } - // Validate log types and collect errors - validLogTypes = args.logTypes.filter((logType) => { - const isAutomate = Object.values(AutomateLogType).includes( - logType as AutomateLogType, + const allTypes = [ + ...new Set([ + ...Object.values(AutomateLogType), + ...Object.values(AppAutomateLogType), + ]), + ] as LogType[]; + const requested = args.logTypes.filter((logType) => { + if (allTypes.includes(logType)) return true; + errors.push( + `Invalid log type '${logType}'. Valid log types are: ${allTypes.join(", ")}`, + ); + return false; + }); + + let sessionType = args.sessionType; + let buildId = + args.buildId && !isTraBuildId(args.buildId) ? args.buildId : undefined; + + const switchTo = (actual: SessionType) => { + if (actual === sessionType) return; + notes.push( + `Session ${args.sessionId} ran on ${actual}, not ${sessionType}.`, ); - const isAppAutomate = Object.values(AppAutomateLogType).includes( - logType as AppAutomateLogType, + sessionType = actual; + }; + const notFound = () => + new Error( + `Session "${args.sessionId}" was not found on Automate or App Automate. ` + + "Use a session id from listSessions or listTestIds.", ); - if (!isAutomate && !isAppAutomate) { - errors.push( - `Invalid log type '${logType}'. Valid log types are: ${[ - ...new Set([ - ...Object.values(AutomateLogType), - ...Object.values(AppAutomateLogType), - ]), - ].join(", ")}`, - ); - return false; + if (requested.some((logType) => !fitsSessionType(logType, sessionType))) { + const detected = await detectSession(args.sessionId, sessionType, config); + if (!detected) throw notFound(); + switchTo(detected.sessionType); + if (sessionType === SessionType.AppAutomate) { + buildId = buildId ?? detected.hashedBuildId; } + } - if (args.sessionType === SessionType.Automate && !isAutomate) { - errors.push( - `Log type '${logType}' is only available for app-automate sessions.`, + const needsBuildId = requested.some( + (logType) => logType !== SessionVideoLogType, + ); + if (sessionType === SessionType.AppAutomate && needsBuildId && !buildId) { + buildId = await resolveAppAutomateBuildId(args.sessionId, config); + if (!buildId) { + const detected = await detectSession( + args.sessionId, + SessionType.Automate, + config, ); - return false; + if (!detected) throw notFound(); + switchTo(detected.sessionType); + if (sessionType === SessionType.AppAutomate) { + buildId = detected.hashedBuildId; + } } + } - if (args.sessionType === SessionType.AppAutomate && !isAppAutomate) { - errors.push( - `Log type '${logType}' is only available for automate sessions.`, + const validLogTypes: LogType[] = []; + for (const logType of requested) { + const fitted = fitsSessionType(logType, sessionType) + ? logType + : EQUIVALENT_LOG_TYPE[logType]; + if (!fitted) { + errors.push(`'${logType}' is not available for ${sessionType} sessions.`); + } else if (fitted !== logType) { + notes.push( + `'${logType}' is not available for ${sessionType} sessions; fetched '${fitted}' instead.`, ); - return false; } - - return true; - }); + if (fitted && !validLogTypes.includes(fitted)) validLogTypes.push(fitted); + } if (validLogTypes.length === 0) { return { content: [ { type: "text", - text: `No valid log types found for ${args.sessionType} session.\nErrors encountered:\n${errors.join("\n")}`, + text: `No valid log types found for ${sessionType} session.\nErrors encountered:\n${errors.join("\n")}`, }, ], isError: true, }; } - let buildId = args.buildId; - const needsBuildId = validLogTypes.some( - (logType) => logType !== SessionVideoLogType, - ); - if ( - args.sessionType === SessionType.AppAutomate && - needsBuildId && - !buildId - ) { - buildId = await resolveAppAutomateBuildId(args.sessionId, config); - if (!buildId) { - throw new Error("Build ID is required for app-automate sessions"); - } - } - let response; // eslint-disable-next-line no-useless-catch try { @@ -121,7 +160,7 @@ export async function getFailureLogs( case SessionVideoLogType: { response = await retrieveSessionVideo( args.sessionId, - args.sessionType, + sessionType, config, ); results.push({ type: "text", text: response }); @@ -169,6 +208,9 @@ export async function getFailureLogs( throw error; } + if (notes.length > 0) { + results.unshift({ type: "text", text: notes.join("\n") }); + } if (errors.length > 0) { results.push({ type: "text", @@ -193,14 +235,10 @@ export default function registerGetFailureLogs( { sessionType: z .enum([SessionType.Automate, SessionType.AppAutomate]) - .describe( - "Type of BrowserStack session. Must be explicitly provided by the user.", - ), + .describe("Product the session ran on; corrected if wrong."), sessionId: z .string() - .describe( - "The BrowserStack session ID. Must be explicitly provided by the user.", - ), + .describe("Session id from listSessions or listTestIds."), buildId: z .string() .optional() diff --git a/src/tools/rca-agent-utils/constants.ts b/src/tools/rca-agent-utils/constants.ts index 5868a66e..dd4d52c6 100644 --- a/src/tools/rca-agent-utils/constants.ts +++ b/src/tools/rca-agent-utils/constants.ts @@ -16,25 +16,30 @@ export const FETCH_RCA_PARAMS = { export const GET_BUILD_ID_PARAMS = { browserStackProjectName: z .string() - .describe( - "BrowserStack project name from the test run config (e.g. browserstack.yml). Exact match; no default.", - ), + .optional() + .describe("Project name, as in browserstack.yml or the dashboard."), browserStackBuildName: z .string() - .describe( - "BrowserStack build name from the test run config (e.g. browserstack.yml). Exact match; no default.", - ), + .optional() + .describe("Build name from browserstack.yml; latest run."), + buildNumber: z + .number() + .int() + .positive() + .optional() + .describe("Build #N; then give the dashboard build name."), + buildUrl: z + .string() + .optional() + .describe("TRA or Automate dashboard link to the build."), }; export const LIST_TEST_IDS_PARAMS = { - buildId: z - .string() - .describe("Build id; get it with getBuildId or listBuildId."), + buildId: z.string().describe("TRA build id from listBuildId."), status: z .nativeEnum(TestStatus) - .describe( - "Filter tests by status. If not provided, all tests are returned. Example for RCA usecase always use failed status", - ), + .optional() + .describe("Only tests with this status; use failed for RCA."), includeFailureDetail: z .boolean() .optional() diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts deleted file mode 100644 index cc32aaff..00000000 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { getRegionalHost } from "../../lib/regional-host.js"; - -export async function getBuildId( - projectName: string, - buildName: string, - username: string, - accessKey: string, -): Promise { - const host = getRegionalHost("automationApi"); - const url = new URL(`${host}/ext/v1/builds/latest`); - url.searchParams.append("project_name", projectName); - url.searchParams.append("build_name", buildName); - url.searchParams.append("user_name", username); - - 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-utils/get-failed-test-id.ts b/src/tools/rca-agent-utils/get-failed-test-id.ts index f8eac83b..50538adb 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -1,3 +1,4 @@ +import { HttpError } from "../../lib/error.js"; import logger from "../../logger.js"; import { getAutomationBaseUrl } from "./constants.js"; import { @@ -14,6 +15,17 @@ const ERROR_SUMMARY_MAX = 200; // Safety bound on pagination; hitting it is logged, never silent. const MAX_PAGES = 100; +async function fetchWithRetry( + url: string, + init: RequestInit, +): Promise { + try { + return await fetch(url, init); + } catch { + return await fetch(url, init); + } +} + export async function getTestIds( buildId: string, authString: string, @@ -35,7 +47,7 @@ export async function getTestIds( while (true) { requestNumber++; - const response = await fetch(url, { + const response = await fetchWithRetry(url, { headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -43,8 +55,9 @@ export async function getTestIds( }); if (!response.ok) { - throw new Error( + throw new HttpError( `Failed to fetch test runs: ${response.status} ${response.statusText}`, + response.status, ); } diff --git a/src/tools/rca-agent-utils/list-build-id.ts b/src/tools/rca-agent-utils/list-build-id.ts deleted file mode 100644 index 42f46b69..00000000 --- a/src/tools/rca-agent-utils/list-build-id.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { apiClient } from "../../lib/apiClient.js"; -import { getRegionalHost } from "../../lib/regional-host.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 { - const authHeader = - "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); - - const host = getRegionalHost("automationApi"); - const response = await apiClient.get({ - url: `${host}/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; -} diff --git a/src/tools/rca-agent-utils/resolve-build.ts b/src/tools/rca-agent-utils/resolve-build.ts new file mode 100644 index 00000000..7451627c --- /dev/null +++ b/src/tools/rca-agent-utils/resolve-build.ts @@ -0,0 +1,263 @@ +import { SessionType } from "../../lib/constants.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { + isHashedBuildId, + isTraBuildId, +} from "../automate-utils/resolve-hashed-build-id.js"; +import { + resolveTraBuildIdFromHashed, + TraBuildNotFoundError, +} from "../automate-utils/resolve-tra-build-id.js"; +import { fetchLatestBuildId } from "./tra-builds.js"; +import { HttpError } from "../../lib/error.js"; +import { + callCapability, + CapabilityUnavailableError, +} from "../capability-registry/call.js"; +import { InvocationError } from "../capability-registry/index-loader.js"; + +export interface BuildLookupInput { + browserStackProjectName?: string; + browserStackBuildName?: string; + buildNumber?: number; + buildUrl?: string; +} + +export interface BuildLookupResult { + buildId: string; + note?: string; +} + +export class BuildLookupError extends Error { + constructor(message: string) { + super(message); + this.name = "BuildLookupError"; + } +} + +export interface ParsedBuildUrl { + projectName?: string; + buildName?: string; + buildNumber?: number; + traBuildId?: string; + hashedBuildId?: string; + sessionType?: SessionType; +} + +function decodeSegment(segment: string): string { + try { + return decodeURIComponent(segment.replace(/\+/g, " ")); + } catch { + return segment.replace(/\+/g, " "); + } +} + +// TRA links (also served on the Automate dashboard): .../projects//builds// +// Automate links: .../builds/[/sessions/] +export function parseBuildUrl(input: string): ParsedBuildUrl | undefined { + const raw = input.trim(); + if (isTraBuildId(raw)) return { traBuildId: raw }; + if (isHashedBuildId(raw)) return { hashedBuildId: raw }; + + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + const segments = url.pathname.split("/").filter(Boolean); + const sessionType = url.hostname.includes("app-automate") + ? SessionType.AppAutomate + : SessionType.Automate; + + const projectsAt = segments.indexOf("projects"); + if (projectsAt >= 0 && segments[projectsAt + 2] === "builds") { + const projectName = decodeSegment(segments[projectsAt + 1] ?? ""); + const buildName = segments[projectsAt + 3] + ? decodeSegment(segments[projectsAt + 3]) + : undefined; + const number = Number(segments[projectsAt + 4]); + if (projectName) { + return { + projectName, + buildName, + ...(Number.isInteger(number) && number > 0 + ? { buildNumber: number } + : {}), + }; + } + } + + const buildsAt = segments.indexOf("builds"); + const afterBuilds = buildsAt >= 0 ? segments[buildsAt + 1] : undefined; + if (afterBuilds && isHashedBuildId(afterBuilds)) { + return { hashedBuildId: afterBuilds, sessionType }; + } + if (afterBuilds && isTraBuildId(afterBuilds)) { + return { traBuildId: afterBuilds }; + } + return undefined; +} + +// The build-number lookup is defined in the TRA capability index and called through +// the registry, so its endpoint is maintained in one place. +export const BUILD_BY_NUMBER_CAPABILITY = "get_build_id_from_build_coordinates"; + +async function buildByNumber( + projectName: string, + buildName: string, + buildNumber: number, + config: BrowserStackConfig, +): Promise { + let result; + try { + result = await callCapability( + "tra", + BUILD_BY_NUMBER_CAPABILITY, + { + query: { + projectNormalisedName: projectName, + buildNormalisedName: buildName, + buildSerialId: buildNumber, + }, + }, + config, + ); + } catch (error) { + if ( + error instanceof CapabilityUnavailableError || + error instanceof InvocationError + ) { + throw new BuildLookupError( + `Looking up a build by number is not available here (${error.message}). ` + + "Pass the TRA build id, or project + the build name from browserstack.yml " + + "for its latest run.", + ); + } + throw error; + } + + const { status, body } = result.http_response; + const buildId = (body as { buildId?: unknown } | null)?.buildId; + if (result.ok && typeof buildId === "string" && buildId) { + return { + buildId, + note: `Build #${buildNumber} of "${buildName}" in "${projectName}".`, + }; + } + if (status === 404 || result.ok) { + throw new BuildLookupError( + `No build #${buildNumber} named "${buildName}" in "${projectName}". With a build ` + + "number, use the build name shown on the dashboard (as in the TRA build link), " + + "which can be shorter than the name in browserstack.yml.", + ); + } + throw new HttpError( + `Failed to look up build #${buildNumber}: ${status} ${result.http_response.error ?? ""}`.trim(), + status, + ); +} + +async function resolveFromHashed( + hashedBuildId: string, + sessionType: SessionType | undefined, + config: BrowserStackConfig, +): Promise { + try { + const resolved = await resolveTraBuildIdFromHashed( + hashedBuildId, + config, + sessionType, + ); + return { + buildId: resolved.traBuildId, + note: + `TRA build for ${resolved.sessionType} build ${hashedBuildId} ` + + `("${resolved.buildName}" in "${resolved.projectName}"). listSessions takes either id.`, + }; + } catch (error) { + if (!(error instanceof TraBuildNotFoundError)) throw error; + // Possibly an older all-hex TRA id. + if (error.unknownToAutomate) return { buildId: hashedBuildId }; + return { + buildId: hashedBuildId, + note: + `This is an Automate hashed build id. ${error.message} ` + + "listTestIds and fetchRCA need a TRA build id and do not apply.", + }; + } +} + +/** + * No paging: a TRA id is returned as is, an Automate id is mapped, a name + * without a number is the latest run, and a name with a number is one call to + * the build-number capability. + */ +export async function resolveBuild( + input: BuildLookupInput, + config: BrowserStackConfig, + options: { userName?: string } = {}, +): Promise { + let projectName = input.browserStackProjectName?.trim() || undefined; + let buildName = input.browserStackBuildName?.trim() || undefined; + let buildNumber = input.buildNumber; + + if (input.buildUrl) { + const parsed = parseBuildUrl(input.buildUrl); + if (!parsed) { + throw new BuildLookupError( + "Could not read a build from that link. Expected a TRA link " + + "(.../projects//builds//) or an Automate build link (.../builds/).", + ); + } + if (parsed.traBuildId) { + return { buildId: parsed.traBuildId }; + } + if (parsed.hashedBuildId) { + return resolveFromHashed( + parsed.hashedBuildId, + parsed.sessionType, + config, + ); + } + projectName = parsed.projectName ?? projectName; + buildName = parsed.buildName ?? buildName; + buildNumber = parsed.buildNumber ?? buildNumber; + } + + if (!projectName || !buildName) { + throw new BuildLookupError( + "Pass browserStackProjectName and browserStackBuildName (from browserstack.yml " + + "or the dashboard), or buildUrl.", + ); + } + + if (buildNumber !== undefined) { + return buildByNumber(projectName, buildName, buildNumber, config); + } + + if (options.userName) { + const own = await fetchLatestBuildId( + projectName, + buildName, + config, + options.userName, + ); + if (own) return { buildId: own }; + } + const anyone = await fetchLatestBuildId(projectName, buildName, config); + if (anyone) { + return options.userName + ? { + buildId: anyone, + note: "None of your own builds has this name; this is the latest run by anyone.", + } + : { buildId: anyone }; + } + + throw new BuildLookupError( + `No build in "${projectName}" was sent with the name "${buildName}". The latest-build ` + + "lookup matches the name from browserstack.yml (buildName), which can differ from " + + "the name on the dashboard. Pass that name, or the build number or dashboard link.", + ); +} diff --git a/src/tools/rca-agent-utils/tra-builds.ts b/src/tools/rca-agent-utils/tra-builds.ts new file mode 100644 index 00000000..530e4b5e --- /dev/null +++ b/src/tools/rca-agent-utils/tra-builds.ts @@ -0,0 +1,71 @@ +import { apiClient } from "../../lib/apiClient.js"; +import { HttpError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { getAutomationBaseUrl } from "./constants.js"; + +// TRA matches /builds/latest on `original_name`, the build name the SDK sent; +// the dashboard shows a shortened `name`, which the build-number capability takes. + +function authHeaders(config: BrowserStackConfig): Record { + const auth = Buffer.from(getBrowserStackAuth(config)).toString("base64"); + return { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + }; +} + +async function getWithRetry( + url: string, + config: BrowserStackConfig, + params?: Record, +) { + const request = () => + apiClient.get({ + url, + headers: authHeaders(config), + ...(params ? { params } : {}), + raise_error: false, + }); + try { + return await request(); + } catch { + return await request(); + } +} + +function httpError(what: string, status: number, statusText: string): Error { + return new HttpError( + `Failed to ${what}: ${status} ${statusText}`.trim(), + status, + ); +} + +export async function fetchLatestBuildId( + projectName: string, + buildName: string, + config: BrowserStackConfig, + userName?: string, +): Promise { + const params: Record = { + project_name: projectName, + build_name: buildName, + }; + if (userName) params.user_name = userName; + + const response = await getWithRetry( + `${getAutomationBaseUrl()}/ext/v1/builds/latest`, + config, + params, + ); + if (response.status === 404) return undefined; + if (!response.ok) { + throw httpError( + "look up the latest build", + response.status, + response.statusText, + ); + } + const buildId = response.data?.build_id; + return typeof buildId === "string" && buildId ? buildId : undefined; +} diff --git a/src/tools/rca-agent-utils/types.ts b/src/tools/rca-agent-utils/types.ts index bd595d67..9df24861 100644 --- a/src/tools/rca-agent-utils/types.ts +++ b/src/tools/rca-agent-utils/types.ts @@ -70,6 +70,8 @@ export interface RCAResponse { } export interface BuildIdArgs { - browserStackProjectName: string; - browserStackBuildName: string; + browserStackProjectName?: string; + browserStackBuildName?: string; + buildNumber?: number; + buildUrl?: string; } diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 3eb2b5e7..41960d66 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -1,10 +1,15 @@ +import { isNotFoundError } from "../lib/error.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; 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 { + BuildLookupResult, + resolveBuild, +} from "./rca-agent-utils/resolve-build.js"; +import { isHashedBuildId } from "./automate-utils/resolve-hashed-build-id.js"; +import { mapHashedToTra } from "./automate-utils/resolve-tra-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"; @@ -19,87 +24,45 @@ import { LIST_TEST_IDS_PARAMS, } from "./rca-agent-utils/constants.js"; -// Tool function to fetch build ID +function buildLookupResult(result: BuildLookupResult): CallToolResult { + const content: CallToolResult["content"] = [ + { type: "text", text: result.buildId }, + ]; + if (result.note) content.push({ type: "text", text: result.note }); + return { content }; +} + +function buildLookupError(error: unknown): CallToolResult { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error("Error fetching build ID: %s", errorMessage); + return { + content: [ + { type: "text", text: `Error fetching build ID: ${errorMessage}` }, + ], + isError: true, + }; +} + export async function getBuildIdTool( args: BuildIdArgs, config: BrowserStackConfig, ): Promise { try { - const { browserStackProjectName, browserStackBuildName } = args; - - const authString = getBrowserStackAuth(config); - const [username, accessKey] = authString.split(":"); - - const buildId = await getBuildId( - browserStackProjectName, - browserStackBuildName, - username, - accessKey, - ); - - return { - content: [ - { - type: "text", - text: buildId, - }, - ], - }; + const [userName] = getBrowserStackAuth(config).split(":"); + return buildLookupResult(await resolveBuild(args, config, { userName })); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - logger.error("Error fetching build ID: %s", errorMessage); - return { - content: [ - { - type: "text", - text: `Error fetching build ID: ${errorMessage}`, - }, - ], - isError: true, - }; + return buildLookupError(error); } } -// 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, - }, - ], - }; + return buildLookupResult(await resolveBuild(args, config)); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - logger.error("Error fetching build ID: %s", errorMessage); - return { - content: [ - { - type: "text", - text: `Error fetching build ID: ${errorMessage}`, - }, - ], - isError: true, - }; + return buildLookupError(error); } } @@ -151,25 +114,37 @@ export async function listTestIdsTool( config: BrowserStackConfig, ): Promise { try { - const { buildId, status, includeFailureDetail } = args; + const { status, includeFailureDetail } = args; const authString = getBrowserStackAuth(config); + let buildId = args.buildId.trim(); + let note: string | undefined; - // Get test IDs - const testIds = await getTestIds( - buildId, - authString, - status, - includeFailureDetail, - ); + let testIds; + try { + testIds = await getTestIds( + buildId, + authString, + status, + includeFailureDetail, + ); + } catch (error) { + if (!isNotFoundError(error) || !isHashedBuildId(buildId)) throw error; + const mapped = (await mapHashedToTra(buildId, error, config)).traBuildId; + note = `Automate build ${buildId} maps to TRA build ${mapped}.`; + buildId = mapped; + testIds = await getTestIds( + buildId, + authString, + status, + includeFailureDetail, + ); + } - return { - content: [ - { - type: "text", - text: JSON.stringify(testIds, null, 2), - }, - ], - }; + const content: CallToolResult["content"] = [ + { type: "text", text: JSON.stringify(testIds, null, 2) }, + ]; + if (note) content.push({ type: "text", text: note }); + return { content }; } catch (error) { logger.error("Error listing test IDs", error); const errorMessage = @@ -221,7 +196,7 @@ export default function addRCATools( tools.getBuildId = server.tool( "getBuildId", - "Get the BrowserStack build ID for a given project and build name, scoped to the current user's builds.", + "Like listBuildId, but a build-name lookup tries the caller's own builds first, then anyone's.", GET_BUILD_ID_PARAMS, { title: "Get Build ID", @@ -247,7 +222,7 @@ 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).", + "Find a TRA build id, any user: project + build name (latest run), + build number, or a dashboard link.", GET_BUILD_ID_PARAMS, { title: "List Build IDs", diff --git a/src/tools/selfheal.ts b/src/tools/selfheal.ts index e74000b5..f52bbe1d 100644 --- a/src/tools/selfheal.ts +++ b/src/tools/selfheal.ts @@ -1,3 +1,4 @@ +import { isNotFoundError } from "../lib/error.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; @@ -16,6 +17,8 @@ import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; import { NEEDS_SESSION_ID } from "./tool-handoff.js"; +import { isHashedBuildId } from "./automate-utils/resolve-hashed-build-id.js"; +import { mapHashedToTra } from "./automate-utils/resolve-tra-build-id.js"; // Local helper: returns the server-configured BrowserStack credentials, or // null when either is missing. Lives here because the self-heal tools need @@ -284,10 +287,18 @@ export async function fetchSelfHealSelectorTool( }; } - const report = await fetchSelfHealingReportByBuild( - buildUuid!, - effectiveConfig, - ); + let report; + try { + report = await fetchSelfHealingReportByBuild(buildUuid!, effectiveConfig); + } catch (error) { + if (!isNotFoundError(error) || !isHashedBuildId(buildUuid!)) throw error; + const { traBuildId } = await mapHashedToTra( + buildUuid!, + error, + effectiveConfig, + ); + report = await fetchSelfHealingReportByBuild(traBuildId, effectiveConfig); + } // Extract unique session IDs from the healing report and fetch test code const sessionIds = Array.from( @@ -581,7 +592,7 @@ export default function addSelfHealTools( .optional(), buildUuid: z .string() - .describe("Build UUID. Fetches the build's self-healing report.") + .describe("TRA build id (listBuildId). Fetches its healing report.") .optional(), }, { diff --git a/src/tools/tool-handoff.ts b/src/tools/tool-handoff.ts index 36052d9c..0a526c8d 100644 --- a/src/tools/tool-handoff.ts +++ b/src/tools/tool-handoff.ts @@ -69,11 +69,7 @@ export const PLAN_WRITES_VIA_CAPABILITIES = " Creating a test plan or sub-plan, and linking or unlinking test runs, are not " + "available as tools here: use the capability functions (they ask you to confirm first)."; -/** A build id comes from either build-lookup tool. */ -export const NEEDS_BUILD_ID = needsIdFrom( - "a BrowserStack build id", - "getBuildId or listBuildId", -); +export const NEEDS_BUILD_ID = needsIdFrom("a TRA build id", "listBuildId"); /** * Session ids ARE listable now — PR #395 added `listSessions`, which merged into main while @@ -81,7 +77,7 @@ export const NEEDS_BUILD_ID = needsIdFrom( * pointing at the agent when a real tool exists is exactly what the header above forbids. */ export const NEEDS_SESSION_ID = - " Needs a session id: listSessions lists them; getBuildId or listBuildId finds the build."; + " Needs a session id: listSessions or listTestIds list them; listBuildId finds the build."; /** * A scan run's ids come from startAccessibilityScan; a build's id is printed by the SDK at diff --git a/tests/lib/error-not-found.test.ts b/tests/lib/error-not-found.test.ts new file mode 100644 index 00000000..9d58987a --- /dev/null +++ b/tests/lib/error-not-found.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { AxiosError, AxiosHeaders } from "axios"; +import { HttpError, isNotFoundError } from "../../src/lib/error"; + +describe("isNotFoundError", () => { + it("reads the status, not the message", () => { + expect(isNotFoundError(new HttpError("Not Found", 404))).toBe(true); + expect(isNotFoundError(new HttpError("404 in the text", 500))).toBe(false); + expect(isNotFoundError(new Error("Failed: 404 Not Found"))).toBe(false); + }); + + it("handles axios errors from apiClient", () => { + const error = new AxiosError( + "Request failed", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 404, + statusText: "Not Found", + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }, + ); + expect(isNotFoundError(error)).toBe(true); + }); +}); diff --git a/tests/tools/buildInsights.test.ts b/tests/tools/buildInsights.test.ts index e94b0304..625a9568 100644 --- a/tests/tools/buildInsights.test.ts +++ b/tests/tools/buildInsights.test.ts @@ -1,7 +1,12 @@ +import { HttpError } from "../../src/lib/error"; 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"; +import { + NoSessionsError, + resolveHashedBuildId, +} from "../../src/tools/automate-utils/resolve-hashed-build-id"; +import { mapHashedToTra } from "../../src/tools/automate-utils/resolve-tra-build-id"; vi.mock("../../src/lib/utils", () => ({ fetchFromBrowserStackAPI: vi.fn(), @@ -11,9 +16,20 @@ vi.mock("../../src/logger", () => ({ 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(), -})); +vi.mock( + "../../src/tools/automate-utils/resolve-hashed-build-id", + async (importOriginal) => ({ + ...(await importOriginal()), + resolveHashedBuildId: vi.fn(), + }), +); +vi.mock( + "../../src/tools/automate-utils/resolve-tra-build-id", + async (importOriginal) => ({ + ...(await importOriginal()), + mapHashedToTra: vi.fn(), + }), +); const mockConfig = { "browserstack-username": "fake-user", @@ -24,7 +40,7 @@ const HASHED_ID = "ca9cccc228cf0e3ff3cb90dd62e2e2bfb4b20bc7"; describe("fetchBuildInsightsTool", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); // Default: the build has no resolvable sessions; insights still succeed. (resolveHashedBuildId as Mock).mockRejectedValue( new Error("No BrowserStack sessions found"), @@ -128,6 +144,71 @@ describe("fetchBuildInsightsTool", () => { expect(result.content[1].text).toContain("No Quality Gate Profiles"); }); + it("SUCCESS: says plainly when an uploaded build has no sessions", async () => { + (fetchFromBrowserStackAPI as Mock) + .mockResolvedValueOnce({ name: "junit" }) + .mockResolvedValueOnce({}); + (resolveHashedBuildId as Mock).mockRejectedValue( + new NoSessionsError("build-123"), + ); + + const result = await fetchBuildInsightsTool( + { buildId: "build-123" }, + mockConfig, + ); + + expect(result.content[0].text).toContain( + '"sessions": "none: results were uploaded', + ); + expect(result.content[0].text).not.toContain("hashed_id"); + }); + + it("SUCCESS: maps an Automate hashed id to its TRA build and retries", async () => { + (fetchFromBrowserStackAPI as Mock) + .mockRejectedValueOnce( + new HttpError( + `Failed to fetch from x/${HASHED_ID}: 404 Not Found`, + 404, + ), + ) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ name: "Mapped Build" }) + .mockResolvedValueOnce({}); + (mapHashedToTra as Mock).mockResolvedValue({ + traBuildId: "zz00000000000000000000000000000000000003", + hashedBuildId: HASHED_ID, + sessionType: "app-automate", + projectName: "P", + buildName: "B", + }); + + const result = await fetchBuildInsightsTool( + { buildId: HASHED_ID }, + mockConfig, + ); + + expect(result.content[0].text).toContain("Mapped Build"); + expect(result.content[0].text).toContain( + '"tra_build_id": "zz00000000000000000000000000000000000003"', + ); + expect(result.content[0].text).toContain(`"hashed_id": "${HASHED_ID}"`); + expect(result.content[0].text).toContain('"session_type": "app-automate"'); + expect(resolveHashedBuildId).not.toHaveBeenCalled(); + }); + + it("FAIL: keeps the 404 and adds the reason when a hashed id has no TRA build", async () => { + (fetchFromBrowserStackAPI as Mock).mockRejectedValue( + new HttpError(`Failed to fetch from x/${HASHED_ID}: 404 Not Found`, 404), + ); + (mapHashedToTra as Mock).mockRejectedValue( + new Error("automate build has no TRA build"), + ); + + await expect( + fetchBuildInsightsTool({ buildId: HASHED_ID }, mockConfig), + ).rejects.toThrow("automate build has no TRA build"); + }); + it("FAIL: throws error for invalid build ID", async () => { (fetchFromBrowserStackAPI as Mock).mockRejectedValue( new Error("Build not found"), diff --git a/tests/tools/capabilityCall.test.ts b/tests/tools/capabilityCall.test.ts new file mode 100644 index 00000000..5567ea21 --- /dev/null +++ b/tests/tools/capabilityCall.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"; +import { + callCapability, + CapabilityUnavailableError, + resetCapabilityRegistryForTests, +} from "../../src/tools/capability-registry/call"; +import { invoke } from "../../src/tools/capability-registry/resolve"; + +vi.mock("../../src/tools/capability-registry/resolve", () => ({ + invoke: vi.fn(), +})); +vi.mock( + "../../src/tools/capability-registry/config", + async (importOriginal) => ({ + ...(await importOriginal()), + resolveBaseUrl: vi.fn( + async () => "https://api-observability.browserstack.com", + ), + }), +); + +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +}; + +describe("callCapability", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetCapabilityRegistryForTests(); + }); + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_DISABLED; + }); + + it("calls the build-number capability from the shipped TRA index", async () => { + (invoke as Mock).mockResolvedValue({ + ok: true, + completed: true, + http_response: { status: 200, body: { buildId: "b1" } }, + }); + + const result = await callCapability( + "tra", + "get_build_id_from_build_coordinates", + { + query: { + projectNormalisedName: "P", + buildNormalisedName: "n", + buildSerialId: 2, + }, + }, + config, + ); + + expect(result.http_response.body).toEqual({ buildId: "b1" }); + const [capability, args, baseUrl, credentials, , auth, product] = ( + invoke as Mock + ).mock.calls[0]; + expect(capability.path).toBe("/api/v1/builds/getBuildIdFromBuildInfo"); + expect(args.query.buildSerialId).toBe(2); + expect(baseUrl).toBe("https://api-observability.browserstack.com"); + expect(credentials).toEqual({ username: "user", accessKey: "key" }); + expect(auth).toEqual({ type: "oauth2" }); + expect(product).toBe("tra"); + }); + + it("loads only the requested product's index", async () => { + const { CapabilityRegistry } = + await import("../../src/tools/capability-registry/index-loader"); + const spy = vi.spyOn(CapabilityRegistry, "fromFiles"); + (invoke as Mock).mockResolvedValue({ + ok: true, + completed: true, + http_response: { status: 200, body: {} }, + }); + + await callCapability( + "tra", + "get_build_id_from_build_coordinates", + {}, + config, + ); + await callCapability( + "tra", + "get_build_id_from_build_coordinates", + {}, + config, + ); + + expect(spy).toHaveBeenCalledTimes(1); + const files = spy.mock.calls[0][0]; + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/tra\.capability-index\.json$/); + spy.mockRestore(); + }); + + it("refuses when the registry is disabled", async () => { + process.env.CAPABILITY_REGISTRY_DISABLED = "true"; + + await expect( + callCapability("tra", "get_build_id_from_build_coordinates", {}, config), + ).rejects.toBeInstanceOf(CapabilityUnavailableError); + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/tools/getFailureLogs.test.ts b/tests/tools/getFailureLogs.test.ts index 7dd8f38c..57d32158 100644 --- a/tests/tools/getFailureLogs.test.ts +++ b/tests/tools/getFailureLogs.test.ts @@ -4,6 +4,7 @@ import * as automate from '../../src/tools/failurelogs-utils/automate'; import * as appAutomate from '../../src/tools/failurelogs-utils/app-automate'; import { resolveAppAutomateBuildId } from '../../src/tools/failurelogs-utils/resolve-app-build-id'; import { retrieveSessionVideo } from '../../src/tools/failurelogs-utils/video'; +import { detectSession } from '../../src/tools/automate-utils/resolve-hashed-build-id'; vi.mock('../../src/config', () => ({ __esModule: true, @@ -21,6 +22,11 @@ vi.mock('../../src/tools/failurelogs-utils/resolve-app-build-id', () => ({ resolveAppAutomateBuildId: vi.fn(), })); +vi.mock('../../src/tools/automate-utils/resolve-hashed-build-id', async (importOriginal) => ({ + ...(await importOriginal()), + detectSession: vi.fn(), +})); + vi.mock('../../src/tools/failurelogs-utils/video', () => ({ retrieveSessionVideo: vi.fn(), })); @@ -83,14 +89,50 @@ describe('BrowserStack Failure Logs', () => { }, mockServer)).rejects.toThrow('Session ID is required'); }); - it('should throw error if buildId is not provided and cannot be resolved for app-automate session', async () => { + it('should throw when the session is on neither product', async () => { const mockServer = { server: { getClientVersion: () => "test-version" } }; (resolveAppAutomateBuildId as any).mockResolvedValue(undefined); + (detectSession as any).mockResolvedValue(undefined); await expect(getFailureLogs({ sessionId: 'test-session', logTypes: ['deviceLogs'], sessionType: 'app-automate' - }, mockServer)).rejects.toThrow('Build ID is required for app-automate sessions'); + }, mockServer)).rejects.toThrow('was not found on Automate or App Automate'); + }); + + it('switches to automate when an "app-automate" session is really Automate', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; + (resolveAppAutomateBuildId as any).mockResolvedValue(undefined); + (detectSession as any).mockResolvedValue({ sessionType: 'automate', hashedBuildId: 'h' }); + vi.mocked(automate.retrieveSessionFailures).mockResolvedValue('session failures'); + + const result = await getFailureLogs({ + sessionId: 'test-session', + logTypes: ['appiumLogs'], + sessionType: 'app-automate' + }, mockServer); + + expect(result.isError).toBeFalsy(); + expect(automate.retrieveSessionFailures).toHaveBeenCalledWith('test-session', mockServer); + const texts = result.content.map((c: any) => c.text).join('\n'); + expect(texts).toContain('ran on automate, not app-automate'); + expect(texts).toContain("fetched 'sessionLogs' instead"); + expect(result.content[0].text).toContain('ran on automate'); + }); + + it('ignores a TRA build id passed as the App Automate build id', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; + (resolveAppAutomateBuildId as any).mockResolvedValue('hashed-app-build'); + vi.mocked(appAutomate.retrieveDeviceLogs).mockResolvedValue('device logs'); + + await getFailureLogs({ + sessionId: 'test-session', + buildId: 'zz00000000000000000000000000000000000003', + logTypes: ['deviceLogs'], + sessionType: 'app-automate' + }, mockServer); + + expect(appAutomate.retrieveDeviceLogs).toHaveBeenCalledWith('test-session', 'hashed-app-build', mockServer); }); it('should resolve buildId from the session when not provided', async () => { @@ -147,23 +189,49 @@ describe('BrowserStack Failure Logs', () => { } }); - it('should return error when mixing session types', async () => { + it('follows the session to app-automate when app log types are asked of "automate"', async () => { const mockServer = { server: { getClientVersion: () => "test-version" } }; - const automateResult = await getFailureLogs({ + (detectSession as any).mockResolvedValue({ sessionType: 'app-automate', hashedBuildId: 'app-build' }); + vi.mocked(appAutomate.retrieveDeviceLogs).mockResolvedValue('device logs'); + + const result = await getFailureLogs({ sessionId: 'test-session', logTypes: ['deviceLogs'], sessionType: 'automate' }, mockServer); - const appAutomateResult = await getFailureLogs({ + expect(result.isError).toBeFalsy(); + expect(appAutomate.retrieveDeviceLogs).toHaveBeenCalledWith('test-session', 'app-build', mockServer); + }); + + it('fetches appiumLogs for sessionLogs and reports networkLogs as unavailable on an app session', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; + (detectSession as any).mockResolvedValue({ sessionType: 'app-automate', hashedBuildId: 'app-build' }); + vi.mocked(appAutomate.retrieveAppiumLogs).mockResolvedValue('appium logs'); + vi.mocked(appAutomate.retrieveDeviceLogs).mockResolvedValue('device logs'); + + const result = await getFailureLogs({ sessionId: 'test-session', - buildId: 'test-build', - logTypes: ['networkLogs'], + logTypes: ['sessionLogs', 'networkLogs'], sessionType: 'app-automate' }, mockServer); - expect(automateResult.isError).toBe(true); - expect(appAutomateResult.isError).toBe(true); + expect(result.isError).toBeFalsy(); + expect(appAutomate.retrieveAppiumLogs).toHaveBeenCalledWith('test-session', 'app-build', mockServer); + expect(appAutomate.retrieveDeviceLogs).not.toHaveBeenCalled(); + expect(automate.retrieveSessionFailures).not.toHaveBeenCalled(); + const texts = result.content.map((c: any) => c.text).join('\n'); + expect(texts).toContain("'networkLogs' is not available for app-automate sessions."); + }); + + it('throws when mismatched log types name a session neither product knows', async () => { + const mockServer = { server: { getClientVersion: () => "test-version" } }; + (detectSession as any).mockResolvedValue(undefined); + await expect(getFailureLogs({ + sessionId: 'test-session', + logTypes: ['deviceLogs'], + sessionType: 'automate' + }, mockServer)).rejects.toThrow('was not found on Automate or App Automate'); }); }); diff --git a/tests/tools/list-session-ids.test.ts b/tests/tools/list-session-ids.test.ts index 81616315..19571b9c 100644 --- a/tests/tools/list-session-ids.test.ts +++ b/tests/tools/list-session-ids.test.ts @@ -8,7 +8,10 @@ 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"; +import { + NoSessionsError, + resolveHashedBuildId, +} from "../../src/tools/automate-utils/resolve-hashed-build-id"; vi.mock("../../src/lib/apiClient", () => ({ apiClient: { @@ -264,13 +267,14 @@ describe("listSessionIdsTool", () => { 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}`, + `Resolved TRA 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: false, status: 404, statusText: "Not Found", data: {} }) .mockResolvedValueOnce({ ok: true, status: 200, data: samplePayload }); (resolveHashedBuildId as Mock).mockResolvedValue({ @@ -293,45 +297,97 @@ describe("listSessionIdsTool", () => { `/builds/${OBS_HEX}/sessions.json`, ); expect((apiClient.get as Mock).mock.calls[1][0].url).toContain( + `/app-automate/builds/${OBS_HEX}/sessions.json`, + ); + expect((apiClient.get as Mock).mock.calls[2][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}`, + `Resolved TRA build ${OBS_HEX} to hashed build id ${HASHED_BUILD}`, ); }); - it("rethrows the original 404 when a 40-hex id resolves to nothing", async () => { + it("names every product it tried when a 40-hex id is unknown everywhere", async () => { (apiClient.get as Mock).mockResolvedValue({ ok: false, status: 404, statusText: "Not Found", data: {}, }); - (resolveHashedBuildId as Mock).mockRejectedValue(new Error("No BrowserStack sessions found")); + (resolveHashedBuildId as Mock).mockRejectedValue(new Error("404")); await expect( listSessionIdsTool( { sessionType: SessionType.Automate, buildId: "deadbeef".repeat(5) }, mockConfig, ), - ).rejects.toThrow(/No automate build found/); - expect(apiClient.get).toHaveBeenCalledTimes(1); + ).rejects.toThrow(/No Automate, App Automate or TRA build has id/); + expect(apiClient.get).toHaveBeenCalledTimes(2); }); - it("surfaces the resolver error for a UUID with no sessions", async () => { + it("surfaces a TRA server error instead of calling the id unknown", async () => { + const { HttpError } = await import("../../src/lib/error"); + (apiClient.get as Mock).mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + data: {}, + }); (resolveHashedBuildId as Mock).mockRejectedValue( - new Error("No BrowserStack sessions found for observability build"), + new HttpError("Failed to fetch test runs: 503 Service Unavailable", 503), ); + await expect( + listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: "deadbeef".repeat(5) }, + mockConfig, + ), + ).rejects.toThrow(/503 Service Unavailable/); + }); + + it("switches product when the hashed build ran on the other one", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found", data: {} }) + .mockResolvedValueOnce({ ok: true, status: 200, data: samplePayload }); + + const result = await listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: HASHED_BUILD }, + mockConfig, + ); + + expect((apiClient.get as Mock).mock.calls[1][0].url).toContain( + `/app-automate/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('use sessionType "app-automate"'); + expect(resolveHashedBuildId).not.toHaveBeenCalled(); + }); + + it("routes a base-36 TRA id straight to resolution", async () => { + const TRA_ID = "zz00000000000000000000000000000000000003"; + (resolveHashedBuildId as Mock).mockRejectedValue(new NoSessionsError(TRA_ID)); + + await expect( + listSessionIdsTool( + { sessionType: SessionType.Automate, buildId: TRA_ID }, + mockConfig, + ), + ).rejects.toThrow(/has no BrowserStack sessions: its results were uploaded/); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it("surfaces the resolver error for a UUID with no sessions", async () => { + (resolveHashedBuildId as Mock).mockRejectedValue(new NoSessionsError(OBS_UUID)); + await expect( listSessionIdsTool( { sessionType: SessionType.Automate, buildId: OBS_UUID }, mockConfig, ), - ).rejects.toThrow(/No BrowserStack sessions found/); + ).rejects.toThrow(/has no BrowserStack sessions/); expect(apiClient.get).not.toHaveBeenCalled(); }); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index 9d00fdbf..c9128f7a 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -1,3 +1,4 @@ +import { HttpError } from "../../src/lib/error"; import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { getBuildIdTool, @@ -5,20 +6,28 @@ import { 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 { resolveBuild } from "../../src/tools/rca-agent-utils/resolve-build"; +import { mapHashedToTra } from "../../src/tools/automate-utils/resolve-tra-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"; import { getBrowserStackAuth } from "../../src/lib/get-auth"; import { TestStatus } from "../../src/tools/rca-agent-utils/types"; -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/resolve-build", + async (importOriginal) => ({ + ...(await importOriginal()), + resolveBuild: vi.fn(), + }), +); +vi.mock( + "../../src/tools/automate-utils/resolve-tra-build-id", + async (importOriginal) => ({ + ...(await importOriginal()), + mapHashedToTra: vi.fn(), + }), +); vi.mock("../../src/tools/rca-agent-utils/get-failed-test-id", () => ({ getTestIds: vi.fn(), })); @@ -46,7 +55,7 @@ describe("RCA Agent Tools", () => { describe("getBuildIdTool", () => { it("SUCCESS: returns build ID string", async () => { - (getBuildId as Mock).mockResolvedValue("build-abc-123"); + (resolveBuild as Mock).mockResolvedValue({ buildId: "build-abc-123" }); const result = await getBuildIdTool( { @@ -58,11 +67,16 @@ describe("RCA Agent Tools", () => { expect(result.isError).toBeFalsy(); expect(result.content[0].text).toBe("build-abc-123"); + expect(result.content).toHaveLength(1); expect(getBrowserStackAuth).toHaveBeenCalledWith(mockConfig); + // Prefers the caller's own builds. + expect((resolveBuild as Mock).mock.calls[0][2]).toEqual({ + userName: "fake-user", + }); }); it("FAIL: returns isError on API failure", async () => { - (getBuildId as Mock).mockRejectedValue(new Error("Not found")); + (resolveBuild as Mock).mockRejectedValue(new Error("Not found")); const result = await getBuildIdTool( { @@ -133,6 +147,36 @@ describe("RCA Agent Tools", () => { expect(parsed[0].failure.category).toBe("Assertion"); }); + it("SUCCESS: maps an Automate hashed id to its TRA build on 404", async () => { + const hashed = "ca9cccc228cf0e3ff3cb90dd62e2e2bfb4b20bc7"; + (getTestIds as Mock) + .mockRejectedValueOnce( + new HttpError("Failed to fetch test runs: 404 Not Found", 404), + ) + .mockResolvedValueOnce([{ test_id: "1" }]); + (mapHashedToTra as Mock).mockResolvedValue({ traBuildId: "tra-build-1" }); + + const result = await listTestIdsTool({ buildId: hashed }, mockConfig); + + expect(result.isError).toBeFalsy(); + expect((getTestIds as Mock).mock.calls[1][0]).toBe("tra-build-1"); + expect(result.content[1].text).toContain("maps to TRA build tra-build-1"); + }); + + it("FAIL: does not map non-hex ids on 404", async () => { + (getTestIds as Mock).mockRejectedValue( + new HttpError("Failed to fetch test runs: 404 Not Found", 404), + ); + + const result = await listTestIdsTool( + { buildId: "zz00000000000000000000000000000000000003" }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(mapHashedToTra).not.toHaveBeenCalled(); + }); + it("FAIL: returns isError on API failure", async () => { (getTestIds as Mock).mockRejectedValue(new Error("Invalid build")); @@ -145,7 +189,10 @@ 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"); + (resolveBuild as Mock).mockResolvedValue({ + buildId: "build-xyz-789", + note: 'Found "junit" #3 by its dashboard name.', + }); const result = await listBuildIdTool( { @@ -156,12 +203,14 @@ describe("RCA Agent Tools", () => { ); expect(result.isError).toBeFalsy(); + // The id stays alone in content[0]; how it was found follows. expect(result.content[0].text).toBe("build-xyz-789"); - expect(getBrowserStackAuth).toHaveBeenCalledWith(mockConfig); + expect(result.content[1].text).toContain("dashboard name"); + expect((resolveBuild as Mock).mock.calls[0][2]).toBeUndefined(); }); it("FAIL: returns isError on API failure", async () => { - (listBuildId as Mock).mockRejectedValue(new Error("Not found")); + (resolveBuild as Mock).mockRejectedValue(new Error("Not found")); const result = await listBuildIdTool( { diff --git a/tests/tools/resolve-hashed-build-id.test.ts b/tests/tools/resolve-hashed-build-id.test.ts index 164e4389..f81b7101 100644 --- a/tests/tools/resolve-hashed-build-id.test.ts +++ b/tests/tools/resolve-hashed-build-id.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; import { SessionType } from "../../src/lib/constants"; import { apiClient } from "../../src/lib/apiClient"; import { + detectSession, findSessionIdForObservabilityBuild, isHashedBuildId, + isTraBuildId, isObservabilityBuildUuid, resolveBuildIdFromSession, resolveHashedBuildId, @@ -106,7 +108,7 @@ describe("resolveBuildIdFromSession", () => { ); }); - it("returns undefined on HTTP failure or a missing field", async () => { + it("returns undefined on a 404 or a missing field", async () => { (apiClient.get as Mock).mockResolvedValueOnce({ ok: false, status: 404 }); await expect( resolveBuildIdFromSession(SESSION_ID, SessionType.Automate, config), @@ -117,6 +119,17 @@ describe("resolveBuildIdFromSession", () => { resolveBuildIdFromSession(SESSION_ID, SessionType.AppAutomate, config), ).resolves.toBeUndefined(); }); + + it("throws on other failures so an outage is not read as an unknown session", async () => { + (apiClient.get as Mock).mockResolvedValueOnce({ + ok: false, + status: 502, + statusText: "Bad Gateway", + }); + await expect( + resolveBuildIdFromSession(SESSION_ID, SessionType.Automate, config), + ).rejects.toThrow(/502 Bad Gateway/); + }); }); describe("findSessionIdForObservabilityBuild", () => { @@ -186,6 +199,42 @@ describe("findSessionIdForObservabilityBuild", () => { }); }); +describe("isTraBuildId", () => { + it("accepts UUIDs and base-36 ids with a letter past f", () => { + expect(isTraBuildId(OBS_UUID)).toBe(true); + expect(isTraBuildId("zz00000000000000000000000000000000000003")).toBe(true); + }); + + it("rejects all-hex ids, which are probably Automate hashed ids", () => { + expect(isTraBuildId(HASHED_BUILD)).toBe(false); + expect(isTraBuildId("not-an-id")).toBe(false); + }); +}); + +describe("detectSession", () => { + beforeEach(() => vi.clearAllMocks()); + + it("finds the session on the other product when the preferred one misses", async () => { + (apiClient.get as Mock) + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce(sessionDetails(HASHED_BUILD)); + + await expect( + detectSession(SESSION_ID, SessionType.AppAutomate, config), + ).resolves.toEqual({ + sessionType: SessionType.Automate, + hashedBuildId: HASHED_BUILD, + }); + }); + + it("returns undefined when neither product knows the session", async () => { + (apiClient.get as Mock).mockResolvedValue({ ok: false, status: 404 }); + await expect( + detectSession(SESSION_ID, SessionType.Automate, config), + ).resolves.toBeUndefined(); + }); +}); + describe("resolveHashedBuildId", () => { beforeEach(() => vi.clearAllMocks()); @@ -232,10 +281,21 @@ describe("resolveHashedBuildId", () => { ); await expect(resolveHashedBuildId(OBS_UUID, config)).rejects.toThrow( - /No BrowserStack sessions found/, + /has no BrowserStack sessions: its results were uploaded/, ); }); + it("corrects a wrong session type by trying the other product", 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, SessionType.Automate), + ).resolves.toMatchObject({ sessionType: SessionType.AppAutomate }); + }); + it("fails clearly when the session does not report a build", async () => { (apiClient.get as Mock) .mockResolvedValueOnce(testRunsPage([{ id: 1, session_id: SESSION_ID }])) diff --git a/tests/tools/resolveBuild.test.ts b/tests/tools/resolveBuild.test.ts new file mode 100644 index 00000000..2a9fa0bd --- /dev/null +++ b/tests/tools/resolveBuild.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { apiClient } from "../../src/lib/apiClient"; +import { SessionType } from "../../src/lib/constants"; +import { HttpError } from "../../src/lib/error"; +import { + BuildLookupError, + parseBuildUrl, + resolveBuild, +} from "../../src/tools/rca-agent-utils/resolve-build"; +import { + resolveTraBuildIdFromHashed, + TraBuildNotFoundError, +} from "../../src/tools/automate-utils/resolve-tra-build-id"; +import { + callCapability, + CapabilityUnavailableError, +} from "../../src/tools/capability-registry/call"; + +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", +})); +vi.mock("../../src/tools/capability-registry/call", async (importOriginal) => ({ + ...(await importOriginal()), + callCapability: vi.fn(), +})); +vi.mock( + "../../src/tools/automate-utils/resolve-tra-build-id", + async (importOriginal) => ({ + ...(await importOriginal()), + resolveTraBuildIdFromHashed: vi.fn(), + }), +); + +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +}; + +const TRA_ID = "zz00000000000000000000000000000000000003"; +const HASHED = "aa00000000000000000000000000000000000001"; + +const ok = (data: unknown) => ({ ok: true, status: 200, data }); +const notFound = { ok: false, status: 404, statusText: "Not Found", data: {} }; + +/** Answers /builds/latest by SDK build name; any other URL is unexpected. */ +function routeLatest(latest: Record) { + (apiClient.get as Mock).mockImplementation(async ({ url, params }) => { + if (url.endsWith("/ext/v1/builds/latest")) { + const id = latest[params.build_name]; + return id ? ok({ build_id: id }) : notFound; + } + throw new Error(`unexpected url ${url}`); + }); +} + +const PROJECT = "Sample Project"; + +describe("parseBuildUrl", () => { + it("reads project, dashboard name and number from a TRA link", () => { + expect( + parseBuildUrl( + "https://observability.browserstack.com/projects/Sample+Project/builds/junit/3?tab=tests", + ), + ).toEqual({ + projectName: "Sample Project", + buildName: "junit", + buildNumber: 3, + }); + }); + + it("reads a TRA link without a build number", () => { + expect( + parseBuildUrl( + "https://observability.browserstack.com/projects/My%20App/builds/nightly%20run", + ), + ).toEqual({ projectName: "My App", buildName: "nightly run" }); + }); + + it("reads the hashed id and product from Automate links", () => { + expect( + parseBuildUrl( + `https://automate.browserstack.com/dashboard/v2/builds/${HASHED}/sessions/abc`, + ), + ).toEqual({ hashedBuildId: HASHED, sessionType: SessionType.Automate }); + expect( + parseBuildUrl( + `https://app-automate.browserstack.com/dashboard/v2/builds/${HASHED}`, + ), + ).toEqual({ hashedBuildId: HASHED, sessionType: SessionType.AppAutomate }); + }); + + it("accepts bare ids and rejects text it cannot read", () => { + expect(parseBuildUrl(TRA_ID)).toEqual({ traBuildId: TRA_ID }); + expect(parseBuildUrl(HASHED)).toEqual({ hashedBuildId: HASHED }); + expect(parseBuildUrl("my build")).toBeUndefined(); + expect(parseBuildUrl("https://example.com/nothing")).toBeUndefined(); + }); +}); + +describe("resolveBuild", () => { + beforeEach(() => vi.resetAllMocks()); + + it("returns the latest run for the SDK build name in one request", async () => { + routeLatest({ "unit-tests-ci-1234": TRA_ID }); + + await expect( + resolveBuild( + { + browserStackProjectName: PROJECT, + browserStackBuildName: "unit-tests-ci-1234", + }, + config, + ), + ).resolves.toEqual({ buildId: TRA_ID }); + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + it("explains the name mismatch after one request instead of searching", async () => { + routeLatest({}); + + await expect( + resolveBuild( + { browserStackProjectName: PROJECT, browserStackBuildName: "junit" }, + config, + ), + ).rejects.toThrow( + /matches the name from browserstack.yml[\s\S]*build number or dashboard link/, + ); + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + const capabilityAnswer = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + completed: true, + http_response: { status, body }, + }); + + it("resolves a build number through the capability in one call", async () => { + (callCapability as Mock).mockResolvedValue( + capabilityAnswer(200, { + buildId: "zz00000000000000000000000000000000000002", + }), + ); + + const result = await resolveBuild( + { + browserStackProjectName: PROJECT, + browserStackBuildName: "junit", + buildNumber: 2, + }, + config, + ); + + expect(result.buildId).toBe("zz00000000000000000000000000000000000002"); + expect(callCapability).toHaveBeenCalledWith( + "tra", + "get_build_id_from_build_coordinates", + { + query: { + projectNormalisedName: PROJECT, + buildNormalisedName: "junit", + buildSerialId: 2, + }, + }, + config, + ); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it("reads project, name and number from TRA and Automate project links", async () => { + (callCapability as Mock).mockResolvedValue( + capabilityAnswer(200, { + buildId: "aa000000000000000000000000000000000000b1", + }), + ); + + const result = await resolveBuild( + { + buildUrl: + "https://automate.browserstack.com/projects/Mobile+App/builds/Checkout+flow++smoke/1022", + }, + config, + ); + + expect(result.buildId).toBe("aa000000000000000000000000000000000000b1"); + expect((callCapability as Mock).mock.calls[0][2]).toEqual({ + query: { + projectNormalisedName: "Mobile App", + buildNormalisedName: "Checkout flow smoke", + buildSerialId: 1022, + }, + }); + }); + + it("explains which name to use when no build has that name and number", async () => { + (callCapability as Mock).mockResolvedValue(capabilityAnswer(404, null)); + + await expect( + resolveBuild( + { + browserStackProjectName: PROJECT, + browserStackBuildName: "unit-tests-ci-1234", + buildNumber: 3, + }, + config, + ), + ).rejects.toThrow( + /No build #3 named[\s\S]*build name shown on the dashboard/, + ); + }); + + it("returns a clear error when the capability registry is off", async () => { + (callCapability as Mock).mockRejectedValue( + new CapabilityUnavailableError( + "the capability registry is disabled on this server", + ), + ); + + await expect( + resolveBuild( + { + browserStackProjectName: PROJECT, + browserStackBuildName: "junit", + buildNumber: 2, + }, + config, + ), + ).rejects.toThrow(/not available here[\s\S]*TRA build id/); + }); + + it("surfaces a server error from the capability", async () => { + (callCapability as Mock).mockResolvedValue( + capabilityAnswer(503, { message: "down" }), + ); + + await expect( + resolveBuild( + { + browserStackProjectName: PROJECT, + browserStackBuildName: "junit", + buildNumber: 2, + }, + config, + ), + ).rejects.toThrow(/503/); + }); + + it("looks up the latest run for a project link without a number", async () => { + routeLatest({ junit: TRA_ID }); + + await expect( + resolveBuild( + { + buildUrl: + "https://observability.browserstack.com/projects/Sample+Project/builds/junit", + }, + config, + ), + ).resolves.toEqual({ buildId: TRA_ID }); + }); + + it("prefers the caller's own build, then anyone's, for getBuildId", async () => { + (apiClient.get as Mock).mockImplementation(async ({ params }) => + params.user_name ? notFound : ok({ build_id: TRA_ID }), + ); + + const result = await resolveBuild( + { browserStackProjectName: PROJECT, browserStackBuildName: "b" }, + config, + { userName: "user" }, + ); + + expect(result).toEqual({ + buildId: TRA_ID, + note: expect.stringContaining("latest run by anyone"), + }); + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); + + it("maps an Automate build link to its TRA build", async () => { + (resolveTraBuildIdFromHashed as Mock).mockResolvedValue({ + traBuildId: TRA_ID, + hashedBuildId: HASHED, + sessionType: SessionType.AppAutomate, + projectName: "P", + buildName: "B", + }); + + const result = await resolveBuild( + { + buildUrl: `https://app-automate.browserstack.com/dashboard/v2/builds/${HASHED}`, + }, + config, + ); + + expect(resolveTraBuildIdFromHashed).toHaveBeenCalledWith( + HASHED, + config, + SessionType.AppAutomate, + ); + expect(result).toMatchObject({ + buildId: TRA_ID, + note: expect.stringContaining("listSessions takes either id"), + }); + }); + + it("returns the hashed id with the reason when the Automate build has no TRA build", async () => { + (resolveTraBuildIdFromHashed as Mock).mockRejectedValue( + new TraBuildNotFoundError( + "only runs through the BrowserStack SDK report tests to TRA", + ), + ); + + const result = await resolveBuild({ buildUrl: HASHED }, config); + + expect(result).toMatchObject({ + buildId: HASHED, + note: expect.stringContaining( + "listTestIds and fetchRCA need a TRA build id", + ), + }); + }); + + it("surfaces a server error instead of returning the Automate id as a TRA id", async () => { + (resolveTraBuildIdFromHashed as Mock).mockRejectedValue( + new HttpError( + "Failed to read automate build: 503 Service Unavailable", + 503, + ), + ); + + await expect( + resolveBuild( + { + buildUrl: `https://automate.browserstack.com/dashboard/v2/builds/${HASHED}`, + }, + config, + ), + ).rejects.toThrow(/503/); + }); + + it("asks for both names when either is missing", async () => { + await expect( + resolveBuild({ browserStackBuildName: "junit" }, config), + ).rejects.toThrow(BuildLookupError); + await expect( + resolveBuild({ browserStackProjectName: PROJECT }, config), + ).rejects.toThrow(/browserStackBuildName/); + await expect( + resolveBuild({ buildUrl: "https://example.com/x" }, config), + ).rejects.toThrow(/Could not read a build from that link/); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it("retries a dropped connection once", async () => { + (apiClient.get as Mock) + .mockRejectedValueOnce(new Error("socket hang up")) + .mockResolvedValueOnce(ok({ build_id: TRA_ID })); + + await expect( + resolveBuild( + { browserStackProjectName: "P", browserStackBuildName: "b" }, + config, + ), + ).resolves.toEqual({ buildId: TRA_ID }); + }); +}); diff --git a/tests/tools/resolveTraBuildId.test.ts b/tests/tools/resolveTraBuildId.test.ts new file mode 100644 index 00000000..339a9ae7 --- /dev/null +++ b/tests/tools/resolveTraBuildId.test.ts @@ -0,0 +1,169 @@ +import { HttpError } from "../../src/lib/error"; +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { apiClient } from "../../src/lib/apiClient"; +import { SessionType } from "../../src/lib/constants"; +import { + mapHashedToTra, + resolveTraBuildIdFromHashed, + TraBuildNotFoundError, +} from "../../src/tools/automate-utils/resolve-tra-build-id"; +import { resolveHashedBuildId } 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", +})); +vi.mock( + "../../src/tools/automate-utils/resolve-hashed-build-id", + async (importOriginal) => ({ + ...(await importOriginal()), + resolveHashedBuildId: vi.fn(), + }), +); + +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +}; +const HASHED = "aa00000000000000000000000000000000000001"; +const TRA_ID = "zz000000000000000000000000000000000000a1"; + +const ok = (data: unknown) => ({ ok: true, status: 200, data }); +const notFound = { ok: false, status: 404, statusText: "Not Found", data: {} }; +const sessionList = ok([ + { + automation_session: { + hashed_id: "s1", + project_name: "Mobile", + build_name: "nightly-42", + }, + }, +]); + +describe("resolveTraBuildIdFromHashed", () => { + beforeEach(() => vi.resetAllMocks()); + + it("maps via the session's project and build name, and confirms the run", async () => { + (apiClient.get as Mock).mockImplementation(async ({ url, params }) => { + if (url.includes("/automate/builds/")) return notFound; + if (url.includes("/app-automate/builds/")) return sessionList; + if (url.endsWith("/builds/latest")) { + expect(params).toEqual({ + project_name: "Mobile", + build_name: "nightly-42", + }); + return ok({ build_id: TRA_ID }); + } + throw new Error(url); + }); + (resolveHashedBuildId as Mock).mockResolvedValue({ + hashedBuildId: HASHED, + sessionId: "s1", + sessionType: SessionType.AppAutomate, + }); + + await expect(resolveTraBuildIdFromHashed(HASHED, config)).resolves.toEqual({ + traBuildId: TRA_ID, + hashedBuildId: HASHED, + sessionType: SessionType.AppAutomate, + projectName: "Mobile", + buildName: "nightly-42", + }); + }); + + it("refuses an older run rather than searching for it", async () => { + (apiClient.get as Mock).mockImplementation(async ({ url }) => + url.endsWith("/builds/latest") ? ok({ build_id: TRA_ID }) : sessionList, + ); + (resolveHashedBuildId as Mock).mockResolvedValue({ + hashedBuildId: "a-different-run", + sessionId: "s9", + sessionType: SessionType.Automate, + }); + + await expect(resolveTraBuildIdFromHashed(HASHED, config)).rejects.toThrow( + /is not the latest TRA build named "nightly-42"/, + ); + // One session read and one latest lookup: no walk over older builds. + const urls = (apiClient.get as Mock).mock.calls.map(([req]) => req.url); + expect(urls.some((u: string) => u.includes("/ext/v1/projects"))).toBe( + false, + ); + }); + + it("explains a build that never reported to TRA", async () => { + (apiClient.get as Mock).mockImplementation(async ({ url }) => + url.endsWith("/builds/latest") ? notFound : sessionList, + ); + + await expect(resolveTraBuildIdFromHashed(HASHED, config)).rejects.toThrow( + /has no TRA build: only runs through the BrowserStack SDK/, + ); + }); + + it("flags ids that neither product knows", async () => { + (apiClient.get as Mock).mockResolvedValue(notFound); + + const error = await resolveTraBuildIdFromHashed(HASHED, config).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(TraBuildNotFoundError); + expect(error.unknownToAutomate).toBe(true); + }); +}); + +describe("resolveTraBuildIdFromHashed: only 404 means another product", () => { + beforeEach(() => vi.resetAllMocks()); + + it("throws on a server error rather than trying the other product", async () => { + (apiClient.get as Mock).mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + data: {}, + }); + + const error = await resolveTraBuildIdFromHashed(HASHED, config).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(HttpError); + expect(error.status).toBe(503); + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); + + it("does not hide an outage behind the confirmation step", async () => { + (apiClient.get as Mock).mockImplementation(async ({ url }) => + url.endsWith("/builds/latest") ? ok({ build_id: TRA_ID }) : sessionList, + ); + (resolveHashedBuildId as Mock).mockRejectedValue( + new HttpError( + "Failed to fetch test runs: 500 Internal Server Error", + 500, + ), + ); + + await expect(resolveTraBuildIdFromHashed(HASHED, config)).rejects.toThrow( + /500/, + ); + }); +}); + +describe("mapHashedToTra", () => { + beforeEach(() => vi.resetAllMocks()); + + it("keeps the original 404 and points to listBuildId for unknown ids", async () => { + (apiClient.get as Mock).mockResolvedValue(notFound); + + await expect( + mapHashedToTra(HASHED, new Error("Failed: 404 Not Found"), config), + ).rejects.toThrow( + "Failed: 404 Not Found. No TRA build has this id; get one with listBuildId.", + ); + }); +});