diff --git a/src/index.ts b/src/index.ts index caa8d79b..7cdb97a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,7 @@ async function main() { const transport = new StdioServerTransport(); - const mcpServer = new BrowserStackMcpServer({ + const mcpServer = await BrowserStackMcpServer.create({ "browserstack-username": username, "browserstack-access-key": accessKey, }); diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts new file mode 100644 index 00000000..89bf5e9a --- /dev/null +++ b/src/lib/account-region.ts @@ -0,0 +1,183 @@ +import { createHash } from "node:crypto"; +import logger from "../logger.js"; +import { apiClient } from "./apiClient.js"; +import { BrowserStackConfig } from "./types.js"; +import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; +import { AccountRegion, GRR_REGIONS, GrrRegion } from "./region-context.js"; + +const AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token"; +const SCOPE = "oauth_user_profile"; +const LOOKUP_TIMEOUT_MS = 5_000; +/** How long a failed lookup answers "unknown" before the next call tries again. */ +export const RETRY_AFTER_MS = 5_000; +/** How long a looked-up region is reused before it is looked up again. */ +export const REGION_TTL_MS = 60 * 60_000; +/** How long a region found by probing is reused, so BrowserStack auth is asked again soon. */ +export const PROBED_TTL_MS = 5 * 60_000; + +// Tried when BrowserStack auth can't answer: the first host that accepts the account's +// credentials is its region. Regional hosts first, so a GRR account is found there. +const PROBE_HOSTS: ReadonlyArray<[GrrRegion, string]> = [ + ["eu", "https://test-management-eu.browserstack.com"], + ["in", "https://test-management-in.browserstack.com"], + ["us", "https://test-management.browserstack.com"], +]; +const MAX_ENTRIES = 1_000; + +interface Lookup { + settled?: AccountRegion; + settledAt?: number; + ttl?: number; + pending?: Promise; + failedAt?: number; +} + +// Keyed on a hash of the credentials, so servers built per request for the same account +// share one lookup. Holds only the region; bounded, oldest entry evicted first. +const lookups = new Map(); + +function keyFor(config: BrowserStackConfig): string { + return createHash("sha256") + .update( + `${config["browserstack-username"]}\0${config["browserstack-access-key"]}`, + ) + .digest("hex"); +} + +function remember(key: string, entry: Lookup): void { + lookups.delete(key); + lookups.set(key, entry); + if (lookups.size > MAX_ENTRIES) { + lookups.delete(lookups.keys().next().value!); + } +} + +/** Reads `user.grr.region` from the token; an account with no region is a US account. */ +export function regionFromToken(token: string): AccountRegion { + const payload = JSON.parse( + Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"), + ); + const raw = payload?.user?.grr?.region; + if (raw === undefined || raw === null || raw === "") return "us"; + const region = String(raw).toLowerCase(); + return (GRR_REGIONS as readonly string[]).includes(region) + ? (region as GrrRegion) + : "unknown"; +} + +/** The account's region from BrowserStack auth, or null when auth could not answer. */ +async function fromToken( + config: BrowserStackConfig, +): Promise { + try { + const token = await mintCentralToken({ + url: AUTH_TOKEN_URL, + scope: SCOPE, + credentials: { + username: config["browserstack-username"], + accessKey: config["browserstack-access-key"], + }, + transport: fetchTokenTransport(LOOKUP_TIMEOUT_MS), + refreshSkewMs: 0, + cacheEnabled: false, + }); + return regionFromToken(token); + } catch (error) { + logger.warn( + `Could not look up the account's region: ${(error as Error)?.message}`, + ); + return null; + } +} + +/** The region whose Test Management host accepts the account, or null when none does. */ +async function probe(config: BrowserStackConfig): Promise { + const authorization = + "Basic " + + Buffer.from( + `${config["browserstack-username"]}:${config["browserstack-access-key"]}`, + ).toString("base64"); + for (const [region, host] of PROBE_HOSTS) { + try { + const res = await apiClient.get({ + url: `${host}/api/v2/projects/`, + headers: { Authorization: authorization }, + timeout: LOOKUP_TIMEOUT_MS, + raise_error: false, + }); + if (res.ok) return region; + } catch (error) { + logger.debug(`Region probe failed for ${host}: ${error}`); + } + } + return null; +} + +interface Answer { + region: AccountRegion; + ttl: number; +} + +/** BrowserStack auth's answer, else a probe's; null when neither can tell. */ +async function lookUp(config: BrowserStackConfig): Promise { + const region = await fromToken(config); + if (region !== null && region !== "unknown") { + return { region, ttl: REGION_TTL_MS }; + } + const probed = await probe(config); + if (probed) return { region: probed, ttl: PROBED_TTL_MS }; + return region === null ? null : { region, ttl: REGION_TTL_MS }; +} + +/** + * The account's region, looked up once per account and refreshed after REGION_TTL_MS + * (PROBED_TTL_MS when it was found by probing). + * Returned directly once known so callers that cannot wait (telemetry) stay synchronous; + * undefined without credentials. When auth can't answer, the account's region is found by + * probing the Test Management hosts. A refresh keeps answering the last known region, and + * keeps it if the refresh fails. With no region known, a failure answers "unknown" for + * RETRY_AFTER_MS, and the next call after that looks up again. + */ +export function accountRegion( + config: BrowserStackConfig | undefined, +): AccountRegion | undefined | Promise { + if ( + !config?.["browserstack-username"] || + !config?.["browserstack-access-key"] + ) { + return undefined; + } + const now = Date.now(); + const key = keyFor(config); + const lookup = lookups.get(key); + if (lookup?.settled && now - lookup.settledAt! < lookup.ttl!) { + return lookup.settled; + } + if (lookup?.pending) return lookup.settled ?? lookup.pending; + if ( + lookup?.failedAt !== undefined && + now - lookup.failedAt < RETRY_AFTER_MS + ) { + return lookup.settled ?? "unknown"; + } + + const entry: Lookup = { + settled: lookup?.settled, + settledAt: lookup?.settledAt, + ttl: lookup?.ttl, + }; + entry.pending = lookUp(config).then((answer) => { + entry.pending = undefined; + if (answer === null) { + entry.failedAt = Date.now(); + return entry.settled ?? "unknown"; + } + entry.settled = answer.region; + entry.settledAt = Date.now(); + entry.ttl = answer.ttl; + entry.failedAt = undefined; + return answer.region; + }); + remember(key, entry); + return entry.settled ?? entry.pending; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 64051e77..134d8857 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,13 +1,15 @@ import { getBrowserStackAuth } from "./get-auth.js"; import { BrowserStackConfig } from "../lib/types.js"; import { apiClient } from "./apiClient.js"; +import { getRegionalHost } from "./regional-host.js"; export async function getLatestO11YBuildInfo( buildName: string, projectName: string, config: BrowserStackConfig, ) { - const buildsUrl = `https://api-observability.browserstack.com/ext/v1/builds/latest?build_name=${encodeURIComponent( + const host = getRegionalHost("observabilityApi"); + const buildsUrl = `${host}/ext/v1/builds/latest?build_name=${encodeURIComponent( buildName, )}&project_name=${encodeURIComponent(projectName)}`; diff --git a/src/lib/feedback-hint.ts b/src/lib/feedback-hint.ts index 75f5290d..52d4ee0c 100644 --- a/src/lib/feedback-hint.ts +++ b/src/lib/feedback-hint.ts @@ -1,3 +1,22 @@ +import { + currentRegionScope, + isRestrictedRegion, + isTelemetryOff, +} from "./region-context.js"; + +/** Whether this call may send feedback; not while the account's region is unknown. */ +export function feedbackEnabled(): boolean { + return !isRestrictedRegion(currentRegionScope()?.account); +} + +/** + * Whether to list submitFeedback. An unknown region may be looked up later in the + * session, so it stays listed and each call decides. + */ +export function feedbackListed(): boolean { + return !isTelemetryOff(currentRegionScope()?.account); +} + export const FEEDBACK_INSTRUCTION = "If a BrowserStack tool cannot do what the user needs, call submitFeedback before giving up or working around it."; @@ -25,7 +44,7 @@ export const NO_HINT_TOOLS: ReadonlySet = new Set([ ]); export function withFeedbackHint(toolName: string, result: T): T { - if (NO_HINT_TOOLS.has(toolName)) return result; + if (!feedbackEnabled() || NO_HINT_TOOLS.has(toolName)) return result; const r = result as { isError?: unknown; content?: unknown } | null; if (typeof r !== "object" || r === null || r.isError !== true) return result; if (!Array.isArray(r.content) || SELF_FIXABLE.test(textOf(r))) return result; diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index 1a1fe5a0..6aa88f01 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -7,6 +7,13 @@ const packageJson = require("../../package.json"); import { apiClient } from "./apiClient.js"; import globalConfig from "../config.js"; import { redact } from "../tools/capability-registry/redact.js"; +import { accountRegion } from "./account-region.js"; +import { + AccountRegion, + currentRegionScope, + isRestrictedRegion, + isTelemetryOff, +} from "./region-context.js"; const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event"; @@ -64,7 +71,63 @@ function errorProperties(error: unknown) { }; } +const NON_US_REGION_FIELDS = new Set([ + "mcp_version", + "tool_name", + "mcp_client", + "node_version", + "is_remote", + "success", + "duration_ms", + "outcome", + "error_type", + "refusal_reason", + "product", + "entity", + "capability", + "capability_method", + "capability_path", + "capability_mode", + "results_returned", + "total_matched", + "truncated", + "weak_match", + "coverage", + "upstream_ok", + "upstream_status", +]); + +function forDataRegion( + props: MCPEventPayload["event_properties"], + account: AccountRegion | undefined, +): MCPEventPayload["event_properties"] { + if (!isRestrictedRegion(account)) return props; + return Object.fromEntries( + Object.entries(props).filter(([key]) => NON_US_REGION_FIELDS.has(key)), + ) as MCPEventPayload["event_properties"]; +} + function sendEvent(event: MCPEventPayload, config?: any): void { + // Inside a tool call the region is already known; outside one, wait for the lookup. + const inCall = currentRegionScope(); + const account = inCall ? inCall.account : accountRegion(config); + if (account instanceof Promise) { + account.then((region) => post(event, config, region)).catch(() => {}); + return; + } + post(event, config, account); +} + +function post( + event: MCPEventPayload, + config: any, + account: AccountRegion | undefined, +): void { + if (isTelemetryOff(account)) return; + event = { + ...event, + event_properties: forDataRegion(event.event_properties, account), + }; let authHeader: string | undefined; if (config) { try { diff --git a/src/lib/region-context.ts b/src/lib/region-context.ts new file mode 100644 index 00000000..45817559 --- /dev/null +++ b/src/lib/region-context.ts @@ -0,0 +1,46 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export const GRR_REGIONS = ["us", "eu", "in"] as const; +export type GrrRegion = (typeof GRR_REGIONS)[number]; + +/** The account's region from BrowserStack auth, or "unknown" when the lookup failed. */ +export type AccountRegion = GrrRegion | "unknown"; + +interface RegionScope { + account: AccountRegion | undefined; +} + +// Per call, not per process: concurrent calls for different accounts each see their own. +const scope = new AsyncLocalStorage(); + +export function withAccountRegion( + account: AccountRegion | undefined, + fn: () => T, +): T { + return scope.run({ account }, fn); +} + +/** The scope set for the current call, or undefined outside one. */ +export function currentRegionScope(): RegionScope | undefined { + return scope.getStore(); +} + +/** The account's region, or undefined when it is not known. */ +export function regionOf( + account: AccountRegion | undefined, +): GrrRegion | undefined { + return account && account !== "unknown" ? account : undefined; +} + +/** True for an EU or IN account: no telemetry is sent for it. */ +export function isTelemetryOff(account: AccountRegion | undefined): boolean { + const region = regionOf(account); + return region !== undefined && region !== "us"; +} + +/** True when telemetry must be limited to usage fields; an unknown account counts. */ +export function isRestrictedRegion( + account: AccountRegion | undefined, +): boolean { + return account === "unknown" || (account !== undefined && account !== "us"); +} diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts new file mode 100644 index 00000000..b78fad07 --- /dev/null +++ b/src/lib/regional-host.ts @@ -0,0 +1,150 @@ +import appConfig from "../config.js"; +import { SessionType } from "./constants.js"; +import { currentRegionScope, GrrRegion, regionOf } from "./region-context.js"; + +/** Regions with their own hosts here. Other regions use the default (US) hosts. */ +type RoutedRegion = "us" | "eu"; + +type Hosts = Record; + +const AUTOMATE_API: Hosts = { + us: "https://api.browserstack.com", + eu: "https://api-eu-only.browserstack.com", +}; + +const HOSTS = { + testManagement: { + us: "https://test-management.browserstack.com", + eu: "https://test-management-eu.browserstack.com", + }, + automateApi: AUTOMATE_API, + appAutomateApi: AUTOMATE_API, + appAutomateCloud: { + us: "https://api-cloud.browserstack.com", + eu: AUTOMATE_API.eu, + }, + appAutomateHub: { + us: "hub.browserstack.com", + eu: "hub-eu-only.browserstack.com", + }, + automateHubUrl: { + us: "https://hub.browserstack.com", + eu: "https://hub-eu-only.browserstack.com", + }, + appAutomateHubUrl: { + us: "https://hub-cloud.browserstack.com", + eu: "https://hub-eu-only.browserstack.com", + }, + accessibilityApi: { + us: "https://api-accessibility.browserstack.com", + eu: "https://api-accessibility-eu.browserstack.com", + }, + accessibilityDashboard: { + us: "https://accessibility.browserstack.com", + eu: "https://accessibility-eu.browserstack.com", + }, + appAccessibilityApi: { + us: "https://api-app-accessibility.browserstack.com", + eu: "https://api-app-accessibility-eu.browserstack.com", + }, + appAccessibilityDashboard: { + us: "https://app-accessibility.browserstack.com", + eu: "https://app-accessibility-eu.browserstack.com", + }, + observabilityApi: { + us: "https://api-observability.browserstack.com", + eu: "https://api-observability-eu.browserstack.com", + }, + automationApi: { + us: "https://api-automation.browserstack.com", + eu: "https://api-automation-eu.browserstack.com", + }, +} as const satisfies Record; + +export type RegionalHostKind = keyof typeof HOSTS; + +export function hostFor(kind: RegionalHostKind, region: RoutedRegion): string { + return HOSTS[kind][region]; +} + +/** True when `hostname` is `kind`'s host in any region, so a link from any region resolves. */ +export function isRegionalHostname( + kind: RegionalHostKind, + hostname: string, +): boolean { + return Object.values(HOSTS[kind]).some( + (host) => new URL(host).hostname === hostname, + ); +} + +/** This call's account region; undefined outside a call or when the lookup failed. */ +export function currentRegion(): GrrRegion | undefined { + return regionOf(currentRegionScope()?.account); +} + +export const REGION_UNKNOWN_MESSAGE = + "Could not confirm your BrowserStack account's region. Please retry in a few seconds."; + +/** Thrown instead of guessing a host when the account's region could not be looked up. */ +export class AccountRegionUnknownError extends Error { + constructor() { + super(REGION_UNKNOWN_MESSAGE); + this.name = "AccountRegionUnknownError"; + } +} + +/** Throws when this call's account region could not be looked up. */ +export function assertRegionKnown(): void { + if (currentRegionScope()?.account === "unknown") { + throw new AccountRegionUnknownError(); + } +} + +/** The region whose hosts this call uses; throws when the region is unknown. */ +export function routedRegion(): RoutedRegion { + assertRegionKnown(); + return currentRegion() === "eu" ? "eu" : "us"; +} + +/** True when this call goes to regional hosts rather than the defaults. */ +export function usesRegionalHosts(): boolean { + return currentRegion() === "eu"; +} + +/** REST host for a session's product: App Automate or Automate. */ +export function sessionApiHost(sessionType: string): string { + return getRegionalHost( + sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi", + ); +} + +/** + * An env var's host when it points somewhere other than `kind`'s US default, without a + * trailing slash; undefined when it is unset or names the default in any spelling. + */ +export function hostOverride( + url: string | undefined, + kind: RegionalHostKind, +): string | undefined { + const value = url?.trim().replace(/\/+$/, ""); + if (!value) return undefined; + try { + if (new URL(value).origin === new URL(HOSTS[kind].us).origin) { + return undefined; + } + } catch { + // Not a URL: pass it through as given. + } + return value; +} + +export function getRegionalHost(kind: RegionalHostKind): string { + if (kind === "automationApi") { + const override = hostOverride( + appConfig.BROWSERSTACK_AUTOMATION_BASE_URL, + kind, + ); + if (override) return override; + } + return hostFor(kind, routedRegion()); +} diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index e92165b9..4fdc0089 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -3,7 +3,9 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; import appConfig from "../config.js"; +import { getRegionalHost, usesRegionalHosts } from "./regional-host.js"; +// Probe order when the account has no regional hosts here. const TM_BASE_URLS = [ "https://test-management.browserstack.com", "https://test-management-eu.browserstack.com", @@ -15,6 +17,12 @@ let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { + // A routed region is used as given, like every other product. Any other account keeps + // the probe below. + if (usesRegionalHosts()) { + return getRegionalHost("testManagement"); + } + // Skip the module-level cache in remote (multi-tenant) mode: it is process-shared, // so the first user's region would be served to every subsequent user — breaking // requests for users on a different region's BrowserStack account. diff --git a/src/lib/tool-latency.ts b/src/lib/tool-latency.ts index 58da1962..f1d42318 100644 --- a/src/lib/tool-latency.ts +++ b/src/lib/tool-latency.ts @@ -1,8 +1,12 @@ import { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ClientInfo, withToolCall } from "./instrumentation.js"; import { withFeedbackHint } from "./feedback-hint.js"; +import { accountRegion } from "./account-region.js"; +import { withAccountRegion } from "./region-context.js"; +import { BrowserStackConfig } from "./types.js"; const WRAPPED = Symbol.for("browserstack.mcp.latencyWrapped"); +const REGION_BOUND = Symbol.for("browserstack.mcp.regionBound"); type AnyHandler = (...args: unknown[]) => unknown; @@ -27,3 +31,25 @@ export function instrumentToolLatency( (tool as { handler: unknown }).handler = wrapped; } } + +/** Runs every handler in the account's region, looked up once for `config`. Idempotent. */ +export function bindAccountRegion( + tools: Record, + config: BrowserStackConfig, +): void { + for (const tool of Object.values(tools)) { + const inner = tool.handler as unknown; + if (typeof inner !== "function") continue; + if ((inner as AnyHandler & { [REGION_BOUND]?: true })[REGION_BOUND]) + continue; + + const bound: AnyHandler & { [REGION_BOUND]?: true } = async ( + ...args: unknown[] + ) => { + const account = await accountRegion(config); + return withAccountRegion(account, () => (inner as AnyHandler)(...args)); + }; + bound[REGION_BOUND] = true; + (tool as { handler: unknown }).handler = bound; + } +} diff --git a/src/server-factory.ts b/src/server-factory.ts index 00563a7c..6171434f 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -22,9 +22,14 @@ import { BrowserStackConfig } from "./lib/types.js"; import addRCATools from "./tools/rca-agent.js"; import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js"; import addCapabilityRegistryTools from "./tools/capability-registry/register.js"; -import { instrumentToolLatency } from "./lib/tool-latency.js"; +import { + bindAccountRegion, + instrumentToolLatency, +} from "./lib/tool-latency.js"; +import { accountRegion } from "./lib/account-region.js"; +import { withAccountRegion } from "./lib/region-context.js"; import { nodeUpgradeNotice } from "./lib/node-version-notice.js"; -import { FEEDBACK_INSTRUCTION } from "./lib/feedback-hint.js"; +import { FEEDBACK_INSTRUCTION, feedbackListed } from "./lib/feedback-hint.js"; import addFeedbackTools from "./tools/feedback.js"; /** @@ -35,6 +40,18 @@ export class BrowserStackMcpServer { public server: McpServer; public tools: Record = {}; + /** + * Looks up the account's region first, so the tool list and instructions match it. + * The constructor alone still routes each call by region, but lists tools as for a + * US account. + */ + static async create( + config: BrowserStackConfig, + ): Promise { + const account = await accountRegion(config); + return withAccountRegion(account, () => new BrowserStackMcpServer(config)); + } + constructor(private config: BrowserStackConfig) { logger.info( "Creating BrowserStack MCP Server, version %s", @@ -47,7 +64,10 @@ export class BrowserStackMcpServer { version: packageJson.version, }, { - instructions: [nodeUpgradeNotice(), FEEDBACK_INSTRUCTION] + instructions: [ + nodeUpgradeNotice(), + feedbackListed() ? FEEDBACK_INSTRUCTION : undefined, + ] .filter(Boolean) .join("\n"), }, @@ -82,7 +102,7 @@ export class BrowserStackMcpServer { // nothing (and logs why) when the artifact is absent, so a packaging problem cannot // take the other products' tools down with it. addCapabilityRegistryTools, - addFeedbackTools, + ...(feedbackListed() ? [addFeedbackTools] : []), ]; toolAdders.forEach((adder) => { @@ -100,6 +120,8 @@ export class BrowserStackMcpServer { () => this.server.server.getClientVersion() ?? {}, this.config, ); + // Outermost, so telemetry and error hints also see the account's region. + bindAccountRegion(this.tools, this.config); } /** diff --git a/src/tools/accessiblity-utils/accessibility-rag.ts b/src/tools/accessiblity-utils/accessibility-rag.ts index 578320a2..8c999f3a 100644 --- a/src/tools/accessiblity-utils/accessibility-rag.ts +++ b/src/tools/accessiblity-utils/accessibility-rag.ts @@ -8,6 +8,7 @@ export interface RAGChunk { import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; export interface AccessibilityRAGResponse { content: Array<{ @@ -20,7 +21,7 @@ export async function queryAccessibilityRAG( userQuery: string, config: BrowserStackConfig, ): Promise { - const url = "https://accessibility.browserstack.com/api/tcg-proxy/search"; + const url = `${getRegionalHost("accessibilityDashboard")}/api/tcg-proxy/search`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/accessiblity-utils/app-report-fetcher.ts b/src/tools/accessiblity-utils/app-report-fetcher.ts index c5975a9f..58511489 100644 --- a/src/tools/accessiblity-utils/app-report-fetcher.ts +++ b/src/tools/accessiblity-utils/app-report-fetcher.ts @@ -3,18 +3,22 @@ import { SessionType } from "../../lib/constants.js"; import { sessionsListUrl } from "../automate-utils/list-session-ids.js"; import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js"; import { mostCommon, pageOf, sortBySeverity } from "./paginate.js"; +import { + getRegionalHost, + isRegionalHostname, +} from "../../lib/regional-host.js"; /** * App Accessibility: the issues of a mobile app build run through App Automate with * accessibility on. Unlike the web products there is no CSV export behind an access key, * so the issues come inline, one API page per batch of scanned screens. */ -export const APP_API_BASE = - "https://api-app-accessibility.browserstack.com/automate/api/v1"; -export const APP_DASHBOARD = "https://app-accessibility.browserstack.com"; +export const appApiBase = () => + `${getRegionalHost("appAccessibilityApi")}/automate/api/v1`; +export const appDashboard = () => getRegionalHost("appAccessibilityDashboard"); /** Manual (Workflow Analyzer) reports recorded on the dashboard: a separate, older route. */ -export const MANUAL_API_BASE = - "https://api-app-accessibility.browserstack.com/api/v1"; +export const manualApiBase = () => + `${getRegionalHost("appAccessibilityApi")}/api/v1`; /** Screens per API page, and how many pages a build or report is read across (1,000 screens). */ const SNAPSHOT_PAGE_SIZE = 50; @@ -25,8 +29,8 @@ export const MAX_APP_BUILD_LIST_PAGES = 3; const BUILD_LIST_PAGE_SIZE = 30; /** A native build is found by name in the account's App Automate build list, newest first. */ -const APP_AUTOMATE_BUILDS_URL = - "https://api-cloud.browserstack.com/app-automate/builds.json"; +const appAutomateBuildsUrl = () => + `${getRegionalHost("appAutomateCloud")}/app-automate/builds.json`; const APP_AUTOMATE_BUILDS_PAGE_SIZE = 100; export const MAX_APP_AUTOMATE_BUILD_PAGES = 3; /** A native run's accessibility build is created as the run starts: seconds apart, in practice. */ @@ -653,7 +657,8 @@ function appAutomateIds( /** The id a manual report's dashboard link names (`/report/{uuid}`), if it is one. */ export function manualReportIdFromDashboardLink(url: URL): string | undefined { - if (url.hostname !== new URL(APP_DASHBOARD).hostname) return undefined; + if (!isRegionalHostname("appAccessibilityDashboard", url.hostname)) + return undefined; return url.pathname.match(/^\/report\/([0-9a-f-]{36})\/?$/i)?.[1]; } @@ -702,7 +707,7 @@ export class AppAccessibilityApi { */ async buildMeta(buildId: string): Promise { const resp = await apiClient.get({ - url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}`, + url: `${appApiBase()}/build/${encodeURIComponent(buildId)}`, headers: this.headers(), raise_error: false, }); @@ -721,7 +726,8 @@ export class AppAccessibilityApi { * carries TestHub's name for the build, which the filter does not match.) */ async buildIdFromDashboardLink(url: URL): Promise { - if (url.hostname !== new URL(APP_DASHBOARD).hostname) return undefined; + if (!isRegionalHostname("appAccessibilityDashboard", url.hostname)) + return undefined; const build = buildOfDashboardLink(url); if (!build) return undefined; // The name in the link is TestHub's, which can differ from the build's own: with no @@ -848,7 +854,7 @@ export class AppAccessibilityApi { ): Promise<{ project?: string; startedAt?: number } | undefined> { for (const framework of ["espresso", "xcuitest"]) { const resp = await apiClient.get({ - url: `https://api-cloud.browserstack.com/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`, + url: `${getRegionalHost("appAutomateCloud")}/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`, headers: this.headers(), raise_error: false, }); @@ -898,7 +904,7 @@ export class AppAccessibilityApi { ): Promise { for (let page = 0; page < MAX_APP_AUTOMATE_BUILD_PAGES; page++) { const resp = await apiClient.get({ - url: `${APP_AUTOMATE_BUILDS_URL}?limit=${APP_AUTOMATE_BUILDS_PAGE_SIZE}&offset=${page * APP_AUTOMATE_BUILDS_PAGE_SIZE}`, + url: `${appAutomateBuildsUrl()}?limit=${APP_AUTOMATE_BUILDS_PAGE_SIZE}&offset=${page * APP_AUTOMATE_BUILDS_PAGE_SIZE}`, headers: this.headers(), raise_error: false, }); @@ -928,7 +934,7 @@ export class AppAccessibilityApi { */ private async projectsNamed(name: string): Promise { const data = await this.listing( - `${APP_API_BASE}/project?name=${encodeURIComponent(name)}`, + `${appApiBase()}/project?name=${encodeURIComponent(name)}`, `the App Accessibility projects named "${name}"`, ); return data.projects ?? []; @@ -960,7 +966,7 @@ export class AppAccessibilityApi { */ async manualReport(reportId: string): Promise { const resp = await apiClient.get({ - url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}`, + url: `${manualApiBase()}/reports/${encodeURIComponent(reportId)}`, headers: this.headers(), raise_error: false, }); @@ -988,7 +994,7 @@ export class AppAccessibilityApi { for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) { const resp = await apiClient .get({ - url: `${MANUAL_API_BASE}/app_scanner/${encodeURIComponent(reportId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, + url: `${manualApiBase()}/app_scanner/${encodeURIComponent(reportId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, headers: this.headers(), raise_error: false, }) @@ -1015,7 +1021,7 @@ export class AppAccessibilityApi { | undefined; for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) { const resp = await apiClient.get({ - url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}/issues?page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, + url: `${manualApiBase()}/reports/${encodeURIComponent(reportId)}/issues?page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, headers: this.headers(), raise_error: false, }); @@ -1073,7 +1079,7 @@ export class AppAccessibilityApi { ...(device?.model ? { device: `${device.model}, ${device.os} ${device.os_version}` } : {}), - dashboard_link: `${APP_DASHBOARD}/report/${reportId}`, + dashboard_link: `${appDashboard()}/report/${reportId}`, ...(more ? { screens_read: details.length } : {}), }; } @@ -1108,7 +1114,7 @@ export class AppAccessibilityApi { const named = buildName ? `build_name=${q(buildName)}&` : ""; for (let page = 1; page <= MAX_APP_BUILD_LIST_PAGES; page++) { const data = await this.listing( - `${APP_API_BASE}/project/${q(String(projectId))}/build?${named}page_size=${BUILD_LIST_PAGE_SIZE}&page=${page}`, + `${appApiBase()}/project/${q(String(projectId))}/build?${named}page_size=${BUILD_LIST_PAGE_SIZE}&page=${page}`, `page ${page} of App Accessibility project ${projectId}'s builds`, ); const more = Boolean(data.info?.next); @@ -1182,7 +1188,7 @@ export class AppAccessibilityApi { try { const projectId = await this.projectId(meta.project_name); if (projectId === undefined) return undefined; - return `${APP_DASHBOARD}/automated-tests/projects/${projectId}/builds/${meta.normalised_name}/${meta.build_id}?thBuildId=${encodeURIComponent(meta.th_build_id)}`; + return `${appDashboard()}/automated-tests/projects/${projectId}/builds/${meta.normalised_name}/${meta.build_id}?thBuildId=${encodeURIComponent(meta.th_build_id)}`; } catch { return undefined; } @@ -1200,7 +1206,7 @@ export class AppAccessibilityApi { let more = false; for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) { const resp = await apiClient.get({ - url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, + url: `${appApiBase()}/build/${encodeURIComponent(buildId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`, headers: this.headers(), raise_error: false, }); diff --git a/src/tools/accessiblity-utils/auth-config.ts b/src/tools/accessiblity-utils/auth-config.ts index 9ceaeaa9..d0293884 100644 --- a/src/tools/accessiblity-utils/auth-config.ts +++ b/src/tools/accessiblity-utils/auth-config.ts @@ -1,5 +1,6 @@ import { apiClient } from "../../lib/apiClient.js"; import logger from "../../logger.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; export interface AuthConfigResponse { success: boolean; @@ -110,7 +111,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -168,7 +169,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -207,7 +208,7 @@ export class AccessibilityAuthConfig { try { // No GET-by-id route exists; list and select. const response = await apiClient.get({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + diff --git a/src/tools/accessiblity-utils/report-fetcher.ts b/src/tools/accessiblity-utils/report-fetcher.ts index 301eb5a9..1f99bb13 100644 --- a/src/tools/accessiblity-utils/report-fetcher.ts +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -11,6 +11,7 @@ import { import { SessionType } from "../../lib/constants.js"; import { sessionsListUrl } from "../automate-utils/list-session-ids.js"; import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; export { MAX_APP_BUILD_LIST_PAGES } from "./app-report-fetcher.js"; @@ -37,7 +38,7 @@ export type IssueSource = | { kind: "appBuild"; buildId: string } | { kind: "appReport"; reportId: string }; -const API_BASE = "https://api-accessibility.browserstack.com/api"; +const apiBase = () => `${getRegionalHost("accessibilityApi")}/api`; /** * How far a report link's scan is searched for. The scan list has no name or link filter and @@ -157,21 +158,21 @@ function exportUrls(source: IssueSource): { const q = encodeURIComponent; switch (source.kind) { case "scan": { - const route = `${API_BASE}/website-scanner/v1/scans/${q(source.scanId)}/scan_runs/issues`; + const route = `${apiBase()}/website-scanner/v1/scans/${q(source.scanId)}/scan_runs/issues`; return { start: `${route}?scan_run_id=${q(source.scanRunId)}`, poll: (taskId) => `${route}?task_id=${q(taskId)}`, }; } case "build": { - const route = `${API_BASE}/automated-tests/v1/builds/issues`; + const route = `${apiBase()}/automated-tests/v1/builds/issues`; return { start: `${route}?buildUuid=${q(source.buildId)}`, poll: (taskId) => `${route}?taskId=${q(taskId)}`, }; } case "workflow": { - const route = `${API_BASE}/workflow-analyzer/v1/reports/issues`; + const route = `${apiBase()}/workflow-analyzer/v1/reports/issues`; return { start: `${route}?reportId=${q(source.reportId)}`, poll: (taskId) => `${route}?taskId=${q(taskId)}`, @@ -292,7 +293,7 @@ export class AccessibilityReportFetcher { // Only a 404 means the web API does not know the id: any other failure is not an // answer about the build, and asking the app API next would hide it. const resp = await apiClient.get({ - url: `${API_BASE}/automated-tests/v1/builds/${encodeURIComponent(source.buildId)}`, + url: `${apiBase()}/automated-tests/v1/builds/${encodeURIComponent(source.buildId)}`, headers: this.authHeaders(), raise_error: false, }); @@ -394,7 +395,7 @@ export class AccessibilityReportFetcher { ); } const build = await apiClient.get({ - url: `${API_BASE}/automated-tests/v1/builds/${encodeURIComponent(buildId)}`, + url: `${apiBase()}/automated-tests/v1/builds/${encodeURIComponent(buildId)}`, headers: this.authHeaders(), raise_error: false, }); @@ -420,7 +421,7 @@ export class AccessibilityReportFetcher { let sameName: number | undefined; for (let page = 0; page < MAX_SCAN_LIST_PAGES; page++) { const resp = await apiClient.get({ - url: `${API_BASE}/website-scanner/v1/scans${ + url: `${apiBase()}/website-scanner/v1/scans${ nextPage ? `?nextPage=${encodeURIComponent(nextPage)}` : "" }`, headers: this.authHeaders(), @@ -542,7 +543,7 @@ export class AccessibilityReportFetcher { ): Promise { // The runs come a page at a time; the one before may be on a later page. const runs: ScanRun[] = []; - const base = `${API_BASE}/website-scanner/v1/scans/${encodeURIComponent(scanId)}/scan_runs`; + const base = `${apiBase()}/website-scanner/v1/scans/${encodeURIComponent(scanId)}/scan_runs`; let nextPage: string | undefined; for (let page = 0; page < MAX_SCAN_RUN_PAGES; page++) { const data = await this.getData( @@ -567,7 +568,7 @@ export class AccessibilityReportFetcher { const q = encodeURIComponent; const changes = ( await this.getData( - `${API_BASE}/website-scanner/v1/scans/${q(scanId)}/scan_runs/${q(scanRunId)}`, + `${apiBase()}/website-scanner/v1/scans/${q(scanId)}/scan_runs/${q(scanRunId)}`, ) )?.changesSinceLastRun; return changes ?? undefined; @@ -587,13 +588,13 @@ export class AccessibilityReportFetcher { case "scan": return ( await this.getData( - `${API_BASE}/website-scanner/v1/scans/${q(source.scanId)}/scan_runs/${q(source.scanRunId)}/status`, + `${apiBase()}/website-scanner/v1/scans/${q(source.scanId)}/scan_runs/${q(source.scanRunId)}/status`, ) )?.status; case "workflow": return ( await this.getData( - `${API_BASE}/workflow-analyzer/v1/reports/${q(source.reportId)}`, + `${apiBase()}/workflow-analyzer/v1/reports/${q(source.reportId)}`, ) )?.summary?.status; case "build": @@ -616,14 +617,14 @@ export class AccessibilityReportFetcher { this.webBuildProjects.get(buildId) ?? ( await this.getData( - `${API_BASE}/automated-tests/v1/builds/${q(buildId)}`, + `${apiBase()}/automated-tests/v1/builds/${q(buildId)}`, ) )?.projectName; if (!project) return undefined; let nextPage: string | undefined; for (let page = 0; page < MAX_BUILD_LIST_PAGES; page++) { const data = await this.getData( - `${API_BASE}/automated-tests/v1/builds?project_name=${q(project)}&limit=100${ + `${apiBase()}/automated-tests/v1/builds?project_name=${q(project)}&limit=100${ nextPage ? `&next_page=${q(nextPage)}` : "" }`, ); diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 821b082f..204593ae 100644 --- a/src/tools/accessiblity-utils/scanner.ts +++ b/src/tools/accessiblity-utils/scanner.ts @@ -7,6 +7,7 @@ import { killExistingBrowserStackLocalProcesses, } from "../../lib/local.js"; import config from "../../config.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; export interface AccessibilityScanResponse { success: boolean; @@ -121,7 +122,7 @@ export class AccessibilityScanner { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans`, headers: { Authorization: "Basic " + @@ -171,7 +172,7 @@ export class AccessibilityScanner { const read = (path: string) => apiClient .get({ - url: `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${path}`, + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${path}`, headers: { Authorization: "Basic " + @@ -216,7 +217,7 @@ export class AccessibilityScanner { async dashboardLink(scanId: string): Promise { const resp = await apiClient .get({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans`, headers: { Authorization: "Basic " + @@ -242,7 +243,7 @@ export class AccessibilityScanner { ): Promise { try { const response = await apiClient.get({ - url: `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/${scanRunId}/status`, headers: { Authorization: "Basic " + diff --git a/src/tools/appautomate-utils/native-execution/appautomate.ts b/src/tools/appautomate-utils/native-execution/appautomate.ts index b5e1c9b2..6583bc11 100644 --- a/src/tools/appautomate-utils/native-execution/appautomate.ts +++ b/src/tools/appautomate-utils/native-execution/appautomate.ts @@ -11,6 +11,7 @@ import { MAX_APP_UPLOAD_BYTES, } from "../../../lib/upload-validator.js"; import appConfig from "../../../config.js"; +import { getRegionalHost } from "../../../lib/regional-host.js"; interface Device { device: string; @@ -156,7 +157,7 @@ export async function uploadApp( formData.append("file", fs.createReadStream(safePath)); const response = await apiClient.post({ - url: "https://api-cloud.browserstack.com/app-automate/upload", + url: `${getRegionalHost("appAutomateCloud")}/app-automate/upload`, headers: { ...formData.getHeaders(), Authorization: @@ -218,7 +219,7 @@ export async function uploadEspressoApp( ): Promise { return uploadFileToBrowserStack( appPath, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", + `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/app`, "app_url", config, { allowedExtensions: [".apk", ".aab"] }, @@ -232,7 +233,7 @@ export async function uploadEspressoTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", + `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".apk"] }, @@ -246,7 +247,7 @@ export async function uploadXcuiApp( ): Promise { return uploadFileToBrowserStack( appPath, - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", + `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/app`, "app_url", config, { allowedExtensions: [".ipa"] }, @@ -260,7 +261,7 @@ export async function uploadXcuiTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", + `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".zip"] }, @@ -302,7 +303,7 @@ export async function triggerEspressoBuild( "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); const response = await apiClient.post({ - url: "https://api-cloud.browserstack.com/app-automate/espresso/v2/build", + url: `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/build`, headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -338,7 +339,7 @@ export async function triggerXcuiBuild( }; const response = await apiClient.post({ - url: "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build", + url: `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/build`, headers: { Authorization: "Basic " + diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index 59114cab..324f7b07 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -43,6 +43,7 @@ import { RUN_APP_AUTOMATE_DESCRIPTION, RUN_APP_AUTOMATE_SCHEMA, } from "./appautomate-utils/native-execution/constants.js"; +import { getRegionalHost } from "../lib/regional-host.js"; /** * Launches an app on a selected BrowserStack device and takes a screenshot. @@ -133,7 +134,7 @@ async function takeAppScreenshot(args: { try { driver = await remote({ protocol: "https", - hostname: "hub.browserstack.com", + hostname: getRegionalHost("appAutomateHub"), port: 443, path: "/wd/hub", capabilities, diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 2a204df0..5b99fff0 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -4,6 +4,7 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import globalConfig from "../../config.js"; +import { sessionApiHost } from "../../lib/regional-host.js"; import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js"; export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP @@ -18,7 +19,9 @@ async function extractScreenshotUrls( const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const baseUrl = `https://api.browserstack.com/${sessionType === SessionType.Automate ? "automate" : "app-automate"}`; + const isAutomate = sessionType === SessionType.Automate; + const host = sessionApiHost(sessionType); + const baseUrl = `${host}/${isAutomate ? "automate" : "app-automate"}`; const url = `${baseUrl}/sessions/${sessionId}/logs`; const response = await apiClient.get({ diff --git a/src/tools/automate-utils/list-session-ids.ts b/src/tools/automate-utils/list-session-ids.ts index 3204cce3..381527d9 100644 --- a/src/tools/automate-utils/list-session-ids.ts +++ b/src/tools/automate-utils/list-session-ids.ts @@ -2,6 +2,7 @@ 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 { getRegionalHost } from "../../lib/regional-host.js"; export const DEFAULT_SESSION_LIST_LIMIT = 10; @@ -52,13 +53,16 @@ interface SessionListItem { export function sessionsListUrl( sessionType: SessionType, buildId: string, + host = getRegionalHost( + sessionType === SessionType.Automate ? "automateApi" : "appAutomateCloud", + ), ): string { const encodedBuildId = encodeURIComponent(buildId); switch (sessionType) { case SessionType.Automate: - return `https://api.browserstack.com/automate/builds/${encodedBuildId}/sessions.json`; + return `${host}/automate/builds/${encodedBuildId}/sessions.json`; case SessionType.AppAutomate: - return `https://api-cloud.browserstack.com/app-automate/builds/${encodedBuildId}/sessions.json`; + return `${host}/app-automate/builds/${encodedBuildId}/sessions.json`; default: { const _exhaustive: never = sessionType; throw new Error(`Unsupported session type: ${_exhaustive}`); diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts index 578c93d0..9d538500 100644 --- a/src/tools/automate-utils/resolve-hashed-build-id.ts +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -3,9 +3,9 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import logger from "../../logger.js"; -import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js"; import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js"; import { TestRun } from "../rca-agent-utils/types.js"; +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". @@ -30,13 +30,14 @@ export function isHashedBuildId(id: string): boolean { export function sessionDetailsUrl( sessionType: SessionType, sessionId: string, + host = sessionApiHost(sessionType), ): string { const encoded = encodeURIComponent(sessionId); switch (sessionType) { case SessionType.Automate: - return `https://api.browserstack.com/automate/sessions/${encoded}.json`; + return `${host}/automate/sessions/${encoded}.json`; case SessionType.AppAutomate: - return `https://api.browserstack.com/app-automate/sessions/${encoded}.json`; + return `${host}/app-automate/sessions/${encoded}.json`; default: { const _exhaustive: never = sessionType; throw new Error(`Unsupported session type: ${_exhaustive}`); @@ -91,7 +92,7 @@ export async function findSessionIdForObservabilityBuild( ): Promise { const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${encodeURIComponent(observabilityBuildId)}/testRuns`; + const baseUrl = `${getRegionalHost("automationApi")}/ext/v1/builds/${encodeURIComponent(observabilityBuildId)}/testRuns`; let nextPage: string | undefined; for (let page = 0; page < MAX_TEST_RUN_PAGES; page++) { diff --git a/src/tools/build-insights.ts b/src/tools/build-insights.ts index 2ea00617..4287647b 100644 --- a/src/tools/build-insights.ts +++ b/src/tools/build-insights.ts @@ -6,6 +6,7 @@ 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 { getRegionalHost } from "../lib/regional-host.js"; // Tool function that fetches build insights from two APIs export async function fetchBuildInsightsTool( @@ -13,8 +14,9 @@ export async function fetchBuildInsightsTool( config: BrowserStackConfig, ): Promise { try { - const buildUrl = `https://api-automation.browserstack.com/ext/v1/builds/${args.buildId}`; - const qualityGateUrl = `https://api-automation.browserstack.com/ext/v1/quality-gates/${args.buildId}`; + const host = getRegionalHost("automationApi"); + const buildUrl = `${host}/ext/v1/builds/${args.buildId}`; + const qualityGateUrl = `${host}/ext/v1/quality-gates/${args.buildId}`; // Quality gate data is optional — a failure there should not block build insights const [buildData, qualityData] = await Promise.all([ diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 6e413041..6c59b61d 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -18,6 +18,12 @@ import { getTMBaseURL } from "../../lib/tm-base-url.js"; import { discoverBaseUrl, HostSource } from "./discovery.js"; import { Credentials, Transport, fetchTransport } from "./egress.js"; import { FLAT_SUFFIX, INDEX_FILE, InvocationError } from "./index-loader.js"; +import { + assertRegionKnown, + getRegionalHost, + usesRegionalHosts, + RegionalHostKind, +} from "../../lib/regional-host.js"; /** * The environment this DEPLOYMENT points at, e.g. "preprod". @@ -52,8 +58,8 @@ function environmentMap(): Record> { * * The general mechanism lives in `discovery.ts` and is driven by the index's `base_urls`. * This map is the bridge for products that predate that: tm's hosts are still hardcoded in - * `lib/tm-base-url.ts` and shared with the hand-written Test Management tools. Once tm's - * product.yaml declares `base_urls`, this entry can go and nothing else changes. + * `lib/tm-base-url.ts` and shared with the hand-written Test Management tools. Once a + * product.yaml declares `base_urls`, its entry can go and nothing else changes. */ const BUILTIN_DISCOVERY: Record< string, @@ -62,6 +68,34 @@ const BUILTIN_DISCOVERY: Record< tm: getTMBaseURL, }; +/** + * Hosts used when the account's region is routed here, ahead of discovery and the + * index's declared hosts. `hosts` covers the capabilities that name a host. + */ +const REGIONAL_HOSTS: Record< + string, + { base: RegionalHostKind; hosts?: Record } +> = { + tm: { base: "testManagement" }, + tra: { base: "observabilityApi" }, + app_accessibility: { base: "appAccessibilityApi" }, + web_accessibility: { base: "accessibilityApi" }, + devicecontrol: { base: "automateHubUrl", hosts: { api: "automateApi" } }, + mobiledevicecontrol: { + base: "appAutomateHubUrl", + hosts: { api: "appAutomateCloud" }, + }, +}; + +function regionalHost(product: string, host?: string): string | undefined { + const entry = REGIONAL_HOSTS[product]; + const kind = host ? entry?.hosts?.[host] : entry?.base; + if (!kind) return undefined; + // Test Management finds the account's region by probing; the others need it known. + if (kind !== "testManagement") assertRegionKnown(); + return usesRegionalHosts() ? getRegionalHost(kind) : undefined; +} + /** True when this product's host is account-specific and must be discovered per call. */ export function isRegionSharded(product: string, source?: HostSource): boolean { return (source?.base_urls?.length ?? 0) > 1 || product in BUILTIN_DISCOVERY; @@ -130,6 +164,9 @@ function resolveNamedHost( ); } + const regional = regionalHost(product, host); + if (regional) return regional; + const declared = source?.hosts?.[host]; if (declared) return trim(declared); @@ -193,6 +230,9 @@ export async function resolveBaseUrl( return trim(source.base_url); }; + const regional = regionalHost(product); + if (regional) return regional; + if ((source?.base_urls?.length ?? 0) > 0) { try { return await discoverBaseUrl( diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 349138ae..3c8a2ade 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -522,8 +522,6 @@ export function addCapabilityRegistryTools( "describeEntity", "unknown_product", `unknown product '${product}'`, - undefined, - { product }, ); const doc = bundle.entities[entity]; if (!doc) { @@ -697,7 +695,6 @@ export function addCapabilityRegistryTools( `No product named '${product}'. Call listProducts — passing the user's request ` + "as `query` — for the names, and for whether the products clash over it.", {}, - { product }, ); } if (!isBrowseQuery(query)) { @@ -872,7 +869,10 @@ export function addCapabilityRegistryTools( // call succeeded. track("searchCapability", { product, - entity, + entity: + entity && registry.index.products[product].entities?.[entity] + ? entity + : undefined, search_query: redact(query), results_returned: hits.length, total_matched: rest.total_matched, diff --git a/src/tools/failurelogs-utils/app-automate.ts b/src/tools/failurelogs-utils/app-automate.ts index b6254f25..c9aa2750 100644 --- a/src/tools/failurelogs-utils/app-automate.ts +++ b/src/tools/failurelogs-utils/app-automate.ts @@ -7,6 +7,7 @@ import { import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import { wrapUntrusted } from "../../lib/untrusted-content.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; // DEVICE LOGS export async function retrieveDeviceLogs( @@ -14,7 +15,7 @@ export async function retrieveDeviceLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/devicelogs`; + const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/devicelogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -47,7 +48,7 @@ export async function retrieveAppiumLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/appiumlogs`; + const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/appiumlogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -80,7 +81,7 @@ export async function retrieveCrashLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/crashlogs`; + const url = `${getRegionalHost("appAutomateApi")}/app-automate/builds/${buildId}/sessions/${sessionId}/crashlogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/failurelogs-utils/automate.ts b/src/tools/failurelogs-utils/automate.ts index 38d710fc..113d573d 100644 --- a/src/tools/failurelogs-utils/automate.ts +++ b/src/tools/failurelogs-utils/automate.ts @@ -9,13 +9,14 @@ import { } from "./utils.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; // NETWORK LOGS export async function retrieveNetworkFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/automate/sessions/${sessionId}/networklogs`; + const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/networklogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -78,7 +79,7 @@ export async function retrieveSessionFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`; + const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/logs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -110,7 +111,7 @@ export async function retrieveConsoleFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/automate/sessions/${sessionId}/consolelogs`; + const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/consolelogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/failurelogs-utils/video.ts b/src/tools/failurelogs-utils/video.ts index 7c23d68f..0feb39a6 100644 --- a/src/tools/failurelogs-utils/video.ts +++ b/src/tools/failurelogs-utils/video.ts @@ -3,6 +3,7 @@ import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import { SessionType } from "../../lib/constants.js"; import { validateLogResponse } from "./utils.js"; +import { sessionApiHost } from "../../lib/regional-host.js"; export async function retrieveSessionVideo( sessionId: string, @@ -11,7 +12,8 @@ export async function retrieveSessionVideo( ): Promise { const product = sessionType === SessionType.AppAutomate ? "app-automate" : "automate"; - const url = `https://api.browserstack.com/${product}/sessions/${encodeURIComponent(sessionId)}.json`; + const host = sessionApiHost(sessionType); + const url = `${host}/${product}/sessions/${encodeURIComponent(sessionId)}.json`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/feedback.ts b/src/tools/feedback.ts index b611212d..116a207e 100644 --- a/src/tools/feedback.ts +++ b/src/tools/feedback.ts @@ -4,6 +4,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; import { redactFeedback } from "./capability-registry/redact.js"; +import { feedbackEnabled } from "../lib/feedback-hint.js"; export const FEEDBACK_TOOL = "submitFeedback"; @@ -21,6 +22,9 @@ export const FEEDBACK_CATEGORIES = [ "other", ] as const; +export const FEEDBACK_UNAVAILABLE = + "Feedback can't be sent from this account. Continue with the user's task."; + export const FEEDBACK_ACK = "Recorded. No need to mention this report; continue with the user's task."; @@ -34,6 +38,9 @@ export function submitFeedback( server: McpServer, config: BrowserStackConfig, ): CallToolResult { + if (!feedbackEnabled()) { + return { content: [{ type: "text", text: FEEDBACK_UNAVAILABLE }] }; + } trackMCP( FEEDBACK_TOOL, server.server.getClientVersion()!, diff --git a/src/tools/rca-agent-utils/constants.ts b/src/tools/rca-agent-utils/constants.ts index 9ecbfebf..f32bc0ba 100644 --- a/src/tools/rca-agent-utils/constants.ts +++ b/src/tools/rca-agent-utils/constants.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import appConfig from "../../config.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; import { TestStatus } from "./types.js"; export function getAutomationBaseUrl(): string { - return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL; + return getRegionalHost("automationApi"); } export const FETCH_RCA_PARAMS = { diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts index 6f3dffe7..cc32aaff 100644 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -1,12 +1,13 @@ +import { getRegionalHost } from "../../lib/regional-host.js"; + export async function getBuildId( projectName: string, buildName: string, username: string, accessKey: string, ): Promise { - const url = new URL( - "https://api-automation.browserstack.com/ext/v1/builds/latest", - ); + 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); 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 4b977916..f8eac83b 100644 --- a/src/tools/rca-agent-utils/get-failed-test-id.ts +++ b/src/tools/rca-agent-utils/get-failed-test-id.ts @@ -19,9 +19,10 @@ export async function getTestIds( authString: string, status?: TestStatus, includeFailureDetail = false, + host: string = getAutomationBaseUrl(), ): Promise { // No `status` → returns ALL tests; a `status` narrows the query. - const baseUrl = `${getAutomationBaseUrl()}/ext/v1/builds/${buildId}/testRuns`; + const baseUrl = `${host}/ext/v1/builds/${buildId}/testRuns`; let url = status ? `${baseUrl}?test_statuses=${status}` : baseUrl; let allTests: FailedTestInfo[] = []; let requestNumber = 0; diff --git a/src/tools/rca-agent-utils/list-build-id.ts b/src/tools/rca-agent-utils/list-build-id.ts index c20c37eb..42f46b69 100644 --- a/src/tools/rca-agent-utils/list-build-id.ts +++ b/src/tools/rca-agent-utils/list-build-id.ts @@ -1,4 +1,5 @@ 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). @@ -12,8 +13,9 @@ export async function listBuildId( const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); + const host = getRegionalHost("automationApi"); const response = await apiClient.get({ - url: "https://api-automation.browserstack.com/ext/v1/builds/latest", + url: `${host}/ext/v1/builds/latest`, headers: { Authorization: authHeader, "Content-Type": "application/json", diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index de7949f5..80c25981 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -1,4 +1,5 @@ import { RCAState, RCATestCase, RCAResponse } from "./types.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; interface ScanProgressContext { sendNotification: (notification: any) => Promise; @@ -243,13 +244,13 @@ export async function getRCAData( testIds: number[], authString: string, context?: ScanProgressContext, + host = getRegionalHost("observabilityApi"), ): Promise { const pollInterval = 5000; const timeout = 40000; const initialDelay = 20000; - const baseUrl = - "https://api-observability.browserstack.com/ext/v1/testRun/{testId}/testRca"; + const baseUrl = `${host}/ext/v1/testRun/{testId}/testRca`; const headers = { Authorization: `Basic ${Buffer.from(authString).toString("base64")}`, "Content-Type": "application/json", diff --git a/src/tools/selfheal-utils/fetch-test-code.ts b/src/tools/selfheal-utils/fetch-test-code.ts index 6d51638f..16164455 100644 --- a/src/tools/selfheal-utils/fetch-test-code.ts +++ b/src/tools/selfheal-utils/fetch-test-code.ts @@ -2,8 +2,7 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import logger from "../../logger.js"; - -const OBSERVABILITY_API_BASE = "https://api-automation.browserstack.com/ext/v1"; +import { getRegionalHost } from "../../lib/regional-host.js"; export interface TestCodeEntry { testRunId: number; @@ -104,7 +103,7 @@ export async function fetchTestCodeBySession( const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const url = `${OBSERVABILITY_API_BASE}/sessions/${encodeURIComponent(sessionId)}/testCode`; + const url = `${getRegionalHost("automationApi")}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`; try { const response = await apiClient.get( diff --git a/src/tools/selfheal-utils/selfheal.ts b/src/tools/selfheal-utils/selfheal.ts index dfad8caa..7bd27da5 100644 --- a/src/tools/selfheal-utils/selfheal.ts +++ b/src/tools/selfheal-utils/selfheal.ts @@ -44,12 +44,10 @@ export interface SelfHealingReport { import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; +import { getRegionalHost, sessionApiHost } from "../../lib/regional-host.js"; type SessionType = "automate" | "app-automate"; -const SELF_HEAL_REPORT_BASE = - "https://api-automation.browserstack.com/ext/v1/builds"; - /** * Fetches the self-healing report for a build via the build-scoped API. * @@ -72,7 +70,7 @@ export async function fetchSelfHealingReportByBuild( const auth = Buffer.from(authString).toString("base64"); const presignedResp = await apiClient.get>({ - url: `${SELF_HEAL_REPORT_BASE}/${encodeURIComponent(buildUuid)}/selfHealingReport`, + url: `${getRegionalHost("automationApi")}/ext/v1/builds/${encodeURIComponent(buildUuid)}/selfHealingReport`, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, @@ -135,7 +133,8 @@ export async function getSelfHealSelectors( const auth = Buffer.from(authString).toString("base64"); const productPath = sessionType === "app-automate" ? "app-automate" : "automate"; - const url = `https://api.browserstack.com/${productPath}/sessions/${sessionId}/logs`; + const host = sessionApiHost(sessionType); + const url = `${host}/${productPath}/sessions/${sessionId}/logs`; const response = await apiClient.get({ url, diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index de5fe339..2386f315 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -267,7 +267,7 @@ export async function updateTestCase( - **Automation Status**: ${tc.automation_status ?? "N/A"} **View on BrowserStack Dashboard:** -https://test-management.browserstack.com/projects/${projectId}/folders/${tc.folder_id}/test-cases/${tc.identifier} +${tmBaseUrl}/projects/${projectId}/folders/${tc.folder_id}/test-cases/${tc.identifier} The test case has been updated successfully and is now available in your BrowserStack Test Management project.`, }, diff --git a/src/tools/tfa-rca-collaboration.ts b/src/tools/tfa-rca-collaboration.ts index 2b4789ea..17e36313 100644 --- a/src/tools/tfa-rca-collaboration.ts +++ b/src/tools/tfa-rca-collaboration.ts @@ -30,6 +30,7 @@ import { fetchTestsInFailureTheme, ListTestsInFailureThemeArgs, } from "./tfa-rca-utils/build-failure-themes.js"; +import { bindAccountRegion } from "../lib/tool-latency.js"; const TOOL_NAME = "tfaRcaTurn"; const GET_RESULT_TOOL_NAME = "getTfaTurnResult"; @@ -294,5 +295,7 @@ export default function addTfaRcaCollaborationTools( }, ); + // Registered by the hosted wrapper after the server is built, so bind them here too. + bindAccountRegion(tools, config); return tools; } diff --git a/src/tools/tfa-rca-utils/constants.ts b/src/tools/tfa-rca-utils/constants.ts index 158c02a0..4abc5351 100644 --- a/src/tools/tfa-rca-utils/constants.ts +++ b/src/tools/tfa-rca-utils/constants.ts @@ -1,9 +1,17 @@ import { z } from "zod"; import appConfig from "../../config.js"; +import { + hostFor, + hostOverride, + routedRegion, +} from "../../lib/regional-host.js"; export function getO11yBaseUrl(): string { - return appConfig.O11Y_TFA_RCA_BASE_URL; + return ( + hostOverride(appConfig.O11Y_TFA_RCA_BASE_URL, "automationApi") ?? + hostFor("automationApi", routedRegion()) + ); } export function getO11yUiBaseUrl(): string { diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts new file mode 100644 index 00000000..8973f0a0 --- /dev/null +++ b/tests/lib/account-region-behaviour.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const regions = new Map(); +vi.mock("../../src/lib/account-region.js", () => ({ + accountRegion: (config: any) => { + const region = regions.get(config?.["browserstack-username"]); + return region === undefined ? undefined : Promise.resolve(region); + }, +})); +vi.mock("../../src/lib/apiClient.js", () => ({ + apiClient: { post: vi.fn().mockResolvedValue({ status: 200, data: {} }) }, +})); +import { apiClient } from "../../src/lib/apiClient.js"; +import { getRegionalHost } from "../../src/lib/regional-host.js"; +import { bindAccountRegion } from "../../src/lib/tool-latency.js"; +import { trackMCP, withToolCall } from "../../src/lib/instrumentation.js"; +import { withAccountRegion } from "../../src/lib/region-context.js"; +import { BrowserStackMcpServer } from "../../src/server-factory.js"; +import { FEEDBACK_UNAVAILABLE } from "../../src/tools/feedback.js"; + +const configFor = (username: string) => ({ + "browserstack-username": username, + "browserstack-access-key": "key", +}); +const clientInfo = { name: "claude-code", version: "1" }; +const rows = () => + (apiClient.post as any).mock.calls.map((c: any) => c[0].body.event_properties); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function tmHostTool(config: ReturnType) { + const tools: any = { + probe: { + handler: async () => { + await settle(); + return getRegionalHost("testManagement"); + }, + }, + }; + bindAccountRegion(tools, config); + return tools.probe.handler as () => Promise; +} + +describe("the account's region", () => { + beforeEach(() => { + regions.clear(); + (apiClient.post as any).mockClear(); + }); + + it("routes concurrent calls for different accounts to their own region", async () => { + regions.set("eu-user", "eu").set("us-user", "us").set("in-user", "in"); + const hosts = await Promise.all( + ["eu-user", "us-user", "in-user"].map((u) => tmHostTool(configFor(u))()), + ); + expect(hosts).toEqual([ + "https://test-management-eu.browserstack.com", + "https://test-management.browserstack.com", + "https://test-management.browserstack.com", + ]); + }); + + it("refuses to pick a host when the lookup failed", async () => { + regions.set("someone", "unknown"); + await expect(tmHostTool(configFor("someone"))()).rejects.toThrow( + /Could not confirm your BrowserStack account's region/, + ); + }); + + it.each(["eu", "in"])( + "sends no telemetry outside a tool call for a %s account", + async (region) => { + regions.set("user", region); + trackMCP("started", clientInfo, undefined, configFor("user")); + await settle(); + expect(rows()).toHaveLength(0); + }, + ); + + it.each([ + ["unknown", true], + ["us", false], + ])( + "limits telemetry outside a tool call for a %s account: %s", + async (region, limited) => { + regions.set("user", region); + trackMCP("started", clientInfo, new Error("boom"), configFor("user"), { + search_query: "checkout runs", + }); + await settle(); + const row = rows().at(-1); + expect(row.tool_name).toBe("started"); + expect("error_message" in row).toBe(!limited); + expect("search_query" in row).toBe(!limited); + }, + ); + + it("sends no telemetry inside a tool call for an EU account", async () => { + await withAccountRegion("eu", () => + withToolCall("listTestCases", () => clientInfo, configFor("x"), async () => ({ + content: [{ type: "text", text: "Project PR-1 not found" }], + isError: true, + })), + ); + expect(rows()).toHaveLength(0); + }); + + it.each(["eu", "in"])( + "leaves feedback out of a server created for a %s account", + async (region) => { + regions.set("user", region); + const server = await BrowserStackMcpServer.create(configFor("user")); + expect(server.getTool("submitFeedback")).toBeUndefined(); + expect((server.getInstance().server as any)._instructions ?? "").not.toContain( + "submitFeedback", + ); + }, + ); + + it("keeps feedback for a US account", async () => { + regions.set("user", "us"); + const server = await BrowserStackMcpServer.create(configFor("user")); + expect(server.getTool("submitFeedback")).toBeDefined(); + }); + + it("declines feedback at call time when the server was built before the lookup", async () => { + regions.set("user", "eu"); + const server = new BrowserStackMcpServer(configFor("user")); + const result: any = await server.getTool("submitFeedback")!.handler( + { message: "the tool did not work", category: "tool_error" } as any, + {} as any, + ); + expect(result.content[0].text).toBe(FEEDBACK_UNAVAILABLE); + await settle(); + expect(rows().some((r: any) => "feedback_message" in r)).toBe(false); + }); +}); diff --git a/tests/lib/account-region.test.ts b/tests/lib/account-region.test.ts new file mode 100644 index 00000000..f2eb8ddb --- /dev/null +++ b/tests/lib/account-region.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.unmock("../../src/lib/account-region.js"); + +// A plain function rather than vi.fn: vitest fails a test whose cleared vi.fn returned a +// rejected promise, even when the code under test handles it. +let mintImpl: () => Promise = async () => ""; +const mintCalls: unknown[] = []; +vi.mock("../../src/lib/central-oauth.js", () => ({ + mintCentralToken: (options: unknown) => { + mintCalls.push(options); + return mintImpl(); + }, + fetchTokenTransport: () => "transport", +})); +// Hosts whose probe accepts the account; none unless a test says otherwise. +let probeAccepts = new Set(); +const probedUrls: string[] = []; +vi.mock("../../src/lib/apiClient.js", () => ({ + apiClient: { + get: async ({ url }: { url: string }) => { + probedUrls.push(url); + return { ok: [...probeAccepts].some((host) => url.startsWith(host)) }; + }, + }, +})); +vi.mock("../../src/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + accountRegion, + PROBED_TTL_MS, + regionFromToken, + REGION_TTL_MS, + RETRY_AFTER_MS, +} from "../../src/lib/account-region.js"; + +const settleLookups = () => new Promise((resolve) => setImmediate(resolve)); + +const tokenWith = (user: unknown) => + `h.${Buffer.from(JSON.stringify({ user })).toString("base64url")}.s`; + +// Lookups are kept per account, so each test uses an account of its own. +let accounts = 0; +const config = (username = `user-${++accounts}`) => ({ + "browserstack-username": username, + "browserstack-access-key": "key", +}); + +describe("regionFromToken", () => { + it.each(["eu", "in", "us"])("reads the %s region", (region) => { + expect( + regionFromToken(tokenWith({ grr: { region, to_be_ignored: {} } })), + ).toBe(region); + }); + + it.each([ + ["no grr claim", {}], + ["a null grr claim", { grr: null }], + ["an empty region", { grr: { region: "" } }], + ])("treats %s as a US account", (_, user) => { + expect(regionFromToken(tokenWith(user))).toBe("us"); + }); + + it("reads a region in any case", () => { + expect(regionFromToken(tokenWith({ grr: { region: "EU" } }))).toBe("eu"); + }); + + it("does not guess at a region it does not know", () => { + expect(regionFromToken(tokenWith({ grr: { region: "apac" } }))).toBe( + "unknown", + ); + }); +}); + +const failAuth = () => { + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; +}; + +describe("accountRegion", () => { + beforeEach(() => { + mintCalls.length = 0; + probedUrls.length = 0; + probeAccepts = new Set(); + }); + + it("looks up nothing without credentials", () => { + expect(accountRegion(undefined)).toBeUndefined(); + expect( + accountRegion({ + "browserstack-username": "", + "browserstack-access-key": "", + }), + ).toBeUndefined(); + expect(mintCalls).toHaveLength(0); + }); + + it("mints a user profile token with the config's credentials", async () => { + mintImpl = async () => tokenWith({ grr: { region: "eu" } }); + expect(await accountRegion(config("user"))).toBe("eu"); + expect(mintCalls).toEqual([ + expect.objectContaining({ + url: "https://auth.browserstack.com/oauth2/v2/token", + scope: "oauth_user_profile", + credentials: { username: "user", accessKey: "key" }, + cacheEnabled: false, + }), + ]); + }); + + it("looks up once per account and answers directly once known", async () => { + mintImpl = async () => tokenWith({ grr: { region: "in" } }); + const cfg = config(); + const first = accountRegion(cfg); + expect(first).toBeInstanceOf(Promise); + expect(await first).toBe("in"); + expect(accountRegion(cfg)).toBe("in"); + expect(mintCalls).toHaveLength(1); + }); + + it("shares one lookup between configs for the same account", async () => { + mintImpl = async () => tokenWith({ grr: { region: "eu" } }); + const [a, b] = [config("shared"), config("shared")]; + const [first, second] = await Promise.all([ + accountRegion(a), + accountRegion(b), + ]); + expect([first, second, accountRegion(config("shared"))]).toEqual([ + "eu", + "eu", + "eu", + ]); + expect(mintCalls).toHaveLength(1); + }); + + it("looks up each account on its own", async () => { + const tokens = [tokenWith({ grr: { region: "eu" } }), tokenWith({})]; + mintImpl = async () => tokens.shift()!; + expect(await accountRegion(config())).toBe("eu"); + expect(await accountRegion(config())).toBe("us"); + expect(mintCalls).toHaveLength(2); + }); + + it("keeps an unrecognised region as an answer", async () => { + mintImpl = async () => tokenWith({ grr: { region: "apac" } }); + const cfg = config(); + expect(await accountRegion(cfg)).toBe("unknown"); + expect(accountRegion(cfg)).toBe("unknown"); + expect(mintCalls).toHaveLength(1); + }); + + it("probes Test Management when auth can't answer, regional hosts first", async () => { + failAuth(); + probeAccepts = new Set(["https://test-management-eu.browserstack.com"]); + expect(await accountRegion(config())).toBe("eu"); + expect(probedUrls).toEqual([ + "https://test-management-eu.browserstack.com/api/v2/projects/", + ]); + }); + + it("finds a US account by probing", async () => { + failAuth(); + probeAccepts = new Set(["https://test-management.browserstack.com"]); + expect(await accountRegion(config())).toBe("us"); + expect(probedUrls).toHaveLength(3); + }); + + it("probes when auth names a region it does not know", async () => { + mintImpl = async () => tokenWith({ grr: { region: "apac" } }); + probeAccepts = new Set(["https://test-management-in.browserstack.com"]); + expect(await accountRegion(config())).toBe("in"); + }); + + it("does not probe when auth answers", async () => { + mintImpl = async () => tokenWith({}); + expect(await accountRegion(config())).toBe("us"); + expect(probedUrls).toHaveLength(0); + }); + + it("answers unknown when the lookup fails", async () => { + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; + expect(await accountRegion(config())).toBe("unknown"); + }); + + describe("after a failed lookup", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-29T10:00:00Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("answers unknown without looking up again until the retry delay", async () => { + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; + const cfg = config(); + expect(await accountRegion(cfg)).toBe("unknown"); + vi.advanceTimersByTime(RETRY_AFTER_MS - 1); + expect(accountRegion(cfg)).toBe("unknown"); + expect(mintCalls).toHaveLength(1); + }); + + it("asks auth again sooner for a region found by probing", async () => { + failAuth(); + probeAccepts = new Set(["https://test-management-eu.browserstack.com"]); + const cfg = config(); + expect(await accountRegion(cfg)).toBe("eu"); + vi.advanceTimersByTime(PROBED_TTL_MS - 1); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(2); + }); + + it("keeps the last known region when a refresh fails", async () => { + mintImpl = async () => tokenWith({ grr: { region: "eu" } }); + const cfg = config(); + expect(await accountRegion(cfg)).toBe("eu"); + + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; + vi.advanceTimersByTime(REGION_TTL_MS); + expect(accountRegion(cfg)).toBe("eu"); + await settleLookups(); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(2); + + mintImpl = async () => tokenWith({}); + vi.advanceTimersByTime(RETRY_AFTER_MS); + expect(accountRegion(cfg)).toBe("eu"); + await settleLookups(); + expect(accountRegion(cfg)).toBe("us"); + expect(mintCalls).toHaveLength(3); + }); + + it("looks up a known region again once it expires", async () => { + mintImpl = async () => tokenWith({ grr: { region: "eu" } }); + const cfg = config(); + expect(await accountRegion(cfg)).toBe("eu"); + vi.advanceTimersByTime(REGION_TTL_MS - 1); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(2); + }); + + it("looks up again after the retry delay and keeps the answer", async () => { + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; + const cfg = config(); + expect(await accountRegion(cfg)).toBe("unknown"); + + mintImpl = async () => tokenWith({ grr: { region: "eu" } }); + vi.advanceTimersByTime(RETRY_AFTER_MS); + expect(await accountRegion(cfg)).toBe("eu"); + expect(accountRegion(cfg)).toBe("eu"); + expect(mintCalls).toHaveLength(2); + }); + }); +}); diff --git a/tests/lib/instrumentation-grr-region.test.ts b/tests/lib/instrumentation-grr-region.test.ts new file mode 100644 index 00000000..a78782f9 --- /dev/null +++ b/tests/lib/instrumentation-grr-region.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { trackMCP, withToolCall } from "../../src/lib/instrumentation"; +import { apiClient } from "../../src/lib/apiClient"; +import { withAccountRegion } from "../../src/lib/region-context"; + +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { post: vi.fn().mockResolvedValue({ status: 200, data: {} }) }, +})); +vi.mock("../../src/logger", () => ({ + default: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); +vi.mock("../../src/config", () => ({ + default: { REMOTE_MCP: false }, +})); + +const clientInfo = { name: "claude-code", version: "1.2.3" }; +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +}; +const lastRow = () => + (apiClient.post as any).mock.calls.at(-1)[0].body.event_properties; + +const rowCount = () => (apiClient.post as any).mock.calls.length; + +describe("telemetry by the account's region", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(["eu", "in"])("sends nothing for a %s account", async (region) => { + withAccountRegion(region as any, () => + trackMCP("invokeCapability", clientInfo, new Error("boom"), config, { + capability: "listTestRuns", + }), + ); + await withAccountRegion(region as any, () => + withToolCall("listTestCases", () => clientInfo, config, async () => ({ + content: [{ type: "text", text: "ok" }], + })), + ); + expect(rowCount()).toBe(0); + }); + + it("drops free text but keeps usage fields when the lookup failed", () => { + withAccountRegion("unknown", () => + trackMCP("invokeCapability", clientInfo, new Error("boom"), config, { + capability: "listTestRuns", + upstream_status: 200, + change_summary: "close the checkout regression run", + search_query: "checkout runs", + feedback_message: "it failed", + feedback_category: "tool_error", + }), + ); + const row = lastRow(); + expect(row).toMatchObject({ + tool_name: "invokeCapability", + mcp_client: "claude-code", + success: false, + error_type: "Error", + capability: "listTestRuns", + upstream_status: 200, + }); + for (const field of [ + "error_message", + "change_summary", + "search_query", + "feedback_message", + "feedback_category", + ]) + expect(row).not.toHaveProperty(field); + }); + + it("drops the error text of a returned failure when the lookup failed", async () => { + await withAccountRegion("unknown", () => + withToolCall("listTestCases", () => clientInfo, config, async () => ({ + content: [{ type: "text", text: "Project PR-1 not found" }], + isError: true, + })), + ); + const row = lastRow(); + expect(row).toMatchObject({ outcome: "error_result", success: true }); + expect(row).not.toHaveProperty("error_message"); + }); + + it.each([undefined, "us"])( + "sends every field when the region is %s", + (region) => { + withAccountRegion(region as any, () => + trackMCP("searchCapability", clientInfo, new Error("boom"), config, { + search_query: "checkout runs", + }), + ); + expect(lastRow()).toMatchObject({ + error_message: "boom", + search_query: "checkout runs", + }); + }, + ); +}); diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts new file mode 100644 index 00000000..87428a9c --- /dev/null +++ b/tests/lib/regional-host.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from "vitest"; + +const PROD_AUTOMATION = "https://api-automation.browserstack.com"; + +// Re-imported per test so each case sees its own mocked config; every export is called +// inside a tool call for an account in `region`. +async function loadModule( + region: string | undefined, + automationBaseUrl: string | undefined = PROD_AUTOMATION, +) { + vi.resetModules(); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { BROWSERSTACK_AUTOMATION_BASE_URL: automationBaseUrl }, + })); + const mod = await import("../../src/lib/regional-host"); + const { withAccountRegion } = await import("../../src/lib/region-context"); + const inRegion = any>(fn: T) => + ((...args: Parameters) => + withAccountRegion(region as any, () => fn(...args))) as T; + return { + getRegionalHost: inRegion(mod.getRegionalHost), + sessionApiHost: inRegion(mod.sessionApiHost), + isRegionalHostname: inRegion(mod.isRegionalHostname), + }; +} + +describe("getRegionalHost", () => { + it.each([undefined, "us"])( + "keeps every host on its US default when the region is %s", + async (region) => { + const { getRegionalHost } = await loadModule(region); + expect(getRegionalHost("automateApi")).toBe( + "https://api.browserstack.com", + ); + expect(getRegionalHost("appAutomateCloud")).toBe( + "https://api-cloud.browserstack.com", + ); + expect(getRegionalHost("appAutomateHub")).toBe("hub.browserstack.com"); + expect(getRegionalHost("accessibilityApi")).toBe( + "https://api-accessibility.browserstack.com", + ); + expect(getRegionalHost("observabilityApi")).toBe( + "https://api-observability.browserstack.com", + ); + expect(getRegionalHost("automationApi")).toBe(PROD_AUTOMATION); + }, + ); + + it("refuses to pick a host when the region is unknown", async () => { + const { getRegionalHost, sessionApiHost } = await loadModule("unknown"); + expect(() => getRegionalHost("automateApi")).toThrow( + /Could not confirm your BrowserStack account's region/, + ); + expect(() => getRegionalHost("accessibilityApi")).toThrow(); + expect(() => sessionApiHost("automate")).toThrow(); + }); + + it("routes every product to its EU host when the region is eu", async () => { + const { getRegionalHost } = await loadModule("eu"); + expect(getRegionalHost("automateApi")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateApi")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateCloud")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateHub")).toBe( + "hub-eu-only.browserstack.com", + ); + expect(getRegionalHost("accessibilityApi")).toBe( + "https://api-accessibility-eu.browserstack.com", + ); + expect(getRegionalHost("accessibilityDashboard")).toBe( + "https://accessibility-eu.browserstack.com", + ); + expect(getRegionalHost("appAccessibilityApi")).toBe( + "https://api-app-accessibility-eu.browserstack.com", + ); + expect(getRegionalHost("appAccessibilityDashboard")).toBe( + "https://app-accessibility-eu.browserstack.com", + ); + expect(getRegionalHost("observabilityApi")).toBe( + "https://api-observability-eu.browserstack.com", + ); + expect(getRegionalHost("automationApi")).toBe( + "https://api-automation-eu.browserstack.com", + ); + }); + + it("keeps the default hosts for a region it does not route", async () => { + const { getRegionalHost } = await loadModule("in"); + expect(getRegionalHost("automateApi")).toBe("https://api.browserstack.com"); + expect(getRegionalHost("appAutomateHub")).toBe("hub.browserstack.com"); + expect(getRegionalHost("testManagement")).toBe( + "https://test-management.browserstack.com", + ); + }); + + it("uses an explicit BROWSERSTACK_AUTOMATION_BASE_URL in any region", async () => { + const staging = "https://api-automation-preprod.bsstag.com"; + const { getRegionalHost } = await loadModule("eu", staging); + expect(getRegionalHost("automationApi")).toBe(staging); + }); + + it.each([`${PROD_AUTOMATION}/`, " https://API-AUTOMATION.browserstack.com "])( + "treats %j as the default, not an override", + async (spelling) => { + const { getRegionalHost } = await loadModule("eu", spelling); + expect(getRegionalHost("automationApi")).toBe( + "https://api-automation-eu.browserstack.com", + ); + }, + ); + + it("drops a trailing slash from a real override", async () => { + const { getRegionalHost } = await loadModule( + "eu", + "https://api-automation-preprod.bsstag.com/", + ); + expect(getRegionalHost("automationApi")).toBe( + "https://api-automation-preprod.bsstag.com", + ); + }); + + it("falls back to the US api-automation host when the config omits it", async () => { + const { getRegionalHost } = await loadModule(undefined, undefined); + expect(getRegionalHost("automationApi")).toBe(PROD_AUTOMATION); + }); +}); + +describe("sessionApiHost", () => { + it("picks the session's product host in the account's region", async () => { + const { sessionApiHost } = await loadModule("eu"); + expect(sessionApiHost("automate")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(sessionApiHost("app-automate")).toBe( + "https://api-eu-only.browserstack.com", + ); + }); +}); + +describe("isRegionalHostname", () => { + it("accepts a link from any region, whatever the configured region", async () => { + const { isRegionalHostname } = await loadModule("eu"); + for (const host of [ + "app-accessibility.browserstack.com", + "app-accessibility-eu.browserstack.com", + ]) { + expect(isRegionalHostname("appAccessibilityDashboard", host)).toBe(true); + } + expect( + isRegionalHostname("appAccessibilityDashboard", "evil.example.com"), + ).toBe(false); + }); +}); diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index eb8c1a2e..88e354f9 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -107,3 +107,46 @@ describe("getTMBaseURL — failure details", () => { expect(err.message).toMatch(/HTTP 503/); }); }); + +describe("getTMBaseURL — account region", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function loadWithRegion(region: string | undefined) { + vi.resetModules(); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { REMOTE_MCP: false }, + })); + const apiClientMod = await import("../../src/lib/apiClient"); + const tmMod = await import("../../src/lib/tm-base-url"); + const { withAccountRegion } = await import("../../src/lib/region-context"); + return { + apiClient: apiClientMod.apiClient, + getTMBaseURL: (config: typeof mockConfig) => + withAccountRegion(region as any, () => tmMod.getTMBaseURL(config)), + }; + } + + it("an EU account uses its host directly, like every other product", async () => { + const { apiClient, getTMBaseURL } = await loadWithRegion("eu"); + await expect(getTMBaseURL(mockConfig)).resolves.toBe( + "https://test-management-eu.browserstack.com", + ); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it.each([undefined, "us", "in", "unknown"])("%s keeps the US → EU → IN probe", async (region) => { + const { apiClient, getTMBaseURL } = await loadWithRegion(region); + (apiClient.get as any) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }); + await expect(getTMBaseURL(mockConfig)).resolves.toBe( + "https://test-management-eu.browserstack.com", + ); + expect( + (apiClient.get as any).mock.calls.map((c: any[]) => new URL(c[0].url).host), + ).toEqual(["test-management.browserstack.com", "test-management-eu.browserstack.com"]); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index f95a206c..bdafb54c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -12,3 +12,9 @@ vi.mock("sharp", () => { sharp.concurrency = vi.fn(); return { default: sharp }; }); + +// No account region lookups against BrowserStack auth; region tests set the region +// with withAccountRegion, and tests/lib/account-region.test.ts unmocks the lookup. +vi.mock("../src/lib/account-region.js", () => ({ + accountRegion: () => undefined, +})); diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index 308b302d..6bd89a01 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -200,3 +200,121 @@ describe("multiple environments, the way Atlas defines them", () => { .rejects.toThrow(/environment 'preprod' has no host/); }); }); + +describe("products follow the account's region", () => { + const DECLARED = "https://api-observability.browserstack.com"; + + // resolveBaseUrl as called inside a tool call for an account in `region`. + async function resolveIn(region: string | undefined) { + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + const { withAccountRegion } = await import("../../src/lib/region-context.js"); + return (...args: Parameters) => + withAccountRegion(region as any, () => resolveBaseUrl(...args)); + } + + beforeEach(() => { + vi.resetModules(); + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TRA; + }); + + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TRA; + }); + + it("sends an EU account to the EU host, though the index declares the US one", async () => { + const resolveBaseUrl = await resolveIn("eu"); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe( + "https://api-observability-eu.browserstack.com", + ); + }); + + it.each([undefined, "us"])( + "keeps the US host when the region is %s", + async (region) => { + const resolveBaseUrl = await resolveIn(region); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe(DECLARED); + }, + ); + + it("refuses a regional product when the region is unknown", async () => { + const resolveBaseUrl = await resolveIn("unknown"); + await expect( + resolveBaseUrl("tra", CONFIG, { base_url: DECLARED }), + ).rejects.toThrow(/Could not confirm your BrowserStack account's region/); + }); + + it("still probes Test Management when the region is unknown", async () => { + const resolveBaseUrl = await resolveIn("unknown"); + expect(await resolveBaseUrl("tm", CONFIG)).toBe( + "https://test-management-eu.browserstack.com", + ); + }); + + it("sends app_accessibility to its EU host too", async () => { + const resolveBaseUrl = await resolveIn("eu"); + expect( + await resolveBaseUrl("app_accessibility", CONFIG, { + base_url: "https://api-app-accessibility.browserstack.com", + }), + ).toBe("https://api-app-accessibility-eu.browserstack.com"); + }); + + it("sends web_accessibility to its regional host", async () => { + const resolveBaseUrl = await resolveIn("eu"); + expect( + await resolveBaseUrl("web_accessibility", CONFIG, { + base_url: "https://api-accessibility.browserstack.com", + }), + ).toBe("https://api-accessibility-eu.browserstack.com"); + }); + + const TRA_SOURCE = { + base_urls: [ + DECLARED, + "https://api-observability-eu.browserstack.com", + "https://api-observability-in.browserstack.com", + ], + probe_path: "/api/v1/access", + }; + + it("does not probe the tra base_urls for an EU account", async () => { + const resolveBaseUrl = await resolveIn("eu"); + const transport = vi.fn(); + expect(await resolveBaseUrl("tra", CONFIG, TRA_SOURCE, transport as any)).toBe( + "https://api-observability-eu.browserstack.com", + ); + expect(transport).not.toHaveBeenCalled(); + }); + + it("probes the tra base_urls for an account in a region not routed here", async () => { + const resolveBaseUrl = await resolveIn("in"); + const transport = vi.fn(async () => ({ status: 401 })); + await resolveBaseUrl("tra", CONFIG, TRA_SOURCE, transport as any).catch(() => {}); + expect(transport).toHaveBeenCalledTimes(TRA_SOURCE.base_urls.length); + }); + + it.each([ + ["devicecontrol", "https://hub.browserstack.com", "https://api.browserstack.com", "https://hub-eu-only.browserstack.com", "https://api-eu-only.browserstack.com"], + ["mobiledevicecontrol", "https://hub-cloud.browserstack.com", "https://api-cloud.browserstack.com", "https://hub-eu-only.browserstack.com", "https://api-eu-only.browserstack.com"], + ])( + "routes %s and its api host for an EU account, and keeps the declared hosts otherwise", + async (product, hub, api, euHub, euApi) => { + const source = { base_url: hub, hosts: { api } }; + const inEu = await resolveIn("eu"); + expect(await inEu(product, CONFIG, source)).toBe(euHub); + expect(await inEu(product, CONFIG, source, undefined, "api")).toBe(euApi); + + const inUs = await resolveIn("us"); + expect(await inUs(product, CONFIG, source)).toBe(hub); + expect(await inUs(product, CONFIG, source, undefined, "api")).toBe(api); + }, + ); + + it("lets an explicit override win over the region", async () => { + process.env.CAPABILITY_REGISTRY_BASE_URL_TRA = "https://tra-preprod.example"; + const resolveBaseUrl = await resolveIn("eu"); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe( + "https://tra-preprod.example", + ); + }); +}); diff --git a/tests/tools/capabilityRegistryTelemetry.test.ts b/tests/tools/capabilityRegistryTelemetry.test.ts index b03deb67..edaaf988 100644 --- a/tests/tools/capabilityRegistryTelemetry.test.ts +++ b/tests/tools/capabilityRegistryTelemetry.test.ts @@ -245,6 +245,42 @@ describe("capability registry telemetry", () => { expect(last.search_query).toBe("find cases assigned to [email]"); }); + it("records an entity only when the product defines it", async () => { + const server = await buildServer(); + const search = (entity: string) => + call(server, "searchCapability", { + product: "tm", + product_choice: "user_confirmed", + query: "list", + entity, + }); + + await search("attachment"); + expect((await rowsFor("searchCapability")).at(-1).entity).toBe( + "attachment", + ); + await search("Acme release 4.2"); + expect((await rowsFor("searchCapability")).at(-1).entity).toBeUndefined(); + }); + + it("does not record an unknown product name", async () => { + const server = await buildServer(); + await call(server, "searchCapability", { + product: "Acme internal", + query: "list", + }); + await call(server, "describeEntity", { + product: "Acme internal", + entity: "x", + }); + + for (const tool of ["searchCapability", "describeEntity"]) { + const last = (await rowsFor(tool)).at(-1); + expect(last.refusal_reason).toBe("unknown_product"); + expect(last.product).toBeUndefined(); + } + }); + it("does not put registry fields on other tools' rows", async () => { const server = await buildServer(); await call(server, "listProducts", {}); diff --git a/tests/tools/feedback-region.test.ts b/tests/tools/feedback-region.test.ts new file mode 100644 index 00000000..156eeffb --- /dev/null +++ b/tests/tools/feedback-region.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +const CONFIG = { + "browserstack-username": "u", + "browserstack-access-key": "k", +} as any; + +async function load(region: string | undefined) { + vi.resetModules(); + vi.doMock("../../src/lib/account-region.js", () => ({ + accountRegion: () => region, + })); + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const { withFeedbackHint, FEEDBACK_HINT } = await import( + "../../src/lib/feedback-hint.js" + ); + const { withAccountRegion } = await import("../../src/lib/region-context.js"); + const server = await BrowserStackMcpServer.create(CONFIG); + const instructions: string = + (server.getInstance().server as any)._instructions ?? ""; + const hinted = (result: unknown) => + JSON.stringify( + withAccountRegion(region as any, () => withFeedbackHint("listBuildId", result)), + ).includes(FEEDBACK_HINT); + return { server, instructions, hinted }; +} + +const failure = () => ({ + content: [{ type: "text", text: "Failed: Request failed with status code 502" }], + isError: true, +}); + +describe("submitFeedback by the account's region", () => { + it.each(["eu", "in"])( + "is not registered, instructed or hinted when the region is %s", + async (region) => { + const { server, instructions, hinted } = await load(region); + expect(server.getTool("submitFeedback")).toBeUndefined(); + expect(instructions).not.toContain("submitFeedback"); + expect(hinted(failure())).toBe(false); + }, + ); + + it("stays listed but is not hinted when the region is unknown", async () => { + const { server, instructions, hinted } = await load("unknown"); + expect(server.getTool("submitFeedback")).toBeDefined(); + expect(instructions).toContain("submitFeedback"); + expect(hinted(failure())).toBe(false); + }); + + it.each([undefined, "us"])( + "is registered, instructed and hinted when the region is %s", + async (region) => { + const { server, instructions, hinted } = await load(region); + expect(server.getTool("submitFeedback")).toBeDefined(); + expect(instructions).toContain("submitFeedback"); + expect(hinted(failure())).toBe(true); + }, + ); +}); diff --git a/tests/tools/tfaRcaRegion.test.ts b/tests/tools/tfaRcaRegion.test.ts new file mode 100644 index 00000000..66ac48f5 --- /dev/null +++ b/tests/tools/tfaRcaRegion.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; + +async function baseUrl(region: string | undefined, o11yBaseUrl: string) { + vi.resetModules(); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { O11Y_TFA_RCA_BASE_URL: o11yBaseUrl }, + })); + const { getO11yBaseUrl } = await import( + "../../src/tools/tfa-rca-utils/constants" + ); + const { withAccountRegion } = await import("../../src/lib/region-context"); + return withAccountRegion(region as any, () => getO11yBaseUrl()); +} + +const DEFAULT = "https://api-automation.browserstack.com"; + +describe("getO11yBaseUrl", () => { + it.each([undefined, "us", "in"])( + "keeps the US host when the region is %s", + async (region) => { + expect(await baseUrl(region, DEFAULT)).toBe(DEFAULT); + }, + ); + + it("refuses to pick a host when the region is unknown", async () => { + await expect(baseUrl("unknown", DEFAULT)).rejects.toThrow( + /Could not confirm your BrowserStack account's region/, + ); + }); + + it("follows the eu region", async () => { + expect(await baseUrl("eu", DEFAULT)).toBe( + "https://api-automation-eu.browserstack.com", + ); + }); + + it("lets an explicit O11Y_TFA_RCA_BASE_URL win", async () => { + const staging = "https://api-observability-rengg-tfa.bsstag.com"; + expect(await baseUrl("eu", staging)).toBe(staging); + }); +});