From 41e3b6ca8f29d4201a1205f5513f03ad4ef1301b Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 16 Oct 2026 13:56:10 +0530 Subject: [PATCH 01/26] feat(lib): route Automate, App Automate, Accessibility and Observability to the account's GRR region Add src/lib/regional-host.ts, which probes each product's US, EU and IN hosts in order and keeps the first that accepts the caller's credentials, the same approach tm-base-url.ts uses for Test Management. A GRR account is rejected by every region except its own, so it lands on its regional host; a non-GRR account is served by the US host and never reaches the others. If every region refuses, the call fails rather than falling back to the US host. Hosts routed: - Automate / App Automate REST: api--only.browserstack.com - App Automate uploads and Espresso/XCUITest builds: api-cloud (US) or api--only - App Automate hub: hub--only.browserstack.com - Accessibility: api-accessibility-, accessibility- - Test Reporting & Analytics / RCA / build insights / self-heal: api-observability-, api-automation- The region is cached per product in stdio mode only; remote mode probes on every call so one user's region cannot reach another (rules/multi-tenant-safety.md). BROWSERSTACK_AUTOMATION_BASE_URL still overrides the api-automation host for non-prod targets. Co-Authored-By: Claude Opus 5.5 --- src/lib/api.ts | 4 +- src/lib/regional-host.ts | 173 ++++++++++++++++++ .../accessiblity-utils/accessibility-rag.ts | 3 +- src/tools/accessiblity-utils/auth-config.ts | 14 +- .../accessiblity-utils/report-fetcher.ts | 12 +- src/tools/accessiblity-utils/scanner.ts | 12 +- .../native-execution/appautomate.ts | 18 +- src/tools/appautomate.ts | 6 +- src/tools/automate-utils/fetch-screenshots.ts | 8 +- src/tools/automate-utils/list-session-ids.ts | 17 +- .../automate-utils/resolve-hashed-build-id.ts | 18 +- src/tools/build-insights.ts | 6 +- src/tools/failurelogs-utils/app-automate.ts | 7 +- src/tools/failurelogs-utils/automate.ts | 7 +- src/tools/failurelogs-utils/video.ts | 7 +- src/tools/rca-agent-utils/get-build-id.ts | 10 +- .../rca-agent-utils/get-failed-test-id.ts | 3 +- src/tools/rca-agent-utils/list-build-id.ts | 7 +- src/tools/rca-agent-utils/rca-data.ts | 4 +- src/tools/rca-agent.ts | 9 +- src/tools/selfheal-utils/fetch-test-code.ts | 5 +- src/tools/selfheal-utils/selfheal.ts | 12 +- tests/lib/regional-host.test.ts | 158 ++++++++++++++++ tests/setup.ts | 14 ++ tests/tools/rcaAgent.test.ts | 2 + 25 files changed, 485 insertions(+), 51 deletions(-) create mode 100644 src/lib/regional-host.ts create mode 100644 tests/lib/regional-host.test.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index 64051e77..15f757e2 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 = await getRegionalHost("observabilityApi", config); + const buildsUrl = `${host}/ext/v1/builds/latest?build_name=${encodeURIComponent( buildName, )}&project_name=${encodeURIComponent(projectName)}`; diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts new file mode 100644 index 00000000..1547e8a1 --- /dev/null +++ b/src/lib/regional-host.ts @@ -0,0 +1,173 @@ +import { apiClient } from "./apiClient.js"; +import logger from "../logger.js"; +import { BrowserStackConfig } from "./types.js"; +import { getBrowserStackAuth } from "./get-auth.js"; +import appConfig from "../config.js"; + +/** + * Geo Region Restriction (GRR) routing for the hand-written tools. + * + * A GRR group's data lives in one region, and that region's hosts are the only ones that + * serve it: the others reject its credentials. So, like `tm-base-url.ts`, we PROBE each + * product's regional hosts in order (US first) and keep the first that answers. A + * non-GRR account is served by the US host and never reaches the regional ones. + * + * The probe is per product, not per account, because GRR is enabled per product. + * + * There is no fallback: if every region refuses, the call fails rather than quietly + * sending a GRR account's data to the US host. + */ + +export type GrrRegion = "us" | "eu" | "in"; + +const REGIONS: readonly GrrRegion[] = ["us", "eu", "in"]; + +type Hosts = Record; + +const AUTOMATE_API: Hosts = { + us: "https://api.browserstack.com", + eu: "https://api-eu-only.browserstack.com", + in: "https://api-aps-only.browserstack.com", +}; + +/** + * Every host the tools call, by region. Automate/App Automate use the `api--only` + * domains from the internal GRR documentation; the rest were confirmed by DNS and by the + * regional host rejecting a US account. + */ +const HOSTS = { + automateApi: AUTOMATE_API, + appAutomateApi: AUTOMATE_API, + appAutomateUpload: { + us: "https://api-cloud.browserstack.com", + eu: AUTOMATE_API.eu, + in: AUTOMATE_API.in, + }, + appAutomateHub: { + us: "hub.browserstack.com", + eu: "hub-eu-only.browserstack.com", + in: "hub-aps-only.browserstack.com", + }, + accessibilityApi: { + us: "https://api-accessibility.browserstack.com", + eu: "https://api-accessibility-eu.browserstack.com", + in: "https://api-accessibility-in.browserstack.com", + }, + accessibilityDashboard: { + us: "https://accessibility.browserstack.com", + eu: "https://accessibility-eu.browserstack.com", + in: "https://accessibility-in.browserstack.com", + }, + observabilityApi: { + us: "https://api-observability.browserstack.com", + eu: "https://api-observability-eu.browserstack.com", + in: "https://api-observability-in.browserstack.com", + }, + automationApi: { + us: "https://api-automation.browserstack.com", + eu: "https://api-automation-eu.browserstack.com", + in: "https://api-automation-in.browserstack.com", + }, +} as const satisfies Record; + +export type RegionalHostKind = keyof typeof HOSTS; + +type RegionalProduct = + | "automate" + | "app-automate" + | "accessibility" + | "observability" + | "automation"; + +/** The product whose region decides each host, and the host + read used to probe it. */ +const PRODUCT_OF: Record = { + automateApi: "automate", + appAutomateApi: "app-automate", + appAutomateUpload: "app-automate", + appAutomateHub: "app-automate", + accessibilityApi: "accessibility", + accessibilityDashboard: "accessibility", + observabilityApi: "observability", + automationApi: "automation", +}; + +const PROBES: Record = { + automate: { hosts: AUTOMATE_API, path: "/automate/plan.json" }, + "app-automate": { hosts: AUTOMATE_API, path: "/app-automate/plan.json" }, + accessibility: { + hosts: HOSTS.accessibilityApi, + path: "/api/website-scanner/v1/scans", + }, + observability: { hosts: HOSTS.observabilityApi, path: "/ext/v1/projects" }, + automation: { hosts: HOSTS.automationApi, path: "/ext/v1/projects" }, +}; + +// Stdio mode only: one user per process, so a region found once holds for the session. +// Remote mode re-probes on every call — a process-wide cache there would serve the first +// user's region to everyone (rules/multi-tenant-safety.md). +const cachedRegion = new Map(); + +/** Exposed for tests. */ +export function clearRegionCache(): void { + cachedRegion.clear(); +} + +async function probeRegion( + product: RegionalProduct, + config: BrowserStackConfig, +): Promise { + if (!appConfig.REMOTE_MCP) { + const cached = cachedRegion.get(product); + if (cached) return cached; + } + + const { hosts, path } = PROBES[product]; + const authHeader = + "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); + const failures: string[] = []; + + for (const region of REGIONS) { + const url = `${hosts[region]}${path}`; + try { + const res = await apiClient.get({ + url, + headers: { Authorization: authHeader }, + raise_error: false, + }); + if (res.ok) { + if (!appConfig.REMOTE_MCP) cachedRegion.set(product, region); + logger.info(`Selected ${product} region: ${region}`); + return region; + } + failures.push(`${region}: HTTP ${res.status}`); + } catch (err) { + const code = (err as { code?: string })?.code ?? (err as Error)?.message; + failures.push(`${region}: ${code}`); + } + } + + throw new Error( + `Could not determine which BrowserStack region serves this account's ${product} data. ` + + `Check your credentials and network connection. Details: ${failures.join("; ")}`, + ); +} + +/** The host for `kind` in a given region. Pure; exposed for tests. */ +export function hostFor(kind: RegionalHostKind, region: GrrRegion): string { + return HOSTS[kind][region]; +} + +/** The host to use for `kind`, in the region that serves this account. */ +export async function getRegionalHost( + kind: RegionalHostKind, + config: BrowserStackConfig, +): Promise { + // An explicit non-prod target (BROWSERSTACK_AUTOMATION_BASE_URL) is used as given. + if ( + kind === "automationApi" && + appConfig.BROWSERSTACK_AUTOMATION_BASE_URL !== HOSTS.automationApi.us + ) { + return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL; + } + return hostFor(kind, await probeRegion(PRODUCT_OF[kind], config)); +} diff --git a/src/tools/accessiblity-utils/accessibility-rag.ts b/src/tools/accessiblity-utils/accessibility-rag.ts index 578320a2..e83814e7 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 = `${await getRegionalHost("accessibilityDashboard", config)}/api/tcg-proxy/search`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/accessiblity-utils/auth-config.ts b/src/tools/accessiblity-utils/auth-config.ts index 9ceaeaa9..19a069d3 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; @@ -71,6 +72,13 @@ export class AccessibilityAuthConfig { this.auth = auth; } + private async apiHost(): Promise { + return getRegionalHost("accessibilityApi", { + "browserstack-username": this.auth?.username ?? "", + "browserstack-access-key": this.auth?.password ?? "", + }); + } + private transformLocalUrl(url: string): string { try { const parsed = new URL(url); @@ -110,7 +118,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + url: `${await this.apiHost()}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -168,7 +176,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/auth_configs", + url: `${await this.apiHost()}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -207,7 +215,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: `${await this.apiHost()}/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 d81893da..f52385bd 100644 --- a/src/tools/accessiblity-utils/report-fetcher.ts +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -1,4 +1,5 @@ import { apiClient } from "../../lib/apiClient.js"; +import { getRegionalHost } from "../../lib/regional-host.js"; interface ReportInitResponse { success: true; @@ -19,9 +20,16 @@ export class AccessibilityReportFetcher { this.auth = auth; } + private async apiHost(): Promise { + return getRegionalHost("accessibilityApi", { + "browserstack-username": this.auth?.username ?? "", + "browserstack-access-key": this.auth?.password ?? "", + }); + } + async getReportLink(scanId: string, scanRunId: string): Promise { // Initiate CSV link generation - const initUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; + const initUrl = `${await this.apiHost()}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; let basicAuthHeader = undefined; if (this.auth) { @@ -42,7 +50,7 @@ export class AccessibilityReportFetcher { const taskId = initData.data.task_id; // Poll for the generated CSV link (task is async, may take a few seconds) - const reportUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( + const reportUrl = `${await this.apiHost()}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( taskId, )}`; const maxAttempts = 3; diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 953fdfec..00dde578 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; @@ -27,6 +28,13 @@ export class AccessibilityScanner { this.auth = auth; } + private async apiHost(): Promise { + return getRegionalHost("accessibilityApi", { + "browserstack-username": this.auth?.username ?? "", + "browserstack-access-key": this.auth?.password ?? "", + }); + } + async startScan( name: string, urlList: string[], @@ -103,7 +111,7 @@ export class AccessibilityScanner { try { const response = await apiClient.post({ - url: "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + url: `${await this.apiHost()}/api/website-scanner/v1/scans`, headers: { Authorization: "Basic " + @@ -142,7 +150,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: `${await this.apiHost()}/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 8fe4e3f0..d0ed7aee 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,10 @@ 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: `${await getRegionalHost("appAutomateUpload", { + "browserstack-username": username, + "browserstack-access-key": password, + })}/app-automate/upload`, headers: { ...formData.getHeaders(), Authorization: @@ -218,7 +222,7 @@ export async function uploadEspressoApp( ): Promise { return uploadFileToBrowserStack( appPath, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/app", + `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/app`, "app_url", config, { allowedExtensions: [".apk", ".aab"] }, @@ -232,7 +236,7 @@ export async function uploadEspressoTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite", + `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".apk"] }, @@ -246,7 +250,7 @@ export async function uploadXcuiApp( ): Promise { return uploadFileToBrowserStack( appPath, - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/app", + `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/app`, "app_url", config, { allowedExtensions: [".ipa"] }, @@ -260,7 +264,7 @@ export async function uploadXcuiTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/test-suite", + `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".zip"] }, @@ -279,7 +283,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: `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/build`, headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -315,7 +319,7 @@ export async function triggerXcuiBuild( }; const response = await apiClient.post({ - url: "https://api-cloud.browserstack.com/app-automate/xcuitest/v2/build", + url: `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/build`, headers: { Authorization: "Basic " + diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index 2cc7e4ea..e7e78208 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,10 @@ async function takeAppScreenshot(args: { try { driver = await remote({ protocol: "https", - hostname: "hub.browserstack.com", + hostname: await getRegionalHost("appAutomateHub", { + "browserstack-username": username, + "browserstack-access-key": password, + }), 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 a570b6f2..9af2023c 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 { getRegionalHost } from "../../lib/regional-host.js"; async function extractScreenshotUrls( sessionId: string, @@ -13,7 +14,12 @@ 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 = await getRegionalHost( + isAutomate ? "automateApi" : "appAutomateApi", + config, + ); + 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..0694c8cd 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,14 @@ interface SessionListItem { export function sessionsListUrl( sessionType: SessionType, buildId: string, + host?: string, ): string { const encodedBuildId = encodeURIComponent(buildId); switch (sessionType) { case SessionType.Automate: - return `https://api.browserstack.com/automate/builds/${encodedBuildId}/sessions.json`; + return `${host ?? "https://api.browserstack.com"}/automate/builds/${encodedBuildId}/sessions.json`; case SessionType.AppAutomate: - return `https://api-cloud.browserstack.com/app-automate/builds/${encodedBuildId}/sessions.json`; + return `${host ?? "https://api-cloud.browserstack.com"}/app-automate/builds/${encodedBuildId}/sessions.json`; default: { const _exhaustive: never = sessionType; throw new Error(`Unsupported session type: ${_exhaustive}`); @@ -122,7 +124,16 @@ export async function listSessionIds( } const response = await apiClient.get({ - url: sessionsListUrl(args.sessionType, buildId), + url: sessionsListUrl( + args.sessionType, + buildId, + await getRegionalHost( + args.sessionType === SessionType.Automate + ? "automateApi" + : "appAutomateUpload", + config, + ), + ), headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts index 578c93d0..8930e847 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 } 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 = "https://api.browserstack.com", ): 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}`); @@ -58,7 +59,14 @@ export async function resolveBuildIdFromSession( const auth = Buffer.from(authString).toString("base64"); const response = await apiClient.get({ - url: sessionDetailsUrl(sessionType, sessionId), + url: sessionDetailsUrl( + sessionType, + sessionId, + await getRegionalHost( + sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi", + config, + ), + ), headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, @@ -91,7 +99,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 = `${await getRegionalHost("automationApi", config)}/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..82d23b91 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 = await getRegionalHost("automationApi", config); + 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/failurelogs-utils/app-automate.ts b/src/tools/failurelogs-utils/app-automate.ts index e228204e..c56675fc 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 = `${await getRegionalHost("appAutomateApi", config)}/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 = `${await getRegionalHost("appAutomateApi", config)}/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 = `${await getRegionalHost("appAutomateApi", config)}/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 99026976..096f579b 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 = `${await getRegionalHost("automateApi", config)}/automate/sessions/${sessionId}/networklogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -71,7 +72,7 @@ export async function retrieveSessionFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/automate/sessions/${sessionId}/logs`; + const url = `${await getRegionalHost("automateApi", config)}/automate/sessions/${sessionId}/logs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -103,7 +104,7 @@ export async function retrieveConsoleFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `https://api.browserstack.com/automate/sessions/${sessionId}/consolelogs`; + const url = `${await getRegionalHost("automateApi", config)}/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..7813d361 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 { getRegionalHost } from "../../lib/regional-host.js"; export async function retrieveSessionVideo( sessionId: string, @@ -11,7 +12,11 @@ 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 = await getRegionalHost( + sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi", + config, + ); + const url = `${host}/${product}/sessions/${encodeURIComponent(sessionId)}.json`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts index 6f3dffe7..5db73f3f 100644 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -1,12 +1,16 @@ +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 = await getRegionalHost("automationApi", { + "browserstack-username": username, + "browserstack-access-key": accessKey, + }); + 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..7c180dfb 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,12 @@ export async function listBuildId( const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); + const host = await getRegionalHost("automationApi", { + "browserstack-username": username, + "browserstack-access-key": accessKey, + }); 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..0d757a9f 100644 --- a/src/tools/rca-agent-utils/rca-data.ts +++ b/src/tools/rca-agent-utils/rca-data.ts @@ -243,13 +243,13 @@ export async function getRCAData( testIds: number[], authString: string, context?: ScanProgressContext, + host = "https://api-observability.browserstack.com", ): 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/rca-agent.ts b/src/tools/rca-agent.ts index 7a7dc922..b19f8e57 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -18,6 +18,7 @@ import { GET_BUILD_ID_PARAMS, LIST_TEST_IDS_PARAMS, } from "./rca-agent-utils/constants.js"; +import { getRegionalHost } from "../lib/regional-host.js"; // Tool function to fetch build ID export async function getBuildIdTool( @@ -114,7 +115,12 @@ export async function fetchRCADataTool( // Limit to first 3 test IDs for performance const testIds = args.testId; - const rcaData = await getRCAData(testIds, authString); + const rcaData = await getRCAData( + testIds, + authString, + undefined, + await getRegionalHost("observabilityApi", config), + ); const formattedData = formatRCAData(rcaData); @@ -160,6 +166,7 @@ export async function listTestIdsTool( authString, status, includeFailureDetail, + await getRegionalHost("automationApi", config), ); return { diff --git a/src/tools/selfheal-utils/fetch-test-code.ts b/src/tools/selfheal-utils/fetch-test-code.ts index 6d51638f..960c676e 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 = `${await getRegionalHost("automationApi", config)}/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..59ffc8e9 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 } 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: `${await getRegionalHost("automationApi", config)}/ext/v1/builds/${encodeURIComponent(buildUuid)}/selfHealingReport`, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, @@ -135,7 +133,11 @@ 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 = await getRegionalHost( + sessionType === "app-automate" ? "appAutomateApi" : "automateApi", + config, + ); + const url = `${host}/${productPath}/sessions/${sessionId}/logs`; const response = await apiClient.get({ url, diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts new file mode 100644 index 00000000..12db68ff --- /dev/null +++ b/tests/lib/regional-host.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// tests/setup.ts stubs the resolver for every other suite; this one tests the real thing. +vi.unmock("../../src/lib/regional-host"); + +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { + get: vi.fn(), + }, +})); + +vi.mock("../../src/logger", () => ({ + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +const PROD_AUTOMATION = "https://api-automation.browserstack.com"; + +// Re-imported per test so the stdio-mode region cache and the mocked config stay isolated. +async function loadModule( + remoteMcp: boolean, + automationBaseUrl = PROD_AUTOMATION, +) { + vi.resetModules(); + vi.doUnmock("../../src/lib/regional-host"); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { + REMOTE_MCP: remoteMcp, + BROWSERSTACK_AUTOMATION_BASE_URL: automationBaseUrl, + }, + })); + const apiClientMod = await import("../../src/lib/apiClient"); + const mod = await import("../../src/lib/regional-host"); + return { get: apiClientMod.apiClient.get as any, ...mod }; +} + +const config = { + "browserstack-username": "u", + "browserstack-access-key": "k", +}; + +const probedUrls = (get: any) => get.mock.calls.map((c: any[]) => c[0].url); + +describe("getRegionalHost", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("serves a non-GRR account from the US host after one probe", async () => { + const { get, getRegionalHost } = await loadModule(false); + get.mockResolvedValueOnce({ ok: true }); + + await expect(getRegionalHost("automateApi", config)).resolves.toBe( + "https://api.browserstack.com", + ); + expect(probedUrls(get)).toEqual([ + "https://api.browserstack.com/automate/plan.json", + ]); + }); + + it("routes an EU GRR account to the EU host when the US host refuses it", async () => { + const { get, getRegionalHost } = await loadModule(false); + get + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }); + + await expect(getRegionalHost("appAutomateHub", config)).resolves.toBe( + "hub-eu-only.browserstack.com", + ); + expect(probedUrls(get)).toEqual([ + "https://api.browserstack.com/app-automate/plan.json", + "https://api-eu-only.browserstack.com/app-automate/plan.json", + ]); + }); + + it("probes each product with its own read endpoint", async () => { + const { get, getRegionalHost } = await loadModule(false); + get.mockResolvedValue({ ok: true }); + + await getRegionalHost("accessibilityApi", config); + await getRegionalHost("observabilityApi", config); + await getRegionalHost("automationApi", config); + + expect(probedUrls(get)).toEqual([ + "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", + "https://api-observability.browserstack.com/ext/v1/projects", + "https://api-automation.browserstack.com/ext/v1/projects", + ]); + }); + + it("keeps trying the next region after a network error", async () => { + const { get, getRegionalHost } = await loadModule(false); + get + .mockRejectedValueOnce(Object.assign(new Error("boom"), { code: "ECONNRESET" })) + .mockResolvedValueOnce({ ok: true }); + + await expect(getRegionalHost("observabilityApi", config)).resolves.toBe( + "https://api-observability-eu.browserstack.com", + ); + }); + + it("fails closed instead of falling back to the US host when every region refuses", async () => { + const { get, getRegionalHost } = await loadModule(false); + get.mockResolvedValue({ ok: false, status: 403 }); + + await expect(getRegionalHost("accessibilityApi", config)).rejects.toThrow( + /accessibility.*us: HTTP 403; eu: HTTP 403; in: HTTP 403/, + ); + expect(get).toHaveBeenCalledTimes(3); + }); + + it("stdio mode caches the region per product, so hosts of one product share a probe", async () => { + const { get, getRegionalHost } = await loadModule(false); + get + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }); + + await getRegionalHost("appAutomateApi", config); + await expect(getRegionalHost("appAutomateUpload", config)).resolves.toBe( + "https://api-eu-only.browserstack.com", + ); + expect(get).toHaveBeenCalledTimes(2); + }); + + it("remote mode never caches, so one user's region cannot reach another", async () => { + const { get, getRegionalHost } = await loadModule(true); + get.mockResolvedValue({ ok: true }); + + await getRegionalHost("automateApi", config); + await getRegionalHost("automateApi", config); + expect(get).toHaveBeenCalledTimes(2); + }); + + it("uses an explicit BROWSERSTACK_AUTOMATION_BASE_URL without probing", async () => { + const staging = "https://api-automation-preprod.bsstag.com"; + const { get, getRegionalHost } = await loadModule(false, staging); + + await expect(getRegionalHost("automationApi", config)).resolves.toBe( + staging, + ); + expect(get).not.toHaveBeenCalled(); + }); +}); + +describe("hostFor", () => { + it("keeps App Automate uploads on api-cloud in the US and on api--only elsewhere", async () => { + const { hostFor } = await loadModule(false); + expect(hostFor("appAutomateUpload", "us")).toBe( + "https://api-cloud.browserstack.com", + ); + expect(hostFor("appAutomateUpload", "eu")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(hostFor("appAutomateUpload", "in")).toBe( + "https://api-aps-only.browserstack.com", + ); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index e634ea58..5d8e6a4c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -11,3 +11,17 @@ vi.mock("sharp", () => { sharp.concurrency = vi.fn(); return { default: sharp }; }); + +// Region routing probes BrowserStack before each call; most suites mock apiClient with +// ordered responses, so resolve every host to its US default here. The resolver's own +// behaviour is covered by tests/lib/regional-host.test.ts, which unmocks it. +vi.mock("../src/lib/regional-host", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getRegionalHost: vi.fn(async (kind: Parameters[0]) => + actual.hostFor(kind, "us"), + ), + }; +}); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index 9d00fdbf..55b82f96 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -106,6 +106,7 @@ describe("RCA Agent Tools", () => { "fake-user:fake-key", TestStatus.FAILED, undefined, + "https://api-automation.browserstack.com", ); }); @@ -128,6 +129,7 @@ describe("RCA Agent Tools", () => { "fake-user:fake-key", TestStatus.FAILED, true, + "https://api-automation.browserstack.com", ); const parsed = JSON.parse(result.content[0].text); expect(parsed[0].failure.category).toBe("Assertion"); From 94dc2317670729b3d025184c410f1b098bb01f11 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 16 Oct 2026 15:11:12 +0530 Subject: [PATCH 02/26] fix(lib): fall back to the US host when no region accepts the account When every regional probe fails (bad credentials, no access to the product, a network failure), use the US host instead of throwing, so the tool behaves as it did before region routing and the product's own error reaches the user. The fallback is logged and not cached, so the next call probes again. Co-Authored-By: Claude Opus 5.5 --- src/lib/regional-host.ts | 13 ++++++++----- tests/lib/regional-host.test.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 1547e8a1..10d2731a 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -14,8 +14,10 @@ import appConfig from "../config.js"; * * The probe is per product, not per account, because GRR is enabled per product. * - * There is no fallback: if every region refuses, the call fails rather than quietly - * sending a GRR account's data to the US host. + * If every region refuses (bad credentials, no access to the product, a network failure), + * we fall back to the US host, which is what the tools called before this existed, so the + * product's own error reaches the user. The fallback is not cached, so the next call + * probes again. */ export type GrrRegion = "us" | "eu" | "in"; @@ -146,10 +148,11 @@ async function probeRegion( } } - throw new Error( - `Could not determine which BrowserStack region serves this account's ${product} data. ` + - `Check your credentials and network connection. Details: ${failures.join("; ")}`, + logger.warn( + `No region accepted this account for ${product}; falling back to the US host. ` + + `Details: ${failures.join("; ")}`, ); + return "us"; } /** The host for `kind` in a given region. Pure; exposed for tests. */ diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 12db68ff..2f365150 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -10,7 +10,7 @@ vi.mock("../../src/lib/apiClient", () => ({ })); vi.mock("../../src/logger", () => ({ - default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, })); const PROD_AUTOMATION = "https://api-automation.browserstack.com"; @@ -99,14 +99,18 @@ describe("getRegionalHost", () => { ); }); - it("fails closed instead of falling back to the US host when every region refuses", async () => { + it("falls back to the US host, uncached, when every region refuses", async () => { const { get, getRegionalHost } = await loadModule(false); get.mockResolvedValue({ ok: false, status: 403 }); - await expect(getRegionalHost("accessibilityApi", config)).rejects.toThrow( - /accessibility.*us: HTTP 403; eu: HTTP 403; in: HTTP 403/, + await expect(getRegionalHost("accessibilityApi", config)).resolves.toBe( + "https://api-accessibility.browserstack.com", ); expect(get).toHaveBeenCalledTimes(3); + + // Not cached: the next call probes again instead of pinning the fallback. + await getRegionalHost("accessibilityApi", config); + expect(get).toHaveBeenCalledTimes(6); }); it("stdio mode caches the region per product, so hosts of one product share a probe", async () => { From 5df4624295d1d986007d306d85a3ae4b32e7e1b2 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 16 Oct 2026 15:26:11 +0530 Subject: [PATCH 03/26] feat(lib): choose the GRR region with BROWSERSTACK_GRR_REGION instead of probing Replace the per-product host probe with a startup flag. BROWSERSTACK_GRR_REGION is read once in config.ts: - unset or "us": every host stays on its US default, as before; Test Management keeps its existing probe - "eu" / "in": Automate, App Automate (REST, uploads, builds, hub), Accessibility and Test Reporting/RCA/build insights/self-heal use that region's hosts, and Test Management goes straight to test-management- without probing - any other value fails at startup, so a typo cannot quietly route a GRR account to the US No extra requests are made in any region, and getRegionalHost is now a synchronous, config-free lookup, so remote mode needs no cache. The flag is documented in llms-install.md. Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 21 ++ src/lib/api.ts | 2 +- src/lib/regional-host.ts | 119 +--------- src/lib/tm-base-url.ts | 7 + .../accessiblity-utils/accessibility-rag.ts | 2 +- src/tools/accessiblity-utils/auth-config.ts | 13 +- .../accessiblity-utils/report-fetcher.ts | 11 +- src/tools/accessiblity-utils/scanner.ts | 11 +- .../native-execution/appautomate.ts | 17 +- src/tools/appautomate.ts | 5 +- src/tools/automate-utils/fetch-screenshots.ts | 5 +- src/tools/automate-utils/list-session-ids.ts | 3 +- .../automate-utils/resolve-hashed-build-id.ts | 5 +- src/tools/build-insights.ts | 2 +- src/tools/failurelogs-utils/app-automate.ts | 6 +- src/tools/failurelogs-utils/automate.ts | 6 +- src/tools/failurelogs-utils/video.ts | 3 +- src/tools/rca-agent-utils/get-build-id.ts | 5 +- src/tools/rca-agent-utils/list-build-id.ts | 5 +- src/tools/rca-agent.ts | 4 +- src/tools/selfheal-utils/fetch-test-code.ts | 2 +- src/tools/selfheal-utils/selfheal.ts | 5 +- tests/lib/regional-host.test.ts | 212 +++++++----------- tests/lib/tm-base-url.test.ts | 35 +++ tests/setup.ts | 14 -- 25 files changed, 197 insertions(+), 323 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7d7c51b3..b9c8a05b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,25 @@ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com"; +/** + * BROWSERSTACK_GRR_REGION: the Geo Region Restriction region of the account this server + * serves — "us" (default), "eu" or "in". Unset or "us" keeps the US hosts. Any other + * value fails at startup: a typo must not quietly send a GRR account's data to the US. + */ +const GRR_REGIONS = ["us", "eu", "in"] as const; +export type GrrRegion = (typeof GRR_REGIONS)[number]; + +function parseGrrRegion(raw: string | undefined): GrrRegion { + const value = (raw || "").trim().toLowerCase(); + if (!value) return "us"; + if ((GRR_REGIONS as readonly string[]).includes(value)) { + return value as GrrRegion; + } + throw new Error( + `Invalid BROWSERSTACK_GRR_REGION "${raw}". Use one of: ${GRR_REGIONS.join(", ")}.`, + ); +} + /** * USE_OWN_LOCAL_BINARY_PROCESS: * If true, the system will not start a new local binary process, but will use the user's own process. @@ -77,6 +96,7 @@ export class Config { * "deferred" | "elicit". Anything else is ignored. */ public readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined, + public readonly BROWSERSTACK_GRR_REGION: GrrRegion = "us", ) {} } @@ -110,6 +130,7 @@ const config = new Config( : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() || undefined, + parseGrrRegion(process.env.BROWSERSTACK_GRR_REGION), ); export default config; diff --git a/src/lib/api.ts b/src/lib/api.ts index 15f757e2..134d8857 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -8,7 +8,7 @@ export async function getLatestO11YBuildInfo( projectName: string, config: BrowserStackConfig, ) { - const host = await getRegionalHost("observabilityApi", config); + const host = getRegionalHost("observabilityApi"); const buildsUrl = `${host}/ext/v1/builds/latest?build_name=${encodeURIComponent( buildName, )}&project_name=${encodeURIComponent(projectName)}`; diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 10d2731a..356bd7c4 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -1,29 +1,15 @@ -import { apiClient } from "./apiClient.js"; -import logger from "../logger.js"; -import { BrowserStackConfig } from "./types.js"; -import { getBrowserStackAuth } from "./get-auth.js"; -import appConfig from "../config.js"; +import appConfig, { GrrRegion } from "../config.js"; /** * Geo Region Restriction (GRR) routing for the hand-written tools. * - * A GRR group's data lives in one region, and that region's hosts are the only ones that - * serve it: the others reject its credentials. So, like `tm-base-url.ts`, we PROBE each - * product's regional hosts in order (US first) and keep the first that answers. A - * non-GRR account is served by the US host and never reaches the regional ones. + * A GRR account's data lives behind its region's hosts, and the other regions reject it. + * The region is set per deployment with BROWSERSTACK_GRR_REGION ("us" by default, "eu", + * "in"); unset or "us" keeps every host exactly as it was before this module existed. * - * The probe is per product, not per account, because GRR is enabled per product. - * - * If every region refuses (bad credentials, no access to the product, a network failure), - * we fall back to the US host, which is what the tools called before this existed, so the - * product's own error reaches the user. The fallback is not cached, so the next call - * probes again. + * Test Management is routed separately in `tm-base-url.ts`, which honours the same flag. */ -export type GrrRegion = "us" | "eu" | "in"; - -const REGIONS: readonly GrrRegion[] = ["us", "eu", "in"]; - type Hosts = Record; const AUTOMATE_API: Hosts = { @@ -34,8 +20,8 @@ const AUTOMATE_API: Hosts = { /** * Every host the tools call, by region. Automate/App Automate use the `api--only` - * domains from the internal GRR documentation; the rest were confirmed by DNS and by the - * regional host rejecting a US account. + * and `hub--only` domains from the internal GRR documentation; the rest were + * confirmed by DNS and by the regional host rejecting a US account. */ const HOSTS = { automateApi: AUTOMATE_API, @@ -74,103 +60,20 @@ const HOSTS = { export type RegionalHostKind = keyof typeof HOSTS; -type RegionalProduct = - | "automate" - | "app-automate" - | "accessibility" - | "observability" - | "automation"; - -/** The product whose region decides each host, and the host + read used to probe it. */ -const PRODUCT_OF: Record = { - automateApi: "automate", - appAutomateApi: "app-automate", - appAutomateUpload: "app-automate", - appAutomateHub: "app-automate", - accessibilityApi: "accessibility", - accessibilityDashboard: "accessibility", - observabilityApi: "observability", - automationApi: "automation", -}; - -const PROBES: Record = { - automate: { hosts: AUTOMATE_API, path: "/automate/plan.json" }, - "app-automate": { hosts: AUTOMATE_API, path: "/app-automate/plan.json" }, - accessibility: { - hosts: HOSTS.accessibilityApi, - path: "/api/website-scanner/v1/scans", - }, - observability: { hosts: HOSTS.observabilityApi, path: "/ext/v1/projects" }, - automation: { hosts: HOSTS.automationApi, path: "/ext/v1/projects" }, -}; - -// Stdio mode only: one user per process, so a region found once holds for the session. -// Remote mode re-probes on every call — a process-wide cache there would serve the first -// user's region to everyone (rules/multi-tenant-safety.md). -const cachedRegion = new Map(); - -/** Exposed for tests. */ -export function clearRegionCache(): void { - cachedRegion.clear(); -} - -async function probeRegion( - product: RegionalProduct, - config: BrowserStackConfig, -): Promise { - if (!appConfig.REMOTE_MCP) { - const cached = cachedRegion.get(product); - if (cached) return cached; - } - - const { hosts, path } = PROBES[product]; - const authHeader = - "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); - const failures: string[] = []; - - for (const region of REGIONS) { - const url = `${hosts[region]}${path}`; - try { - const res = await apiClient.get({ - url, - headers: { Authorization: authHeader }, - raise_error: false, - }); - if (res.ok) { - if (!appConfig.REMOTE_MCP) cachedRegion.set(product, region); - logger.info(`Selected ${product} region: ${region}`); - return region; - } - failures.push(`${region}: HTTP ${res.status}`); - } catch (err) { - const code = (err as { code?: string })?.code ?? (err as Error)?.message; - failures.push(`${region}: ${code}`); - } - } - - logger.warn( - `No region accepted this account for ${product}; falling back to the US host. ` + - `Details: ${failures.join("; ")}`, - ); - return "us"; -} - /** The host for `kind` in a given region. Pure; exposed for tests. */ export function hostFor(kind: RegionalHostKind, region: GrrRegion): string { return HOSTS[kind][region]; } -/** The host to use for `kind`, in the region that serves this account. */ -export async function getRegionalHost( - kind: RegionalHostKind, - config: BrowserStackConfig, -): Promise { +/** The host for `kind` in this deployment's GRR region. */ +export function getRegionalHost(kind: RegionalHostKind): string { // An explicit non-prod target (BROWSERSTACK_AUTOMATION_BASE_URL) is used as given. if ( kind === "automationApi" && + appConfig.BROWSERSTACK_AUTOMATION_BASE_URL && appConfig.BROWSERSTACK_AUTOMATION_BASE_URL !== HOSTS.automationApi.us ) { return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL; } - return hostFor(kind, await probeRegion(PRODUCT_OF[kind], config)); + return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us"); } diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index e92165b9..1c5a296b 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -15,6 +15,13 @@ let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { + // A GRR deployment names its region (BROWSERSTACK_GRR_REGION); use that host directly. + // Unset or "us" keeps the probe below, which also finds EU/IN accounts on its own. + const grrRegion = appConfig.BROWSERSTACK_GRR_REGION; + if (grrRegion === "eu" || grrRegion === "in") { + return `https://test-management-${grrRegion}.browserstack.com`; + } + // 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/tools/accessiblity-utils/accessibility-rag.ts b/src/tools/accessiblity-utils/accessibility-rag.ts index e83814e7..8c999f3a 100644 --- a/src/tools/accessiblity-utils/accessibility-rag.ts +++ b/src/tools/accessiblity-utils/accessibility-rag.ts @@ -21,7 +21,7 @@ export async function queryAccessibilityRAG( userQuery: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("accessibilityDashboard", config)}/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/auth-config.ts b/src/tools/accessiblity-utils/auth-config.ts index 19a069d3..d0293884 100644 --- a/src/tools/accessiblity-utils/auth-config.ts +++ b/src/tools/accessiblity-utils/auth-config.ts @@ -72,13 +72,6 @@ export class AccessibilityAuthConfig { this.auth = auth; } - private async apiHost(): Promise { - return getRegionalHost("accessibilityApi", { - "browserstack-username": this.auth?.username ?? "", - "browserstack-access-key": this.auth?.password ?? "", - }); - } - private transformLocalUrl(url: string): string { try { const parsed = new URL(url); @@ -118,7 +111,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: `${await this.apiHost()}/api/website-scanner/v1/auth_configs`, + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -176,7 +169,7 @@ export class AccessibilityAuthConfig { try { const response = await apiClient.post({ - url: `${await this.apiHost()}/api/website-scanner/v1/auth_configs`, + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/auth_configs`, headers: { Authorization: "Basic " + @@ -215,7 +208,7 @@ export class AccessibilityAuthConfig { try { // No GET-by-id route exists; list and select. const response = await apiClient.get({ - url: `${await this.apiHost()}/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 f52385bd..3dbbc984 100644 --- a/src/tools/accessiblity-utils/report-fetcher.ts +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -20,16 +20,9 @@ export class AccessibilityReportFetcher { this.auth = auth; } - private async apiHost(): Promise { - return getRegionalHost("accessibilityApi", { - "browserstack-username": this.auth?.username ?? "", - "browserstack-access-key": this.auth?.password ?? "", - }); - } - async getReportLink(scanId: string, scanRunId: string): Promise { // Initiate CSV link generation - const initUrl = `${await this.apiHost()}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; + const initUrl = `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; let basicAuthHeader = undefined; if (this.auth) { @@ -50,7 +43,7 @@ export class AccessibilityReportFetcher { const taskId = initData.data.task_id; // Poll for the generated CSV link (task is async, may take a few seconds) - const reportUrl = `${await this.apiHost()}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( + const reportUrl = `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( taskId, )}`; const maxAttempts = 3; diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 00dde578..825d579e 100644 --- a/src/tools/accessiblity-utils/scanner.ts +++ b/src/tools/accessiblity-utils/scanner.ts @@ -28,13 +28,6 @@ export class AccessibilityScanner { this.auth = auth; } - private async apiHost(): Promise { - return getRegionalHost("accessibilityApi", { - "browserstack-username": this.auth?.username ?? "", - "browserstack-access-key": this.auth?.password ?? "", - }); - } - async startScan( name: string, urlList: string[], @@ -111,7 +104,7 @@ export class AccessibilityScanner { try { const response = await apiClient.post({ - url: `${await this.apiHost()}/api/website-scanner/v1/scans`, + url: `${getRegionalHost("accessibilityApi")}/api/website-scanner/v1/scans`, headers: { Authorization: "Basic " + @@ -150,7 +143,7 @@ export class AccessibilityScanner { ): Promise { try { const response = await apiClient.get({ - url: `${await this.apiHost()}/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 d0ed7aee..1bd319c0 100644 --- a/src/tools/appautomate-utils/native-execution/appautomate.ts +++ b/src/tools/appautomate-utils/native-execution/appautomate.ts @@ -157,10 +157,7 @@ export async function uploadApp( formData.append("file", fs.createReadStream(safePath)); const response = await apiClient.post({ - url: `${await getRegionalHost("appAutomateUpload", { - "browserstack-username": username, - "browserstack-access-key": password, - })}/app-automate/upload`, + url: `${getRegionalHost("appAutomateUpload")}/app-automate/upload`, headers: { ...formData.getHeaders(), Authorization: @@ -222,7 +219,7 @@ export async function uploadEspressoApp( ): Promise { return uploadFileToBrowserStack( appPath, - `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/app`, + `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/app`, "app_url", config, { allowedExtensions: [".apk", ".aab"] }, @@ -236,7 +233,7 @@ export async function uploadEspressoTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/test-suite`, + `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".apk"] }, @@ -250,7 +247,7 @@ export async function uploadXcuiApp( ): Promise { return uploadFileToBrowserStack( appPath, - `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/app`, + `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/app`, "app_url", config, { allowedExtensions: [".ipa"] }, @@ -264,7 +261,7 @@ export async function uploadXcuiTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/test-suite`, + `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".zip"] }, @@ -283,7 +280,7 @@ export async function triggerEspressoBuild( "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); const response = await apiClient.post({ - url: `${await getRegionalHost("appAutomateUpload", config)}/app-automate/espresso/v2/build`, + url: `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/build`, headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -319,7 +316,7 @@ export async function triggerXcuiBuild( }; const response = await apiClient.post({ - url: `${await getRegionalHost("appAutomateUpload", config)}/app-automate/xcuitest/v2/build`, + url: `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/build`, headers: { Authorization: "Basic " + diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index e7e78208..90e837df 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -134,10 +134,7 @@ async function takeAppScreenshot(args: { try { driver = await remote({ protocol: "https", - hostname: await getRegionalHost("appAutomateHub", { - "browserstack-username": username, - "browserstack-access-key": password, - }), + 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 9af2023c..80940929 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -15,10 +15,7 @@ async function extractScreenshotUrls( const auth = Buffer.from(authString).toString("base64"); const isAutomate = sessionType === SessionType.Automate; - const host = await getRegionalHost( - isAutomate ? "automateApi" : "appAutomateApi", - config, - ); + const host = getRegionalHost(isAutomate ? "automateApi" : "appAutomateApi"); const baseUrl = `${host}/${isAutomate ? "automate" : "app-automate"}`; const url = `${baseUrl}/sessions/${sessionId}/logs`; diff --git a/src/tools/automate-utils/list-session-ids.ts b/src/tools/automate-utils/list-session-ids.ts index 0694c8cd..224af3a9 100644 --- a/src/tools/automate-utils/list-session-ids.ts +++ b/src/tools/automate-utils/list-session-ids.ts @@ -127,11 +127,10 @@ export async function listSessionIds( url: sessionsListUrl( args.sessionType, buildId, - await getRegionalHost( + getRegionalHost( args.sessionType === SessionType.Automate ? "automateApi" : "appAutomateUpload", - config, ), ), headers: { diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts index 8930e847..cbe03695 100644 --- a/src/tools/automate-utils/resolve-hashed-build-id.ts +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -62,9 +62,8 @@ export async function resolveBuildIdFromSession( url: sessionDetailsUrl( sessionType, sessionId, - await getRegionalHost( + getRegionalHost( sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi", - config, ), ), headers: { @@ -99,7 +98,7 @@ export async function findSessionIdForObservabilityBuild( ): Promise { const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const baseUrl = `${await getRegionalHost("automationApi", config)}/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 82d23b91..4287647b 100644 --- a/src/tools/build-insights.ts +++ b/src/tools/build-insights.ts @@ -14,7 +14,7 @@ export async function fetchBuildInsightsTool( config: BrowserStackConfig, ): Promise { try { - const host = await getRegionalHost("automationApi", config); + const host = getRegionalHost("automationApi"); const buildUrl = `${host}/ext/v1/builds/${args.buildId}`; const qualityGateUrl = `${host}/ext/v1/quality-gates/${args.buildId}`; diff --git a/src/tools/failurelogs-utils/app-automate.ts b/src/tools/failurelogs-utils/app-automate.ts index c56675fc..ffea04c6 100644 --- a/src/tools/failurelogs-utils/app-automate.ts +++ b/src/tools/failurelogs-utils/app-automate.ts @@ -15,7 +15,7 @@ export async function retrieveDeviceLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("appAutomateApi", config)}/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"); @@ -48,7 +48,7 @@ export async function retrieveAppiumLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("appAutomateApi", config)}/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"); @@ -81,7 +81,7 @@ export async function retrieveCrashLogs( buildId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("appAutomateApi", config)}/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 096f579b..df50cffe 100644 --- a/src/tools/failurelogs-utils/automate.ts +++ b/src/tools/failurelogs-utils/automate.ts @@ -16,7 +16,7 @@ export async function retrieveNetworkFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("automateApi", config)}/automate/sessions/${sessionId}/networklogs`; + const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/networklogs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -72,7 +72,7 @@ export async function retrieveSessionFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("automateApi", config)}/automate/sessions/${sessionId}/logs`; + const url = `${getRegionalHost("automateApi")}/automate/sessions/${sessionId}/logs`; const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); @@ -104,7 +104,7 @@ export async function retrieveConsoleFailures( sessionId: string, config: BrowserStackConfig, ): Promise { - const url = `${await getRegionalHost("automateApi", config)}/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 7813d361..9d10fbcc 100644 --- a/src/tools/failurelogs-utils/video.ts +++ b/src/tools/failurelogs-utils/video.ts @@ -12,9 +12,8 @@ export async function retrieveSessionVideo( ): Promise { const product = sessionType === SessionType.AppAutomate ? "app-automate" : "automate"; - const host = await getRegionalHost( + const host = getRegionalHost( sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi", - config, ); const url = `${host}/${product}/sessions/${encodeURIComponent(sessionId)}.json`; const authString = getBrowserStackAuth(config); diff --git a/src/tools/rca-agent-utils/get-build-id.ts b/src/tools/rca-agent-utils/get-build-id.ts index 5db73f3f..cc32aaff 100644 --- a/src/tools/rca-agent-utils/get-build-id.ts +++ b/src/tools/rca-agent-utils/get-build-id.ts @@ -6,10 +6,7 @@ export async function getBuildId( username: string, accessKey: string, ): Promise { - const host = await getRegionalHost("automationApi", { - "browserstack-username": username, - "browserstack-access-key": accessKey, - }); + 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); diff --git a/src/tools/rca-agent-utils/list-build-id.ts b/src/tools/rca-agent-utils/list-build-id.ts index 7c180dfb..42f46b69 100644 --- a/src/tools/rca-agent-utils/list-build-id.ts +++ b/src/tools/rca-agent-utils/list-build-id.ts @@ -13,10 +13,7 @@ export async function listBuildId( const authHeader = "Basic " + Buffer.from(`${username}:${accessKey}`).toString("base64"); - const host = await getRegionalHost("automationApi", { - "browserstack-username": username, - "browserstack-access-key": accessKey, - }); + const host = getRegionalHost("automationApi"); const response = await apiClient.get({ url: `${host}/ext/v1/builds/latest`, headers: { diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index b19f8e57..f33a226a 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -119,7 +119,7 @@ export async function fetchRCADataTool( testIds, authString, undefined, - await getRegionalHost("observabilityApi", config), + getRegionalHost("observabilityApi"), ); const formattedData = formatRCAData(rcaData); @@ -166,7 +166,7 @@ export async function listTestIdsTool( authString, status, includeFailureDetail, - await getRegionalHost("automationApi", config), + getRegionalHost("automationApi"), ); return { diff --git a/src/tools/selfheal-utils/fetch-test-code.ts b/src/tools/selfheal-utils/fetch-test-code.ts index 960c676e..16164455 100644 --- a/src/tools/selfheal-utils/fetch-test-code.ts +++ b/src/tools/selfheal-utils/fetch-test-code.ts @@ -103,7 +103,7 @@ export async function fetchTestCodeBySession( const authString = getBrowserStackAuth(config); const auth = Buffer.from(authString).toString("base64"); - const url = `${await getRegionalHost("automationApi", config)}/ext/v1/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 59ffc8e9..b9781b85 100644 --- a/src/tools/selfheal-utils/selfheal.ts +++ b/src/tools/selfheal-utils/selfheal.ts @@ -70,7 +70,7 @@ export async function fetchSelfHealingReportByBuild( const auth = Buffer.from(authString).toString("base64"); const presignedResp = await apiClient.get>({ - url: `${await getRegionalHost("automationApi", config)}/ext/v1/builds/${encodeURIComponent(buildUuid)}/selfHealingReport`, + url: `${getRegionalHost("automationApi")}/ext/v1/builds/${encodeURIComponent(buildUuid)}/selfHealingReport`, headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, @@ -133,9 +133,8 @@ export async function getSelfHealSelectors( const auth = Buffer.from(authString).toString("base64"); const productPath = sessionType === "app-automate" ? "app-automate" : "automate"; - const host = await getRegionalHost( + const host = getRegionalHost( sessionType === "app-automate" ? "appAutomateApi" : "automateApi", - config, ); const url = `${host}/${productPath}/sessions/${sessionId}/logs`; diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 2f365150..0be9c2eb 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -1,162 +1,124 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -// tests/setup.ts stubs the resolver for every other suite; this one tests the real thing. -vi.unmock("../../src/lib/regional-host"); - -vi.mock("../../src/lib/apiClient", () => ({ - apiClient: { - get: vi.fn(), - }, -})); - -vi.mock("../../src/logger", () => ({ - default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, -})); +import { describe, it, expect, vi, afterEach } from "vitest"; const PROD_AUTOMATION = "https://api-automation.browserstack.com"; -// Re-imported per test so the stdio-mode region cache and the mocked config stay isolated. +// Re-imported per test so each case sees its own mocked config. async function loadModule( - remoteMcp: boolean, - automationBaseUrl = PROD_AUTOMATION, + region: string | undefined, + automationBaseUrl: string | undefined = PROD_AUTOMATION, ) { vi.resetModules(); - vi.doUnmock("../../src/lib/regional-host"); vi.doMock("../../src/config", () => ({ __esModule: true, default: { - REMOTE_MCP: remoteMcp, + BROWSERSTACK_GRR_REGION: region, BROWSERSTACK_AUTOMATION_BASE_URL: automationBaseUrl, }, })); - const apiClientMod = await import("../../src/lib/apiClient"); - const mod = await import("../../src/lib/regional-host"); - return { get: apiClientMod.apiClient.get as any, ...mod }; + return import("../../src/lib/regional-host"); } -const config = { - "browserstack-username": "u", - "browserstack-access-key": "k", -}; - -const probedUrls = (get: any) => get.mock.calls.map((c: any[]) => c[0].url); - describe("getRegionalHost", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("serves a non-GRR account from the US host after one probe", async () => { - const { get, getRegionalHost } = await loadModule(false); - get.mockResolvedValueOnce({ ok: true }); + 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("appAutomateUpload")).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); + }, + ); - await expect(getRegionalHost("automateApi", config)).resolves.toBe( - "https://api.browserstack.com", + 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(probedUrls(get)).toEqual([ - "https://api.browserstack.com/automate/plan.json", - ]); - }); - - it("routes an EU GRR account to the EU host when the US host refuses it", async () => { - const { get, getRegionalHost } = await loadModule(false); - get - .mockResolvedValueOnce({ ok: false, status: 401 }) - .mockResolvedValueOnce({ ok: true }); - - await expect(getRegionalHost("appAutomateHub", config)).resolves.toBe( + expect(getRegionalHost("appAutomateApi")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateUpload")).toBe( + "https://api-eu-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateHub")).toBe( "hub-eu-only.browserstack.com", ); - expect(probedUrls(get)).toEqual([ - "https://api.browserstack.com/app-automate/plan.json", - "https://api-eu-only.browserstack.com/app-automate/plan.json", - ]); - }); - - it("probes each product with its own read endpoint", async () => { - const { get, getRegionalHost } = await loadModule(false); - get.mockResolvedValue({ ok: true }); - - await getRegionalHost("accessibilityApi", config); - await getRegionalHost("observabilityApi", config); - await getRegionalHost("automationApi", config); - - expect(probedUrls(get)).toEqual([ - "https://api-accessibility.browserstack.com/api/website-scanner/v1/scans", - "https://api-observability.browserstack.com/ext/v1/projects", - "https://api-automation.browserstack.com/ext/v1/projects", - ]); - }); - - it("keeps trying the next region after a network error", async () => { - const { get, getRegionalHost } = await loadModule(false); - get - .mockRejectedValueOnce(Object.assign(new Error("boom"), { code: "ECONNRESET" })) - .mockResolvedValueOnce({ ok: true }); - - await expect(getRegionalHost("observabilityApi", config)).resolves.toBe( + expect(getRegionalHost("accessibilityApi")).toBe( + "https://api-accessibility-eu.browserstack.com", + ); + expect(getRegionalHost("accessibilityDashboard")).toBe( + "https://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("falls back to the US host, uncached, when every region refuses", async () => { - const { get, getRegionalHost } = await loadModule(false); - get.mockResolvedValue({ ok: false, status: 403 }); - - await expect(getRegionalHost("accessibilityApi", config)).resolves.toBe( - "https://api-accessibility.browserstack.com", + it("routes Automate to api-aps-only when the region is in", async () => { + const { getRegionalHost } = await loadModule("in"); + expect(getRegionalHost("automateApi")).toBe( + "https://api-aps-only.browserstack.com", + ); + expect(getRegionalHost("appAutomateHub")).toBe( + "hub-aps-only.browserstack.com", ); - expect(get).toHaveBeenCalledTimes(3); - - // Not cached: the next call probes again instead of pinning the fallback. - await getRegionalHost("accessibilityApi", config); - expect(get).toHaveBeenCalledTimes(6); }); - it("stdio mode caches the region per product, so hosts of one product share a probe", async () => { - const { get, getRegionalHost } = await loadModule(false); - get - .mockResolvedValueOnce({ ok: false, status: 401 }) - .mockResolvedValueOnce({ ok: true }); + 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); + }); - await getRegionalHost("appAutomateApi", config); - await expect(getRegionalHost("appAutomateUpload", config)).resolves.toBe( - "https://api-eu-only.browserstack.com", - ); - expect(get).toHaveBeenCalledTimes(2); + 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); }); +}); - it("remote mode never caches, so one user's region cannot reach another", async () => { - const { get, getRegionalHost } = await loadModule(true); - get.mockResolvedValue({ ok: true }); +describe("BROWSERSTACK_GRR_REGION parsing", () => { + const original = process.env.BROWSERSTACK_GRR_REGION; - await getRegionalHost("automateApi", config); - await getRegionalHost("automateApi", config); - expect(get).toHaveBeenCalledTimes(2); + afterEach(() => { + if (original === undefined) delete process.env.BROWSERSTACK_GRR_REGION; + else process.env.BROWSERSTACK_GRR_REGION = original; }); - it("uses an explicit BROWSERSTACK_AUTOMATION_BASE_URL without probing", async () => { - const staging = "https://api-automation-preprod.bsstag.com"; - const { get, getRegionalHost } = await loadModule(false, staging); - - await expect(getRegionalHost("automationApi", config)).resolves.toBe( - staging, - ); - expect(get).not.toHaveBeenCalled(); + async function loadConfig(value: string | undefined) { + vi.resetModules(); + vi.doUnmock("../../src/config"); + if (value === undefined) delete process.env.BROWSERSTACK_GRR_REGION; + else process.env.BROWSERSTACK_GRR_REGION = value; + return (await import("../../src/config")).default; + } + + it.each([ + [undefined, "us"], + ["", "us"], + ["us", "us"], + [" EU ", "eu"], + ["in", "in"], + ])("reads %j as %s", async (value, expected) => { + expect((await loadConfig(value)).BROWSERSTACK_GRR_REGION).toBe(expected); }); -}); -describe("hostFor", () => { - it("keeps App Automate uploads on api-cloud in the US and on api--only elsewhere", async () => { - const { hostFor } = await loadModule(false); - expect(hostFor("appAutomateUpload", "us")).toBe( - "https://api-cloud.browserstack.com", - ); - expect(hostFor("appAutomateUpload", "eu")).toBe( - "https://api-eu-only.browserstack.com", - ); - expect(hostFor("appAutomateUpload", "in")).toBe( - "https://api-aps-only.browserstack.com", + it("refuses an unknown region at startup instead of defaulting to US", async () => { + await expect(loadConfig("uk")).rejects.toThrow( + /Invalid BROWSERSTACK_GRR_REGION "uk"/, ); }); }); diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index eb8c1a2e..e1f97b59 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -107,3 +107,38 @@ describe("getTMBaseURL — failure details", () => { expect(err.message).toMatch(/HTTP 503/); }); }); + +describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function loadWithRegion(region: string | undefined) { + vi.resetModules(); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { REMOTE_MCP: false, BROWSERSTACK_GRR_REGION: region }, + })); + const apiClientMod = await import("../../src/lib/apiClient"); + const tmMod = await import("../../src/lib/tm-base-url"); + return { apiClient: apiClientMod.apiClient, getTMBaseURL: tmMod.getTMBaseURL }; + } + + it.each([ + ["eu", "https://test-management-eu.browserstack.com"], + ["in", "https://test-management-in.browserstack.com"], + ])("region %s goes straight to its host without probing", async (region, url) => { + const { apiClient, getTMBaseURL } = await loadWithRegion(region); + await expect(getTMBaseURL(mockConfig)).resolves.toBe(url); + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it("region us keeps the existing probe", async () => { + const { apiClient, getTMBaseURL } = await loadWithRegion("us"); + (apiClient.get as any).mockResolvedValueOnce({ ok: true }); + await expect(getTMBaseURL(mockConfig)).resolves.toBe( + "https://test-management.browserstack.com", + ); + expect(apiClient.get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 5d8e6a4c..e634ea58 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -11,17 +11,3 @@ vi.mock("sharp", () => { sharp.concurrency = vi.fn(); return { default: sharp }; }); - -// Region routing probes BrowserStack before each call; most suites mock apiClient with -// ordered responses, so resolve every host to its US default here. The resolver's own -// behaviour is covered by tests/lib/regional-host.test.ts, which unmocks it. -vi.mock("../src/lib/regional-host", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - getRegionalHost: vi.fn(async (kind: Parameters[0]) => - actual.hostFor(kind, "us"), - ), - }; -}); From 1666f61d3b052cbfe5a482765970002d353288d5 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 16 Oct 2026 15:26:28 +0530 Subject: [PATCH 04/26] docs: document BROWSERSTACK_GRR_REGION in llms-install.md Co-Authored-By: Claude Opus 5.5 --- llms-install.md | 1 + 1 file changed, 1 insertion(+) diff --git a/llms-install.md b/llms-install.md index cf1b8598..7be19979 100644 --- a/llms-install.md +++ b/llms-install.md @@ -123,6 +123,7 @@ Optional environment variables for the local server: | --- | --- | | `MCP_UPLOAD_BASE_DIR` | Directory that file-upload tools (`uploadProductRequirementFile`, `takeAppScreenshot`, `runAppTestsOnBrowserStack`, `runAppLiveSession`) may read from. Uploads are restricted to this directory. | | `BROWSERSTACK_LOCAL_OPTION_` | Passes an option to the BrowserStack Local tunnel, e.g. `BROWSERSTACK_LOCAL_OPTION_PROXYHOST`. Only needed when testing sites behind a firewall or on localhost. | +| `BROWSERSTACK_GRR_REGION` | For accounts with Geo Region Restriction (GRR): `eu` or `in` sends Test Management, Automate, App Automate, Accessibility and Test Reporting calls to that region's endpoints. Leave unset (or `us`) otherwise. | ## Verify the install From dc36a284085c430c6ab3fe4d0ed1a017ed40aaaa Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 16 Oct 2026 18:15:58 +0530 Subject: [PATCH 05/26] refactor(lib): trim GRR routing comments Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 6 +----- src/lib/regional-host.ts | 18 ------------------ src/lib/tm-base-url.ts | 3 +-- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/config.ts b/src/config.ts index b9c8a05b..8ad9c802 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,11 +41,7 @@ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com"; -/** - * BROWSERSTACK_GRR_REGION: the Geo Region Restriction region of the account this server - * serves — "us" (default), "eu" or "in". Unset or "us" keeps the US hosts. Any other - * value fails at startup: a typo must not quietly send a GRR account's data to the US. - */ +// BROWSERSTACK_GRR_REGION: "us" (default), "eu" or "in". Invalid values fail at startup. const GRR_REGIONS = ["us", "eu", "in"] as const; export type GrrRegion = (typeof GRR_REGIONS)[number]; diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 356bd7c4..2d281307 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -1,15 +1,5 @@ import appConfig, { GrrRegion } from "../config.js"; -/** - * Geo Region Restriction (GRR) routing for the hand-written tools. - * - * A GRR account's data lives behind its region's hosts, and the other regions reject it. - * The region is set per deployment with BROWSERSTACK_GRR_REGION ("us" by default, "eu", - * "in"); unset or "us" keeps every host exactly as it was before this module existed. - * - * Test Management is routed separately in `tm-base-url.ts`, which honours the same flag. - */ - type Hosts = Record; const AUTOMATE_API: Hosts = { @@ -18,11 +8,6 @@ const AUTOMATE_API: Hosts = { in: "https://api-aps-only.browserstack.com", }; -/** - * Every host the tools call, by region. Automate/App Automate use the `api--only` - * and `hub--only` domains from the internal GRR documentation; the rest were - * confirmed by DNS and by the regional host rejecting a US account. - */ const HOSTS = { automateApi: AUTOMATE_API, appAutomateApi: AUTOMATE_API, @@ -60,14 +45,11 @@ const HOSTS = { export type RegionalHostKind = keyof typeof HOSTS; -/** The host for `kind` in a given region. Pure; exposed for tests. */ export function hostFor(kind: RegionalHostKind, region: GrrRegion): string { return HOSTS[kind][region]; } -/** The host for `kind` in this deployment's GRR region. */ export function getRegionalHost(kind: RegionalHostKind): string { - // An explicit non-prod target (BROWSERSTACK_AUTOMATION_BASE_URL) is used as given. if ( kind === "automationApi" && appConfig.BROWSERSTACK_AUTOMATION_BASE_URL && diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index 1c5a296b..ccb04ec1 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -15,8 +15,7 @@ let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { - // A GRR deployment names its region (BROWSERSTACK_GRR_REGION); use that host directly. - // Unset or "us" keeps the probe below, which also finds EU/IN accounts on its own. + // Unset or "us" falls through to the probe below. const grrRegion = appConfig.BROWSERSTACK_GRR_REGION; if (grrRegion === "eu" || grrRegion === "in") { return `https://test-management-${grrRegion}.browserstack.com`; From 94a2a1856d6dac7bab546fc65c938425e4e9c598 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Mon, 19 Oct 2026 17:22:14 +0530 Subject: [PATCH 06/26] feat(lib): route the TRA capability by BROWSERSTACK_GRR_REGION The capability registry took tra's host from its index, which declares only the US host, so an EU deployment sent every invokeCapability call for tra to api-observability.browserstack.com and the EU account was rejected there. Resolve tra through the same getRegionalHost("observabilityApi") lookup the hand-written Test Reporting tools use, next to tm in the registry's built-in map. Unset or "us" returns the host the index already declares, and CAPABILITY_REGISTRY_BASE_URL_TRA and per-environment overrides still win. Co-Authored-By: Claude Opus 5.5 --- src/tools/capability-registry/config.ts | 7 ++-- tests/tools/capabilityRegistryRegion.test.ts | 37 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 8258bd6b..157c71b6 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -18,6 +18,7 @@ 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 { getRegionalHost } from "../../lib/regional-host.js"; /** * The environment this DEPLOYMENT points at, e.g. "preprod". @@ -52,14 +53,16 @@ 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. tra follows + * BROWSERSTACK_GRR_REGION through `lib/regional-host.ts`, like the hand-written Test Reporting + * tools. Once a product.yaml declares `base_urls`, its entry can go and nothing else changes. */ const BUILTIN_DISCOVERY: Record< string, (config: BrowserStackConfig) => Promise > = { tm: getTMBaseURL, + tra: async () => getRegionalHost("observabilityApi"), }; /** True when this product's host is account-specific and must be discovered per call. */ diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index 308b302d..21100bff 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -200,3 +200,40 @@ describe("multiple environments, the way Atlas defines them", () => { .rejects.toThrow(/environment 'preprod' has no host/); }); }); + +describe("tra follows BROWSERSTACK_GRR_REGION", () => { + const DECLARED = "https://api-observability.browserstack.com"; + + beforeEach(() => { + vi.resetModules(); + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TRA; + }); + + afterEach(() => { + delete process.env.BROWSERSTACK_GRR_REGION; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TRA; + }); + + it("sends an EU deployment to the EU host, though the index declares the US one", async () => { + process.env.BROWSERSTACK_GRR_REGION = "eu"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe( + "https://api-observability-eu.browserstack.com", + ); + }); + + it("keeps the US host when the region is unset", async () => { + delete process.env.BROWSERSTACK_GRR_REGION; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe(DECLARED); + }); + + it("lets an explicit override win over the region", async () => { + process.env.BROWSERSTACK_GRR_REGION = "eu"; + process.env.CAPABILITY_REGISTRY_BASE_URL_TRA = "https://tra-preprod.example"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe( + "https://tra-preprod.example", + ); + }); +}); From 2ee35371b35333c651651f0bbc68fb9764320e88 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 22 Oct 2026 13:48:01 +0530 Subject: [PATCH 07/26] feat(testmanagement): let BROWSERSTACK_GRR_REGION set the TM probe order Test Management is the one product that probes its regional hosts. Instead of skipping the probe when the flag is set, probe in the flag's order: the configured region first, then the rest in their standard order (unset/us: us, eu, in; eu: eu, us, in; in: in, us, eu). A correct flag still answers on the first request, and a missing or wrong flag still finds the account's region, because the other regions reject it. The order comes from regionPreference() in regional-host.ts, derived from the region host table rather than hardcoded per region. Co-Authored-By: Claude Opus 5.5 --- src/lib/regional-host.ts | 7 +++++++ src/lib/tm-base-url.ts | 21 ++++++++------------ tests/lib/regional-host.test.ts | 12 ++++++++++++ tests/lib/tm-base-url.test.ts | 34 ++++++++++++++++++++++++--------- 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 2d281307..cec36fc7 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -59,3 +59,10 @@ export function getRegionalHost(kind: RegionalHostKind): string { } return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us"); } + +// The configured region first, then the rest in their standard order. +export function regionPreference(): GrrRegion[] { + const first = appConfig.BROWSERSTACK_GRR_REGION ?? "us"; + const regions = Object.keys(AUTOMATE_API) as GrrRegion[]; + return [first, ...regions.filter((r) => r !== first)]; +} diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index ccb04ec1..07bafdd2 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -2,25 +2,20 @@ import { apiClient } from "./apiClient.js"; import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; -import appConfig from "../config.js"; +import appConfig, { GrrRegion } from "../config.js"; +import { regionPreference } from "./regional-host.js"; -const TM_BASE_URLS = [ - "https://test-management.browserstack.com", - "https://test-management-eu.browserstack.com", - "https://test-management-in.browserstack.com", -] as const; +const TM_BASE_URLS: Record = { + us: "https://test-management.browserstack.com", + eu: "https://test-management-eu.browserstack.com", + in: "https://test-management-in.browserstack.com", +}; let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { - // Unset or "us" falls through to the probe below. - const grrRegion = appConfig.BROWSERSTACK_GRR_REGION; - if (grrRegion === "eu" || grrRegion === "in") { - return `https://test-management-${grrRegion}.browserstack.com`; - } - // 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. @@ -40,7 +35,7 @@ export async function getTMBaseURL( const failures: string[] = []; - for (const baseUrl of TM_BASE_URLS) { + for (const baseUrl of regionPreference().map((r) => TM_BASE_URLS[r])) { try { const res = await apiClient.get({ url: `${baseUrl}/api/v2/projects/`, diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 0be9c2eb..3bd2baea 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -90,6 +90,18 @@ describe("getRegionalHost", () => { }); }); +describe("regionPreference", () => { + it.each([ + [undefined, ["us", "eu", "in"]], + ["us", ["us", "eu", "in"]], + ["eu", ["eu", "us", "in"]], + ["in", ["in", "us", "eu"]], + ])("region %s orders %j", async (region, order) => { + const { regionPreference } = await loadModule(region); + expect(regionPreference()).toEqual(order); + }); +}); + describe("BROWSERSTACK_GRR_REGION parsing", () => { const original = process.env.BROWSERSTACK_GRR_REGION; diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index e1f97b59..ded7ce56 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -108,7 +108,7 @@ describe("getTMBaseURL — failure details", () => { }); }); -describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { +describe("getTMBaseURL — BROWSERSTACK_GRR_REGION sets the probe order", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -124,21 +124,37 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { return { apiClient: apiClientMod.apiClient, getTMBaseURL: tmMod.getTMBaseURL }; } + const probed = (apiClient: any) => + apiClient.get.mock.calls.map((c: any[]) => new URL(c[0].url).host); + it.each([ - ["eu", "https://test-management-eu.browserstack.com"], - ["in", "https://test-management-in.browserstack.com"], - ])("region %s goes straight to its host without probing", async (region, url) => { + [undefined, ["test-management", "test-management-eu", "test-management-in"]], + ["us", ["test-management", "test-management-eu", "test-management-in"]], + ["eu", ["test-management-eu", "test-management", "test-management-in"]], + ["in", ["test-management-in", "test-management", "test-management-eu"]], + ])("region %s probes %j in that order", async (region, order) => { const { apiClient, getTMBaseURL } = await loadWithRegion(region); - await expect(getTMBaseURL(mockConfig)).resolves.toBe(url); - expect(apiClient.get).not.toHaveBeenCalled(); + (apiClient.get as any).mockResolvedValue({ ok: false, status: 401 }); + await getTMBaseURL(mockConfig).catch(() => {}); + expect(probed(apiClient)).toEqual(order.map((h) => `${h}.browserstack.com`)); }); - it("region us keeps the existing probe", async () => { - const { apiClient, getTMBaseURL } = await loadWithRegion("us"); + it("a correct flag answers on the first probe", async () => { + const { apiClient, getTMBaseURL } = await loadWithRegion("eu"); (apiClient.get as any).mockResolvedValueOnce({ ok: true }); await expect(getTMBaseURL(mockConfig)).resolves.toBe( - "https://test-management.browserstack.com", + "https://test-management-eu.browserstack.com", ); expect(apiClient.get).toHaveBeenCalledTimes(1); }); + + it("a wrong flag still finds the account's region", async () => { + const { apiClient, getTMBaseURL } = await loadWithRegion("eu"); + (apiClient.get as any) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }); + await expect(getTMBaseURL(mockConfig)).resolves.toBe( + "https://test-management.browserstack.com", + ); + }); }); From a214729e393701da752c3e80c51edbe210ee2db7 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 22 Oct 2026 13:56:17 +0530 Subject: [PATCH 08/26] refactor(lib): route every product, TM included, through the BROWSERSTACK_GRR_REGION lookup Replace the TM-only probe ordering with one path for every product: when BROWSERSTACK_GRR_REGION is set, Test Management (tools and the tm capability), the tra capability, Automate, App Automate, Accessibility and Test Reporting all take their host from getRegionalHost() - a lookup on the env flag, with no probing and nothing to cache. The flag now stays undefined when unset, so "not configured" is distinct from "us". Only in that case does Test Management keep its existing US -> EU -> IN probe, so EU/IN accounts that never set the flag keep working as on main. Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 8 +++---- src/lib/regional-host.ts | 12 +++++------ src/lib/tm-base-url.ts | 21 ++++++++++++------- tests/lib/regional-host.test.ts | 16 ++------------ tests/lib/tm-base-url.test.ts | 37 ++++++++++++--------------------- 5 files changed, 37 insertions(+), 57 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8ad9c802..7908c6f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,13 +41,13 @@ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com"; -// BROWSERSTACK_GRR_REGION: "us" (default), "eu" or "in". Invalid values fail at startup. +// BROWSERSTACK_GRR_REGION: "us", "eu" or "in"; unset means not configured. Invalid values fail at startup. const GRR_REGIONS = ["us", "eu", "in"] as const; export type GrrRegion = (typeof GRR_REGIONS)[number]; -function parseGrrRegion(raw: string | undefined): GrrRegion { +function parseGrrRegion(raw: string | undefined): GrrRegion | undefined { const value = (raw || "").trim().toLowerCase(); - if (!value) return "us"; + if (!value) return undefined; if ((GRR_REGIONS as readonly string[]).includes(value)) { return value as GrrRegion; } @@ -92,7 +92,7 @@ export class Config { * "deferred" | "elicit". Anything else is ignored. */ public readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined, - public readonly BROWSERSTACK_GRR_REGION: GrrRegion = "us", + public readonly BROWSERSTACK_GRR_REGION?: GrrRegion, ) {} } diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index cec36fc7..3d4c4702 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -9,6 +9,11 @@ const AUTOMATE_API: Hosts = { }; const HOSTS = { + testManagement: { + us: "https://test-management.browserstack.com", + eu: "https://test-management-eu.browserstack.com", + in: "https://test-management-in.browserstack.com", + }, automateApi: AUTOMATE_API, appAutomateApi: AUTOMATE_API, appAutomateUpload: { @@ -59,10 +64,3 @@ export function getRegionalHost(kind: RegionalHostKind): string { } return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us"); } - -// The configured region first, then the rest in their standard order. -export function regionPreference(): GrrRegion[] { - const first = appConfig.BROWSERSTACK_GRR_REGION ?? "us"; - const regions = Object.keys(AUTOMATE_API) as GrrRegion[]; - return [first, ...regions.filter((r) => r !== first)]; -} diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index 07bafdd2..136ba496 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -2,20 +2,25 @@ import { apiClient } from "./apiClient.js"; import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; -import appConfig, { GrrRegion } from "../config.js"; -import { regionPreference } from "./regional-host.js"; +import appConfig from "../config.js"; +import { getRegionalHost, hostFor } from "./regional-host.js"; -const TM_BASE_URLS: Record = { - us: "https://test-management.browserstack.com", - eu: "https://test-management-eu.browserstack.com", - in: "https://test-management-in.browserstack.com", -}; +// Probe order when BROWSERSTACK_GRR_REGION is not set. +const TM_BASE_URLS = (["us", "eu", "in"] as const).map((r) => + hostFor("testManagement", r), +); let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { + // A configured region is used as given, like every other product. Unset keeps the + // probe below, so existing EU/IN accounts that never set the flag keep working. + if (appConfig.BROWSERSTACK_GRR_REGION) { + 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. @@ -35,7 +40,7 @@ export async function getTMBaseURL( const failures: string[] = []; - for (const baseUrl of regionPreference().map((r) => TM_BASE_URLS[r])) { + for (const baseUrl of TM_BASE_URLS) { try { const res = await apiClient.get({ url: `${baseUrl}/api/v2/projects/`, diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 3bd2baea..eb64a379 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -90,18 +90,6 @@ describe("getRegionalHost", () => { }); }); -describe("regionPreference", () => { - it.each([ - [undefined, ["us", "eu", "in"]], - ["us", ["us", "eu", "in"]], - ["eu", ["eu", "us", "in"]], - ["in", ["in", "us", "eu"]], - ])("region %s orders %j", async (region, order) => { - const { regionPreference } = await loadModule(region); - expect(regionPreference()).toEqual(order); - }); -}); - describe("BROWSERSTACK_GRR_REGION parsing", () => { const original = process.env.BROWSERSTACK_GRR_REGION; @@ -119,8 +107,8 @@ describe("BROWSERSTACK_GRR_REGION parsing", () => { } it.each([ - [undefined, "us"], - ["", "us"], + [undefined, undefined], + ["", undefined], ["us", "us"], [" EU ", "eu"], ["in", "in"], diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index ded7ce56..04553cfa 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -108,7 +108,7 @@ describe("getTMBaseURL — failure details", () => { }); }); -describe("getTMBaseURL — BROWSERSTACK_GRR_REGION sets the probe order", () => { +describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -124,37 +124,26 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION sets the probe order", () => return { apiClient: apiClientMod.apiClient, getTMBaseURL: tmMod.getTMBaseURL }; } - const probed = (apiClient: any) => - apiClient.get.mock.calls.map((c: any[]) => new URL(c[0].url).host); - it.each([ - [undefined, ["test-management", "test-management-eu", "test-management-in"]], - ["us", ["test-management", "test-management-eu", "test-management-in"]], - ["eu", ["test-management-eu", "test-management", "test-management-in"]], - ["in", ["test-management-in", "test-management", "test-management-eu"]], - ])("region %s probes %j in that order", async (region, order) => { + ["us", "https://test-management.browserstack.com"], + ["eu", "https://test-management-eu.browserstack.com"], + ["in", "https://test-management-in.browserstack.com"], + ])("region %s uses its host directly, like every other product", async (region, url) => { const { apiClient, getTMBaseURL } = await loadWithRegion(region); - (apiClient.get as any).mockResolvedValue({ ok: false, status: 401 }); - await getTMBaseURL(mockConfig).catch(() => {}); - expect(probed(apiClient)).toEqual(order.map((h) => `${h}.browserstack.com`)); - }); - - it("a correct flag answers on the first probe", async () => { - const { apiClient, getTMBaseURL } = await loadWithRegion("eu"); - (apiClient.get as any).mockResolvedValueOnce({ ok: true }); - await expect(getTMBaseURL(mockConfig)).resolves.toBe( - "https://test-management-eu.browserstack.com", - ); - expect(apiClient.get).toHaveBeenCalledTimes(1); + await expect(getTMBaseURL(mockConfig)).resolves.toBe(url); + expect(apiClient.get).not.toHaveBeenCalled(); }); - it("a wrong flag still finds the account's region", async () => { - const { apiClient, getTMBaseURL } = await loadWithRegion("eu"); + it("unset keeps the US → EU → IN probe for accounts that never set the flag", async () => { + const { apiClient, getTMBaseURL } = await loadWithRegion(undefined); (apiClient.get as any) .mockResolvedValueOnce({ ok: false, status: 401 }) .mockResolvedValueOnce({ ok: true }); await expect(getTMBaseURL(mockConfig)).resolves.toBe( - "https://test-management.browserstack.com", + "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"]); }); }); From 91822058870c8d3990838ed5588de84a690f275d Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Tue, 27 Oct 2026 18:02:52 +0530 Subject: [PATCH 09/26] feat(lib): send only usage fields in telemetry when BROWSERSTACK_GRR_REGION is eu or in Co-Authored-By: Claude Opus 5.5 --- src/lib/instrumentation.ts | 38 +++++++++ tests/lib/instrumentation-grr-region.test.ts | 87 ++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/lib/instrumentation-grr-region.test.ts diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index 1a1fe5a0..dfc17ca1 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -64,7 +64,45 @@ function errorProperties(error: unknown) { }; } +const REGION_RESTRICTED_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", + "feedback_category", +]); + +function forDataRegion( + props: MCPEventPayload["event_properties"], +): MCPEventPayload["event_properties"] { + const region = globalConfig.BROWSERSTACK_GRR_REGION; + if (!region || region === "us") return props; + return Object.fromEntries( + Object.entries(props).filter(([key]) => REGION_RESTRICTED_FIELDS.has(key)), + ) as MCPEventPayload["event_properties"]; +} + function sendEvent(event: MCPEventPayload, config?: any): void { + event = { ...event, event_properties: forDataRegion(event.event_properties) }; let authHeader: string | undefined; if (config) { try { diff --git a/tests/lib/instrumentation-grr-region.test.ts b/tests/lib/instrumentation-grr-region.test.ts new file mode 100644 index 00000000..584f7d6f --- /dev/null +++ b/tests/lib/instrumentation-grr-region.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { trackMCP, withToolCall } from "../../src/lib/instrumentation"; +import { apiClient } from "../../src/lib/apiClient"; +import appConfig from "../../src/config"; + +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, BROWSERSTACK_GRR_REGION: undefined }, +})); + +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; + +function setRegion(region: string | undefined) { + (appConfig as any).BROWSERSTACK_GRR_REGION = region; +} + +describe("telemetry under BROWSERSTACK_GRR_REGION", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each(["eu", "in"])( + "drops free text but keeps usage fields when the region is %s", + (region) => { + setRegion(region); + 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, + feedback_category: "tool_error", + }); + for (const field of [ + "error_message", + "change_summary", + "search_query", + "feedback_message", + ]) + expect(row).not.toHaveProperty(field); + }, + ); + + it("drops the error text of a returned failure inside withToolCall", async () => { + setRegion("eu"); + await 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) => { + setRegion(region); + trackMCP("searchCapability", clientInfo, new Error("boom"), config, { + search_query: "checkout runs", + }); + expect(lastRow()).toMatchObject({ + error_message: "boom", + search_query: "checkout runs", + }); + }, + ); +}); From 9cbd76da95d322f612536bd24a4665e2e1b87f9c Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 14:32:58 +0530 Subject: [PATCH 10/26] feat(server): leave out submitFeedback when BROWSERSTACK_GRR_REGION is eu or in Co-Authored-By: Claude Opus 5.5 --- src/lib/feedback-hint.ts | 9 ++- src/lib/instrumentation.ts | 1 - src/server-factory.ts | 9 ++- tests/lib/instrumentation-grr-region.test.ts | 2 +- tests/tools/feedback-region.test.ts | 60 ++++++++++++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 tests/tools/feedback-region.test.ts diff --git a/src/lib/feedback-hint.ts b/src/lib/feedback-hint.ts index 75f5290d..65cfdd4f 100644 --- a/src/lib/feedback-hint.ts +++ b/src/lib/feedback-hint.ts @@ -1,3 +1,10 @@ +import appConfig from "../config.js"; + +export function feedbackEnabled(): boolean { + const region = appConfig.BROWSERSTACK_GRR_REGION; + return !region || region === "us"; +} + 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 +32,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 dfc17ca1..3c76d08a 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -88,7 +88,6 @@ const REGION_RESTRICTED_FIELDS = new Set([ "coverage", "upstream_ok", "upstream_status", - "feedback_category", ]); function forDataRegion( diff --git a/src/server-factory.ts b/src/server-factory.ts index 00563a7c..6af8b806 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -24,7 +24,7 @@ import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js"; import addCapabilityRegistryTools from "./tools/capability-registry/register.js"; import { instrumentToolLatency } from "./lib/tool-latency.js"; import { nodeUpgradeNotice } from "./lib/node-version-notice.js"; -import { FEEDBACK_INSTRUCTION } from "./lib/feedback-hint.js"; +import { FEEDBACK_INSTRUCTION, feedbackEnabled } from "./lib/feedback-hint.js"; import addFeedbackTools from "./tools/feedback.js"; /** @@ -47,7 +47,10 @@ export class BrowserStackMcpServer { version: packageJson.version, }, { - instructions: [nodeUpgradeNotice(), FEEDBACK_INSTRUCTION] + instructions: [ + nodeUpgradeNotice(), + feedbackEnabled() ? FEEDBACK_INSTRUCTION : undefined, + ] .filter(Boolean) .join("\n"), }, @@ -82,7 +85,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, + ...(feedbackEnabled() ? [addFeedbackTools] : []), ]; toolAdders.forEach((adder) => { diff --git a/tests/lib/instrumentation-grr-region.test.ts b/tests/lib/instrumentation-grr-region.test.ts index 584f7d6f..8a5812dc 100644 --- a/tests/lib/instrumentation-grr-region.test.ts +++ b/tests/lib/instrumentation-grr-region.test.ts @@ -48,13 +48,13 @@ describe("telemetry under BROWSERSTACK_GRR_REGION", () => { error_type: "Error", capability: "listTestRuns", upstream_status: 200, - feedback_category: "tool_error", }); for (const field of [ "error_message", "change_summary", "search_query", "feedback_message", + "feedback_category", ]) expect(row).not.toHaveProperty(field); }, diff --git a/tests/tools/feedback-region.test.ts b/tests/tools/feedback-region.test.ts new file mode 100644 index 00000000..5a716cc9 --- /dev/null +++ b/tests/tools/feedback-region.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const CONFIG = { + "browserstack-username": "u", + "browserstack-access-key": "k", +} as any; + +const original = process.env.BROWSERSTACK_GRR_REGION; + +async function load(region: string | undefined) { + vi.resetModules(); + if (region === undefined) delete process.env.BROWSERSTACK_GRR_REGION; + else process.env.BROWSERSTACK_GRR_REGION = region; + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const { withFeedbackHint, FEEDBACK_HINT } = await import( + "../../src/lib/feedback-hint.js" + ); + const server = new BrowserStackMcpServer(CONFIG); + const instructions: string = + (server.getInstance().server as any)._instructions ?? ""; + return { server, instructions, withFeedbackHint, FEEDBACK_HINT }; +} + +const failure = () => ({ + content: [{ type: "text", text: "Failed: Request failed with status code 502" }], + isError: true, +}); + +describe("submitFeedback by BROWSERSTACK_GRR_REGION", () => { + afterEach(() => { + if (original === undefined) delete process.env.BROWSERSTACK_GRR_REGION; + else process.env.BROWSERSTACK_GRR_REGION = original; + }); + + it.each(["eu", "in"])( + "is not registered, instructed or hinted when the region is %s", + async (region) => { + const { server, instructions, withFeedbackHint, FEEDBACK_HINT } = + await load(region); + expect(server.getTool("submitFeedback")).toBeUndefined(); + expect(instructions).not.toContain("submitFeedback"); + expect(JSON.stringify(withFeedbackHint("listBuildId", failure()))).not.toContain( + FEEDBACK_HINT, + ); + }, + ); + + it.each([undefined, "us"])( + "is registered, instructed and hinted when the region is %s", + async (region) => { + const { server, instructions, withFeedbackHint, FEEDBACK_HINT } = + await load(region); + expect(server.getTool("submitFeedback")).toBeDefined(); + expect(instructions).toContain("submitFeedback"); + expect(JSON.stringify(withFeedbackHint("listBuildId", failure()))).toContain( + FEEDBACK_HINT, + ); + }, + ); +}); From 914fc5dedc76000b53ca3d85304417ae494ef4d2 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 14:38:16 +0530 Subject: [PATCH 11/26] fix(testmanagement): build the updateTestCase dashboard link from the resolved TM host Co-Authored-By: Claude Opus 5.5 --- src/tools/testmanagement-utils/update-testcase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.`, }, From d9cc0430531cd100c9bbb8d9c1e671b73220d692 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 14:38:17 +0530 Subject: [PATCH 12/26] refactor(lib): share one non-US region check and default host params to the regional host Co-Authored-By: Claude Opus 5.5 --- src/lib/feedback-hint.ts | 5 ++--- src/lib/instrumentation.ts | 8 ++++---- src/lib/regional-host.ts | 5 +++++ src/tools/automate-utils/list-session-ids.ts | 8 +++++--- src/tools/automate-utils/resolve-hashed-build-id.ts | 4 +++- src/tools/rca-agent-utils/rca-data.ts | 3 ++- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/lib/feedback-hint.ts b/src/lib/feedback-hint.ts index 65cfdd4f..b48021b8 100644 --- a/src/lib/feedback-hint.ts +++ b/src/lib/feedback-hint.ts @@ -1,8 +1,7 @@ -import appConfig from "../config.js"; +import { isNonUsRegion } from "./regional-host.js"; export function feedbackEnabled(): boolean { - const region = appConfig.BROWSERSTACK_GRR_REGION; - return !region || region === "us"; + return !isNonUsRegion(); } export const FEEDBACK_INSTRUCTION = diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index 3c76d08a..af9dcfc5 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -7,6 +7,7 @@ const packageJson = require("../../package.json"); import { apiClient } from "./apiClient.js"; import globalConfig from "../config.js"; import { redact } from "../tools/capability-registry/redact.js"; +import { isNonUsRegion } from "./regional-host.js"; const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event"; @@ -64,7 +65,7 @@ function errorProperties(error: unknown) { }; } -const REGION_RESTRICTED_FIELDS = new Set([ +const NON_US_REGION_FIELDS = new Set([ "mcp_version", "tool_name", "mcp_client", @@ -93,10 +94,9 @@ const REGION_RESTRICTED_FIELDS = new Set([ function forDataRegion( props: MCPEventPayload["event_properties"], ): MCPEventPayload["event_properties"] { - const region = globalConfig.BROWSERSTACK_GRR_REGION; - if (!region || region === "us") return props; + if (!isNonUsRegion()) return props; return Object.fromEntries( - Object.entries(props).filter(([key]) => REGION_RESTRICTED_FIELDS.has(key)), + Object.entries(props).filter(([key]) => NON_US_REGION_FIELDS.has(key)), ) as MCPEventPayload["event_properties"]; } diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 3d4c4702..973b7445 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -54,6 +54,11 @@ export function hostFor(kind: RegionalHostKind, region: GrrRegion): string { return HOSTS[kind][region]; } +export function isNonUsRegion(): boolean { + const region = appConfig.BROWSERSTACK_GRR_REGION; + return region !== undefined && region !== "us"; +} + export function getRegionalHost(kind: RegionalHostKind): string { if ( kind === "automationApi" && diff --git a/src/tools/automate-utils/list-session-ids.ts b/src/tools/automate-utils/list-session-ids.ts index 224af3a9..a0068d7b 100644 --- a/src/tools/automate-utils/list-session-ids.ts +++ b/src/tools/automate-utils/list-session-ids.ts @@ -53,14 +53,16 @@ interface SessionListItem { export function sessionsListUrl( sessionType: SessionType, buildId: string, - host?: string, + host = getRegionalHost( + sessionType === SessionType.Automate ? "automateApi" : "appAutomateUpload", + ), ): string { const encodedBuildId = encodeURIComponent(buildId); switch (sessionType) { case SessionType.Automate: - return `${host ?? "https://api.browserstack.com"}/automate/builds/${encodedBuildId}/sessions.json`; + return `${host}/automate/builds/${encodedBuildId}/sessions.json`; case SessionType.AppAutomate: - return `${host ?? "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 cbe03695..d39247c4 100644 --- a/src/tools/automate-utils/resolve-hashed-build-id.ts +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -30,7 +30,9 @@ export function isHashedBuildId(id: string): boolean { export function sessionDetailsUrl( sessionType: SessionType, sessionId: string, - host = "https://api.browserstack.com", + host = getRegionalHost( + sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi", + ), ): string { const encoded = encodeURIComponent(sessionId); switch (sessionType) { diff --git a/src/tools/rca-agent-utils/rca-data.ts b/src/tools/rca-agent-utils/rca-data.ts index 0d757a9f..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,7 +244,7 @@ export async function getRCAData( testIds: number[], authString: string, context?: ScanProgressContext, - host = "https://api-observability.browserstack.com", + host = getRegionalHost("observabilityApi"), ): Promise { const pollInterval = 5000; const timeout = 40000; From 374d6386c123df540e02bfec064e88af76e48125 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 15:16:32 +0530 Subject: [PATCH 13/26] feat(lib): route the app_accessibility capability by BROWSERSTACK_GRR_REGION Co-Authored-By: Claude Opus 5.5 --- src/tools/capability-registry/config.ts | 7 ++++--- tests/tools/capabilityRegistryRegion.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 6c7945ad..5b0cfbc9 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -53,9 +53,9 @@ 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. tra follows - * BROWSERSTACK_GRR_REGION through `lib/regional-host.ts`, like the hand-written Test Reporting - * tools. Once a product.yaml declares `base_urls`, its entry can go and nothing else changes. + * `lib/tm-base-url.ts` and shared with the hand-written Test Management tools. tra and + * app_accessibility follow BROWSERSTACK_GRR_REGION through `lib/regional-host.ts`, like the + * hand-written tools for the same products. Once a product.yaml declares `base_urls`, its entry can go and nothing else changes. */ const BUILTIN_DISCOVERY: Record< string, @@ -63,6 +63,7 @@ const BUILTIN_DISCOVERY: Record< > = { tm: getTMBaseURL, tra: async () => getRegionalHost("observabilityApi"), + app_accessibility: async () => getRegionalHost("appAccessibilityApi"), }; /** True when this product's host is account-specific and must be discovered per call. */ diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index 21100bff..a0f5c528 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -228,6 +228,16 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe(DECLARED); }); + it("sends app_accessibility to its EU host too", async () => { + process.env.BROWSERSTACK_GRR_REGION = "eu"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("app_accessibility", CONFIG, { + base_url: "https://api-app-accessibility.browserstack.com", + }), + ).toBe("https://api-app-accessibility-eu.browserstack.com"); + }); + it("lets an explicit override win over the region", async () => { process.env.BROWSERSTACK_GRR_REGION = "eu"; process.env.CAPABILITY_REGISTRY_BASE_URL_TRA = "https://tra-preprod.example"; From 53e52b02d8947dfe894dc768a69cd4fe26445d07 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 16:12:26 +0530 Subject: [PATCH 14/26] feat(lib): use the configured region for capability hosts before discovery With BROWSERSTACK_GRR_REGION set to eu or in, tra no longer probes its base_urls, and devicecontrol and mobiledevicecontrol (hub and api hosts) use the region's hosts. Unset keeps the existing discovery and declared hosts. Co-Authored-By: Claude Opus 5.5 --- src/lib/regional-host.ts | 10 +++++ src/tools/capability-registry/config.ts | 43 +++++++++++++++++--- tests/tools/capabilityRegistryRegion.test.ts | 38 +++++++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 1d8325ac..adafc2f1 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -26,6 +26,16 @@ const HOSTS = { eu: "hub-eu-only.browserstack.com", in: "hub-aps-only.browserstack.com", }, + automateHubUrl: { + us: "https://hub.browserstack.com", + eu: "https://hub-eu-only.browserstack.com", + in: "https://hub-aps-only.browserstack.com", + }, + appAutomateHubUrl: { + us: "https://hub-cloud.browserstack.com", + eu: "https://hub-eu-only.browserstack.com", + in: "https://hub-aps-only.browserstack.com", + }, accessibilityApi: { us: "https://api-accessibility.browserstack.com", eu: "https://api-accessibility-eu.browserstack.com", diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 5b0cfbc9..27c75dcb 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -18,7 +18,11 @@ 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 { getRegionalHost } from "../../lib/regional-host.js"; +import { + getRegionalHost, + isNonUsRegion, + RegionalHostKind, +} from "../../lib/regional-host.js"; /** * The environment this DEPLOYMENT points at, e.g. "preprod". @@ -53,19 +57,40 @@ 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. tra and - * app_accessibility follow BROWSERSTACK_GRR_REGION through `lib/regional-host.ts`, like the - * hand-written tools for the same products. Once a product.yaml declares `base_urls`, its 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, (config: BrowserStackConfig) => Promise > = { tm: getTMBaseURL, - tra: async () => getRegionalHost("observabilityApi"), - app_accessibility: async () => getRegionalHost("appAccessibilityApi"), }; +/** + * Hosts used when BROWSERSTACK_GRR_REGION is a non-US region, 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 } +> = { + tra: { base: "observabilityApi" }, + app_accessibility: { base: "appAccessibilityApi" }, + devicecontrol: { base: "automateHubUrl", hosts: { api: "automateApi" } }, + mobiledevicecontrol: { + base: "appAutomateHubUrl", + hosts: { api: "appAutomateUpload" }, + }, +}; + +function regionalHost(product: string, host?: string): string | undefined { + if (!isNonUsRegion()) return undefined; + const entry = REGIONAL_HOSTS[product]; + const kind = host ? entry?.hosts?.[host] : entry?.base; + return kind ? 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; @@ -134,6 +159,9 @@ function resolveNamedHost( ); } + const regional = regionalHost(product, host); + if (regional) return regional; + const declared = source?.hosts?.[host]; if (declared) return trim(declared); @@ -197,6 +225,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/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index a0f5c528..d4eefb25 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -238,6 +238,44 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { ).toBe("https://api-app-accessibility-eu.browserstack.com"); }); + it("does not probe the tra base_urls when a region is set", async () => { + process.env.BROWSERSTACK_GRR_REGION = "in"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + const transport = vi.fn(); + const source = { + base_urls: [ + DECLARED, + "https://api-observability-eu.browserstack.com", + "https://api-observability-in.browserstack.com", + ], + probe_path: "/api/v1/access", + }; + expect(await resolveBaseUrl("tra", CONFIG, source, transport as any)).toBe( + "https://api-observability-in.browserstack.com", + ); + expect(transport).not.toHaveBeenCalled(); + }); + + 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 under eu, and keeps the declared hosts when unset", + async (product, hub, api, euHub, euApi) => { + const source = { base_url: hub, hosts: { api } }; + process.env.BROWSERSTACK_GRR_REGION = "eu"; + let { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl(product, CONFIG, source)).toBe(euHub); + expect(await resolveBaseUrl(product, CONFIG, source, undefined, "api")).toBe(euApi); + + vi.resetModules(); + delete process.env.BROWSERSTACK_GRR_REGION; + ({ resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js")); + expect(await resolveBaseUrl(product, CONFIG, source)).toBe(hub); + expect(await resolveBaseUrl(product, CONFIG, source, undefined, "api")).toBe(api); + }, + ); + it("lets an explicit override win over the region", async () => { process.env.BROWSERSTACK_GRR_REGION = "eu"; process.env.CAPABILITY_REGISTRY_BASE_URL_TRA = "https://tra-preprod.example"; From 40928fbda6fa9923c6ff6b422ffc18e5f8b217c0 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 28 Oct 2026 21:02:28 +0530 Subject: [PATCH 15/26] fix(lib): record capability entity and product only when known Co-Authored-By: Claude Opus 5.5 --- src/tools/capability-registry/register.ts | 8 ++--- .../tools/capabilityRegistryTelemetry.test.ts | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) 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/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", {}); From 24266f6408021e79a3a86928622914b7e195dd44 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 11:33:36 +0530 Subject: [PATCH 16/26] fix(accessibility): route the scanner link and web_accessibility capability by region Co-Authored-By: Claude Opus 5.5 --- src/tools/accessiblity-utils/scanner.ts | 2 +- src/tools/capability-registry/config.ts | 1 + tests/tools/capabilityRegistryRegion.test.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tools/accessiblity-utils/scanner.ts b/src/tools/accessiblity-utils/scanner.ts index 3587b39b..204593ae 100644 --- a/src/tools/accessiblity-utils/scanner.ts +++ b/src/tools/accessiblity-utils/scanner.ts @@ -217,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 " + diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 27c75dcb..1ad0426d 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -77,6 +77,7 @@ const REGIONAL_HOSTS: Record< > = { tra: { base: "observabilityApi" }, app_accessibility: { base: "appAccessibilityApi" }, + web_accessibility: { base: "accessibilityApi" }, devicecontrol: { base: "automateHubUrl", hosts: { api: "automateApi" } }, mobiledevicecontrol: { base: "appAutomateHubUrl", diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index d4eefb25..024c4144 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -238,6 +238,16 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { ).toBe("https://api-app-accessibility-eu.browserstack.com"); }); + it("sends web_accessibility to its regional host", async () => { + process.env.BROWSERSTACK_GRR_REGION = "in"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("web_accessibility", CONFIG, { + base_url: "https://api-accessibility.browserstack.com", + }), + ).toBe("https://api-accessibility-in.browserstack.com"); + }); + it("does not probe the tra base_urls when a region is set", async () => { process.env.BROWSERSTACK_GRR_REGION = "in"; const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); From 30dafabac8a9bc4c06bfca059ee2e9047151e23b Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 11:58:24 +0530 Subject: [PATCH 17/26] fix(lib): address region routing review - Route the AI-TFA/RCA collaboration host by region - Compare host overrides by origin, so a trailing slash is not an override - Keep the Test Management probe for us, matching the other products - Apply the region to the tm capability at the same point as the others - Ignore the process-wide region in remote mode - Accept App Accessibility links from any region - One sessionApiHost helper instead of repeated host selection Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 5 +- src/lib/regional-host.ts | 52 +++++++++++++--- src/lib/tm-base-url.ts | 6 +- .../accessiblity-utils/app-report-fetcher.ts | 15 +++-- .../native-execution/appautomate.ts | 14 ++--- src/tools/automate-utils/fetch-screenshots.ts | 4 +- src/tools/automate-utils/list-session-ids.ts | 12 +--- .../automate-utils/resolve-hashed-build-id.ts | 14 +---- src/tools/capability-registry/config.ts | 3 +- src/tools/failurelogs-utils/video.ts | 6 +- src/tools/rca-agent.ts | 9 +-- src/tools/selfheal-utils/selfheal.ts | 6 +- src/tools/tfa-rca-utils/constants.ts | 6 +- tests/lib/regional-host.test.ts | 61 ++++++++++++++++++- tests/lib/tm-base-url.test.ts | 5 +- tests/tools/rcaAgent.test.ts | 2 - tests/tools/tfaRcaRegion.test.ts | 36 +++++++++++ 17 files changed, 185 insertions(+), 71 deletions(-) create mode 100644 tests/tools/tfaRcaRegion.test.ts diff --git a/src/config.ts b/src/config.ts index 7908c6f9..3e895485 100644 --- a/src/config.ts +++ b/src/config.ts @@ -126,7 +126,10 @@ const config = new Config( : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() || undefined, - parseGrrRegion(process.env.BROWSERSTACK_GRR_REGION), + // Process-wide, so ignored in remote (multi-tenant) mode, where it would apply to every tenant. + process.env.REMOTE_MCP === "true" + ? undefined + : parseGrrRegion(process.env.BROWSERSTACK_GRR_REGION), ); export default config; diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index adafc2f1..3978b3ce 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -1,4 +1,5 @@ import appConfig, { GrrRegion } from "../config.js"; +import { SessionType } from "./constants.js"; type Hosts = Record; @@ -16,7 +17,7 @@ const HOSTS = { }, automateApi: AUTOMATE_API, appAutomateApi: AUTOMATE_API, - appAutomateUpload: { + appAutomateCloud: { us: "https://api-cloud.browserstack.com", eu: AUTOMATE_API.eu, in: AUTOMATE_API.in, @@ -74,18 +75,55 @@ export function hostFor(kind: RegionalHostKind, region: GrrRegion): 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, + ); +} + export function isNonUsRegion(): boolean { const region = appConfig.BROWSERSTACK_GRR_REGION; return region !== undefined && region !== "us"; } +/** 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" && - appConfig.BROWSERSTACK_AUTOMATION_BASE_URL && - appConfig.BROWSERSTACK_AUTOMATION_BASE_URL !== HOSTS.automationApi.us - ) { - return appConfig.BROWSERSTACK_AUTOMATION_BASE_URL; + if (kind === "automationApi") { + const override = hostOverride( + appConfig.BROWSERSTACK_AUTOMATION_BASE_URL, + kind, + ); + if (override) return override; } return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us"); } diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index 136ba496..72721aa2 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -3,7 +3,7 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; import appConfig from "../config.js"; -import { getRegionalHost, hostFor } from "./regional-host.js"; +import { getRegionalHost, hostFor, isNonUsRegion } from "./regional-host.js"; // Probe order when BROWSERSTACK_GRR_REGION is not set. const TM_BASE_URLS = (["us", "eu", "in"] as const).map((r) => @@ -15,9 +15,9 @@ let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { - // A configured region is used as given, like every other product. Unset keeps the + // An EU/IN region is used as given, like every other product. Unset or `us` keeps the // probe below, so existing EU/IN accounts that never set the flag keep working. - if (appConfig.BROWSERSTACK_GRR_REGION) { + if (isNonUsRegion()) { return getRegionalHost("testManagement"); } diff --git a/src/tools/accessiblity-utils/app-report-fetcher.ts b/src/tools/accessiblity-utils/app-report-fetcher.ts index 16bdaa5b..58511489 100644 --- a/src/tools/accessiblity-utils/app-report-fetcher.ts +++ b/src/tools/accessiblity-utils/app-report-fetcher.ts @@ -3,7 +3,10 @@ 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 } from "../../lib/regional-host.js"; +import { + getRegionalHost, + isRegionalHostname, +} from "../../lib/regional-host.js"; /** * App Accessibility: the issues of a mobile app build run through App Automate with @@ -27,7 +30,7 @@ const BUILD_LIST_PAGE_SIZE = 30; /** A native build is found by name in the account's App Automate build list, newest first. */ const appAutomateBuildsUrl = () => - `${getRegionalHost("appAutomateUpload")}/app-automate/builds.json`; + `${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. */ @@ -654,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(appDashboard()).hostname) return undefined; + if (!isRegionalHostname("appAccessibilityDashboard", url.hostname)) + return undefined; return url.pathname.match(/^\/report\/([0-9a-f-]{36})\/?$/i)?.[1]; } @@ -722,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(appDashboard()).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 @@ -849,7 +854,7 @@ export class AppAccessibilityApi { ): Promise<{ project?: string; startedAt?: number } | undefined> { for (const framework of ["espresso", "xcuitest"]) { const resp = await apiClient.get({ - url: `${getRegionalHost("appAutomateUpload")}/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`, + url: `${getRegionalHost("appAutomateCloud")}/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`, headers: this.headers(), raise_error: false, }); diff --git a/src/tools/appautomate-utils/native-execution/appautomate.ts b/src/tools/appautomate-utils/native-execution/appautomate.ts index 16042266..6583bc11 100644 --- a/src/tools/appautomate-utils/native-execution/appautomate.ts +++ b/src/tools/appautomate-utils/native-execution/appautomate.ts @@ -157,7 +157,7 @@ export async function uploadApp( formData.append("file", fs.createReadStream(safePath)); const response = await apiClient.post({ - url: `${getRegionalHost("appAutomateUpload")}/app-automate/upload`, + url: `${getRegionalHost("appAutomateCloud")}/app-automate/upload`, headers: { ...formData.getHeaders(), Authorization: @@ -219,7 +219,7 @@ export async function uploadEspressoApp( ): Promise { return uploadFileToBrowserStack( appPath, - `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/app`, + `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/app`, "app_url", config, { allowedExtensions: [".apk", ".aab"] }, @@ -233,7 +233,7 @@ export async function uploadEspressoTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/test-suite`, + `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".apk"] }, @@ -247,7 +247,7 @@ export async function uploadXcuiApp( ): Promise { return uploadFileToBrowserStack( appPath, - `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/app`, + `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/app`, "app_url", config, { allowedExtensions: [".ipa"] }, @@ -261,7 +261,7 @@ export async function uploadXcuiTestSuite( ): Promise { return uploadFileToBrowserStack( testSuitePath, - `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/test-suite`, + `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/test-suite`, "test_suite_url", config, { allowedExtensions: [".zip"] }, @@ -303,7 +303,7 @@ export async function triggerEspressoBuild( "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); const response = await apiClient.post({ - url: `${getRegionalHost("appAutomateUpload")}/app-automate/espresso/v2/build`, + url: `${getRegionalHost("appAutomateCloud")}/app-automate/espresso/v2/build`, headers: { Authorization: authHeader, "Content-Type": "application/json", @@ -339,7 +339,7 @@ export async function triggerXcuiBuild( }; const response = await apiClient.post({ - url: `${getRegionalHost("appAutomateUpload")}/app-automate/xcuitest/v2/build`, + url: `${getRegionalHost("appAutomateCloud")}/app-automate/xcuitest/v2/build`, headers: { Authorization: "Basic " + diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 93a1286e..5b99fff0 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -4,7 +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 { getRegionalHost } from "../../lib/regional-host.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 @@ -20,7 +20,7 @@ async function extractScreenshotUrls( const auth = Buffer.from(authString).toString("base64"); const isAutomate = sessionType === SessionType.Automate; - const host = getRegionalHost(isAutomate ? "automateApi" : "appAutomateApi"); + const host = sessionApiHost(sessionType); const baseUrl = `${host}/${isAutomate ? "automate" : "app-automate"}`; const url = `${baseUrl}/sessions/${sessionId}/logs`; diff --git a/src/tools/automate-utils/list-session-ids.ts b/src/tools/automate-utils/list-session-ids.ts index a0068d7b..381527d9 100644 --- a/src/tools/automate-utils/list-session-ids.ts +++ b/src/tools/automate-utils/list-session-ids.ts @@ -54,7 +54,7 @@ export function sessionsListUrl( sessionType: SessionType, buildId: string, host = getRegionalHost( - sessionType === SessionType.Automate ? "automateApi" : "appAutomateUpload", + sessionType === SessionType.Automate ? "automateApi" : "appAutomateCloud", ), ): string { const encodedBuildId = encodeURIComponent(buildId); @@ -126,15 +126,7 @@ export async function listSessionIds( } const response = await apiClient.get({ - url: sessionsListUrl( - args.sessionType, - buildId, - getRegionalHost( - args.sessionType === SessionType.Automate - ? "automateApi" - : "appAutomateUpload", - ), - ), + url: sessionsListUrl(args.sessionType, buildId), headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, diff --git a/src/tools/automate-utils/resolve-hashed-build-id.ts b/src/tools/automate-utils/resolve-hashed-build-id.ts index d39247c4..9d538500 100644 --- a/src/tools/automate-utils/resolve-hashed-build-id.ts +++ b/src/tools/automate-utils/resolve-hashed-build-id.ts @@ -5,7 +5,7 @@ import { apiClient } from "../../lib/apiClient.js"; import logger from "../../logger.js"; import { extractTestIds } from "../rca-agent-utils/get-failed-test-id.js"; import { TestRun } from "../rca-agent-utils/types.js"; -import { getRegionalHost } from "../../lib/regional-host.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,9 +30,7 @@ export function isHashedBuildId(id: string): boolean { export function sessionDetailsUrl( sessionType: SessionType, sessionId: string, - host = getRegionalHost( - sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi", - ), + host = sessionApiHost(sessionType), ): string { const encoded = encodeURIComponent(sessionId); switch (sessionType) { @@ -61,13 +59,7 @@ export async function resolveBuildIdFromSession( const auth = Buffer.from(authString).toString("base64"); const response = await apiClient.get({ - url: sessionDetailsUrl( - sessionType, - sessionId, - getRegionalHost( - sessionType === SessionType.Automate ? "automateApi" : "appAutomateApi", - ), - ), + url: sessionDetailsUrl(sessionType, sessionId), headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}`, diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 1ad0426d..82068bc1 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -75,13 +75,14 @@ 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: "appAutomateUpload" }, + hosts: { api: "appAutomateCloud" }, }, }; diff --git a/src/tools/failurelogs-utils/video.ts b/src/tools/failurelogs-utils/video.ts index 9d10fbcc..0feb39a6 100644 --- a/src/tools/failurelogs-utils/video.ts +++ b/src/tools/failurelogs-utils/video.ts @@ -3,7 +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 { getRegionalHost } from "../../lib/regional-host.js"; +import { sessionApiHost } from "../../lib/regional-host.js"; export async function retrieveSessionVideo( sessionId: string, @@ -12,9 +12,7 @@ export async function retrieveSessionVideo( ): Promise { const product = sessionType === SessionType.AppAutomate ? "app-automate" : "automate"; - const host = getRegionalHost( - sessionType === SessionType.AppAutomate ? "appAutomateApi" : "automateApi", - ); + 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/rca-agent.ts b/src/tools/rca-agent.ts index f33a226a..7a7dc922 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -18,7 +18,6 @@ import { GET_BUILD_ID_PARAMS, LIST_TEST_IDS_PARAMS, } from "./rca-agent-utils/constants.js"; -import { getRegionalHost } from "../lib/regional-host.js"; // Tool function to fetch build ID export async function getBuildIdTool( @@ -115,12 +114,7 @@ export async function fetchRCADataTool( // Limit to first 3 test IDs for performance const testIds = args.testId; - const rcaData = await getRCAData( - testIds, - authString, - undefined, - getRegionalHost("observabilityApi"), - ); + const rcaData = await getRCAData(testIds, authString); const formattedData = formatRCAData(rcaData); @@ -166,7 +160,6 @@ export async function listTestIdsTool( authString, status, includeFailureDetail, - getRegionalHost("automationApi"), ); return { diff --git a/src/tools/selfheal-utils/selfheal.ts b/src/tools/selfheal-utils/selfheal.ts index b9781b85..7bd27da5 100644 --- a/src/tools/selfheal-utils/selfheal.ts +++ b/src/tools/selfheal-utils/selfheal.ts @@ -44,7 +44,7 @@ export interface SelfHealingReport { 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"; +import { getRegionalHost, sessionApiHost } from "../../lib/regional-host.js"; type SessionType = "automate" | "app-automate"; @@ -133,9 +133,7 @@ export async function getSelfHealSelectors( const auth = Buffer.from(authString).toString("base64"); const productPath = sessionType === "app-automate" ? "app-automate" : "automate"; - const host = getRegionalHost( - sessionType === "app-automate" ? "appAutomateApi" : "automateApi", - ); + const host = sessionApiHost(sessionType); const url = `${host}/${productPath}/sessions/${sessionId}/logs`; const response = await apiClient.get({ diff --git a/src/tools/tfa-rca-utils/constants.ts b/src/tools/tfa-rca-utils/constants.ts index 158c02a0..fab0a497 100644 --- a/src/tools/tfa-rca-utils/constants.ts +++ b/src/tools/tfa-rca-utils/constants.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import appConfig from "../../config.js"; +import { hostFor, hostOverride } 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", appConfig.BROWSERSTACK_GRR_REGION ?? "us") + ); } export function getO11yUiBaseUrl(): string { diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 3b4fcc98..8a10f76e 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -26,7 +26,7 @@ describe("getRegionalHost", () => { expect(getRegionalHost("automateApi")).toBe( "https://api.browserstack.com", ); - expect(getRegionalHost("appAutomateUpload")).toBe( + expect(getRegionalHost("appAutomateCloud")).toBe( "https://api-cloud.browserstack.com", ); expect(getRegionalHost("appAutomateHub")).toBe("hub.browserstack.com"); @@ -48,7 +48,7 @@ describe("getRegionalHost", () => { expect(getRegionalHost("appAutomateApi")).toBe( "https://api-eu-only.browserstack.com", ); - expect(getRegionalHost("appAutomateUpload")).toBe( + expect(getRegionalHost("appAutomateCloud")).toBe( "https://api-eu-only.browserstack.com", ); expect(getRegionalHost("appAutomateHub")).toBe( @@ -90,12 +90,60 @@ describe("getRegionalHost", () => { 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 configured region", async () => { + const { sessionApiHost } = await loadModule("in"); + expect(sessionApiHost("automate")).toBe( + "https://api-aps-only.browserstack.com", + ); + expect(sessionApiHost("app-automate")).toBe( + "https://api-aps-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", + "app-accessibility-in.browserstack.com", + ]) { + expect(isRegionalHostname("appAccessibilityDashboard", host)).toBe(true); + } + expect( + isRegionalHostname("appAccessibilityDashboard", "evil.example.com"), + ).toBe(false); + }); +}); + describe("BROWSERSTACK_GRR_REGION parsing", () => { const original = process.env.BROWSERSTACK_GRR_REGION; @@ -122,6 +170,15 @@ describe("BROWSERSTACK_GRR_REGION parsing", () => { expect((await loadConfig(value)).BROWSERSTACK_GRR_REGION).toBe(expected); }); + it("is ignored in remote mode, where one value would apply to every tenant", async () => { + process.env.REMOTE_MCP = "true"; + try { + expect((await loadConfig("eu")).BROWSERSTACK_GRR_REGION).toBeUndefined(); + } finally { + delete process.env.REMOTE_MCP; + } + }); + it("refuses an unknown region at startup instead of defaulting to US", async () => { await expect(loadConfig("uk")).rejects.toThrow( /Invalid BROWSERSTACK_GRR_REGION "uk"/, diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index 04553cfa..e13107ed 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -125,7 +125,6 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { } it.each([ - ["us", "https://test-management.browserstack.com"], ["eu", "https://test-management-eu.browserstack.com"], ["in", "https://test-management-in.browserstack.com"], ])("region %s uses its host directly, like every other product", async (region, url) => { @@ -134,8 +133,8 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { expect(apiClient.get).not.toHaveBeenCalled(); }); - it("unset keeps the US → EU → IN probe for accounts that never set the flag", async () => { - const { apiClient, getTMBaseURL } = await loadWithRegion(undefined); + it.each([undefined, "us"])("%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 }); diff --git a/tests/tools/rcaAgent.test.ts b/tests/tools/rcaAgent.test.ts index 55b82f96..9d00fdbf 100644 --- a/tests/tools/rcaAgent.test.ts +++ b/tests/tools/rcaAgent.test.ts @@ -106,7 +106,6 @@ describe("RCA Agent Tools", () => { "fake-user:fake-key", TestStatus.FAILED, undefined, - "https://api-automation.browserstack.com", ); }); @@ -129,7 +128,6 @@ describe("RCA Agent Tools", () => { "fake-user:fake-key", TestStatus.FAILED, true, - "https://api-automation.browserstack.com", ); const parsed = JSON.parse(result.content[0].text); expect(parsed[0].failure.category).toBe("Assertion"); diff --git a/tests/tools/tfaRcaRegion.test.ts b/tests/tools/tfaRcaRegion.test.ts new file mode 100644 index 00000000..e28f6ceb --- /dev/null +++ b/tests/tools/tfaRcaRegion.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest"; + +async function baseUrl(region: string | undefined, o11yBaseUrl: string) { + vi.resetModules(); + vi.doMock("../../src/config", () => ({ + __esModule: true, + default: { + BROWSERSTACK_GRR_REGION: region, + O11Y_TFA_RCA_BASE_URL: o11yBaseUrl, + }, + })); + const { getO11yBaseUrl } = await import( + "../../src/tools/tfa-rca-utils/constants" + ); + return getO11yBaseUrl(); +} + +const DEFAULT = "https://api-automation.browserstack.com"; + +describe("getO11yBaseUrl", () => { + it("keeps the US host when no region is set", async () => { + expect(await baseUrl(undefined, DEFAULT)).toBe(DEFAULT); + }); + + it.each([ + ["eu", "https://api-automation-eu.browserstack.com"], + ["in", "https://api-automation-in.browserstack.com"], + ])("follows the %s region", async (region, host) => { + expect(await baseUrl(region, DEFAULT)).toBe(host); + }); + + 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); + }); +}); From 0941eb0cce2b30928c1ec7b56d9e9cf1df112179 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 14:09:55 +0530 Subject: [PATCH 18/26] feat(lib): detect the account's region from BrowserStack auth Look up the account's region once per server config from a user profile token minted with the configured username and access key, and use it for routing, telemetry fields and the feedback tool. BROWSERSTACK_GRR_REGION is used when the lookup fails. - Each tool call runs in its account's region, so concurrent calls for different accounts route independently in hosted mode. - Telemetry outside a tool call waits for the lookup; when it fails and no region is configured, only usage fields are sent. - BrowserStackMcpServer.create() resolves the region before building the server, so the tool list and instructions match it. The local entry point uses it. - submitFeedback declines at call time when the account's region is EU or IN. Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 2 +- src/index.ts | 2 +- src/lib/account-region.ts | 76 +++++++++++ src/lib/instrumentation.ts | 30 +++- src/lib/region-context.ts | 41 ++++++ src/lib/regional-host.ts | 10 +- src/lib/tm-base-url.ts | 2 +- src/lib/tool-latency.ts | 26 ++++ src/server-factory.ts | 21 ++- src/tools/capability-registry/config.ts | 2 +- src/tools/feedback.ts | 7 + src/tools/tfa-rca-collaboration.ts | 3 + src/tools/tfa-rca-utils/constants.ts | 8 +- tests/lib/account-region-behaviour.test.ts | 152 +++++++++++++++++++++ tests/lib/account-region.test.ts | 108 +++++++++++++++ tests/setup.ts | 6 + 16 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 src/lib/account-region.ts create mode 100644 src/lib/region-context.ts create mode 100644 tests/lib/account-region-behaviour.test.ts create mode 100644 tests/lib/account-region.test.ts diff --git a/src/config.ts b/src/config.ts index 3e895485..8068b673 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,7 +42,7 @@ const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com"; // BROWSERSTACK_GRR_REGION: "us", "eu" or "in"; unset means not configured. Invalid values fail at startup. -const GRR_REGIONS = ["us", "eu", "in"] as const; +export const GRR_REGIONS = ["us", "eu", "in"] as const; export type GrrRegion = (typeof GRR_REGIONS)[number]; function parseGrrRegion(raw: string | undefined): GrrRegion | undefined { 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..a4b163c4 --- /dev/null +++ b/src/lib/account-region.ts @@ -0,0 +1,76 @@ +import logger from "../logger.js"; +import { GRR_REGIONS, GrrRegion } from "../config.js"; +import { BrowserStackConfig } from "./types.js"; +import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; +import { AccountRegion } 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; + +interface Lookup { + settled?: AccountRegion; + pending: Promise; +} + +// Keyed on the config object a server was built with, so an entry belongs to that one +// server and is collected with it; nothing is shared between servers or accounts. +const lookups = new WeakMap(); + +/** 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 region = payload?.user?.grr?.region; + if (region === undefined || region === null || region === "") return "us"; + return (GRR_REGIONS as readonly string[]).includes(region) + ? (region as GrrRegion) + : "unknown"; +} + +async function lookUp(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 "unknown"; + } +} + +/** + * The account's region, looked up once per config. Returned directly once known so + * callers that cannot wait (telemetry) stay synchronous; undefined without credentials. + */ +export function accountRegion( + config: BrowserStackConfig | undefined, +): AccountRegion | undefined | Promise { + if ( + !config?.["browserstack-username"] || + !config?.["browserstack-access-key"] + ) { + return undefined; + } + let lookup = lookups.get(config); + if (!lookup) { + const entry: Lookup = { + pending: lookUp(config).then((region) => (entry.settled = region)), + }; + lookup = entry; + lookups.set(config, entry); + } + return lookup.settled ?? lookup.pending; +} diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index af9dcfc5..c49c3a26 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -7,7 +7,12 @@ const packageJson = require("../../package.json"); import { apiClient } from "./apiClient.js"; import globalConfig from "../config.js"; import { redact } from "../tools/capability-registry/redact.js"; -import { isNonUsRegion } from "./regional-host.js"; +import { accountRegion } from "./account-region.js"; +import { + AccountRegion, + currentRegionScope, + isRestrictedRegion, +} from "./region-context.js"; const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event"; @@ -93,15 +98,34 @@ const NON_US_REGION_FIELDS = new Set([ function forDataRegion( props: MCPEventPayload["event_properties"], + account: AccountRegion | undefined, ): MCPEventPayload["event_properties"] { - if (!isNonUsRegion()) return props; + 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 { - event = { ...event, event_properties: forDataRegion(event.event_properties) }; + // 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 { + 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..9034183b --- /dev/null +++ b/src/lib/region-context.ts @@ -0,0 +1,41 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import appConfig, { GrrRegion } from "../config.js"; + +/** 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 when it is known, otherwise BROWSERSTACK_GRR_REGION. */ +export function regionOf( + account: AccountRegion | undefined, +): GrrRegion | undefined { + if (account && account !== "unknown") return account; + return appConfig.BROWSERSTACK_GRR_REGION; +} + +/** True when telemetry must be limited to usage fields; an unknown account counts. */ +export function isRestrictedRegion( + account: AccountRegion | undefined, +): boolean { + const region = regionOf(account); + if (region) return region !== "us"; + return account === "unknown"; +} diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 3978b3ce..b9ddb8ee 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -1,5 +1,6 @@ import appConfig, { GrrRegion } from "../config.js"; import { SessionType } from "./constants.js"; +import { currentRegionScope, regionOf } from "./region-context.js"; type Hosts = Record; @@ -85,8 +86,13 @@ export function isRegionalHostname( ); } +/** This call's region: the account's when known, otherwise BROWSERSTACK_GRR_REGION. */ +export function currentRegion(): GrrRegion | undefined { + return regionOf(currentRegionScope()?.account); +} + export function isNonUsRegion(): boolean { - const region = appConfig.BROWSERSTACK_GRR_REGION; + const region = currentRegion(); return region !== undefined && region !== "us"; } @@ -125,5 +131,5 @@ export function getRegionalHost(kind: RegionalHostKind): string { ); if (override) return override; } - return hostFor(kind, appConfig.BROWSERSTACK_GRR_REGION ?? "us"); + return hostFor(kind, currentRegion() ?? "us"); } diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index 72721aa2..b43c4df6 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -5,7 +5,7 @@ import { getBrowserStackAuth } from "./get-auth.js"; import appConfig from "../config.js"; import { getRegionalHost, hostFor, isNonUsRegion } from "./regional-host.js"; -// Probe order when BROWSERSTACK_GRR_REGION is not set. +// Probe order when no non-US region is known. const TM_BASE_URLS = (["us", "eu", "in"] as const).map((r) => hostFor("testManagement", r), ); 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 6af8b806..fb8ec9de 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -22,7 +22,12 @@ 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, feedbackEnabled } 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 for the + * process-wide BROWSERSTACK_GRR_REGION. + */ + 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", @@ -103,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/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 82068bc1..0c29a5da 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -68,7 +68,7 @@ const BUILTIN_DISCOVERY: Record< }; /** - * Hosts used when BROWSERSTACK_GRR_REGION is a non-US region, ahead of discovery and the + * Hosts used when the region is non-US, ahead of discovery and the * index's declared hosts. `hosts` covers the capabilities that name a host. */ const REGIONAL_HOSTS: Record< 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/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 fab0a497..d7479a3c 100644 --- a/src/tools/tfa-rca-utils/constants.ts +++ b/src/tools/tfa-rca-utils/constants.ts @@ -1,12 +1,16 @@ import { z } from "zod"; import appConfig from "../../config.js"; -import { hostFor, hostOverride } from "../../lib/regional-host.js"; +import { + currentRegion, + hostFor, + hostOverride, +} from "../../lib/regional-host.js"; export function getO11yBaseUrl(): string { return ( hostOverride(appConfig.O11Y_TFA_RCA_BASE_URL, "automationApi") ?? - hostFor("automationApi", appConfig.BROWSERSTACK_GRR_REGION ?? "us") + hostFor("automationApi", currentRegion() ?? "us") ); } diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts new file mode 100644 index 00000000..ceeca5ac --- /dev/null +++ b/tests/lib/account-region-behaviour.test.ts @@ -0,0 +1,152 @@ +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: {} }) }, +})); +vi.mock("../../src/config.js", async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + default: { ...actual.default, BROWSERSTACK_GRR_REGION: undefined }, + }; +}); + +import appConfig from "../../src/config.js"; +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; +} + +function setFlag(region: string | undefined) { + (appConfig as any).BROWSERSTACK_GRR_REGION = region; +} + +describe("the account's region", () => { + beforeEach(() => { + regions.clear(); + setFlag(undefined); + (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-in.browserstack.com", + ]); + }); + + it("wins over BROWSERSTACK_GRR_REGION when known", async () => { + regions.set("eu-user", "eu"); + setFlag("us"); + expect(await tmHostTool(configFor("eu-user"))()).toBe( + "https://test-management-eu.browserstack.com", + ); + }); + + it("falls back to BROWSERSTACK_GRR_REGION when the lookup failed", async () => { + regions.set("someone", "unknown"); + setFlag("in"); + expect(await tmHostTool(configFor("someone"))()).toBe( + "https://test-management-in.browserstack.com", + ); + }); + + it.each([ + ["eu", true], + ["in", true], + ["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("limits 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, + })), + ); + const row = rows().at(-1); + expect(row).toMatchObject({ tool_name: "listTestCases", outcome: "error_result" }); + expect(row).not.toHaveProperty("error_message"); + }); + + 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..f8b7930d --- /dev/null +++ b/tests/lib/account-region.test.ts @@ -0,0 +1,108 @@ +import { 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", +})); +vi.mock("../../src/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + accountRegion, + regionFromToken, +} from "../../src/lib/account-region.js"; + +const tokenWith = (user: unknown) => + `h.${Buffer.from(JSON.stringify({ user })).toString("base64url")}.s`; + +const config = () => ({ + "browserstack-username": "user", + "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("does not guess at a region it does not know", () => { + expect(regionFromToken(tokenWith({ grr: { region: "apac" } }))).toBe( + "unknown", + ); + }); +}); + +describe("accountRegion", () => { + beforeEach(() => { + mintCalls.length = 0; + }); + + 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())).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 config 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("looks up each config 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("answers unknown when the lookup fails", async () => { + mintImpl = async () => { + throw new Error("auth could not be reached"); + }; + expect(await accountRegion(config())).toBe("unknown"); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index f95a206c..f7ace18e 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: tests use BROWSERSTACK_GRR_REGION. +// tests/lib/account-region.test.ts unmocks it. +vi.mock("../src/lib/account-region.js", () => ({ + accountRegion: () => undefined, +})); From b3a677127f82db2606bf71a7e166268faca2dca5 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 14:46:24 +0530 Subject: [PATCH 19/26] refactor(lib): take the region only from the account The account's region from BrowserStack auth is now the only source, so BROWSERSTACK_GRR_REGION is removed. When the lookup fails, hosts keep their US defaults (Test Management still probes each region) and telemetry sends only usage fields. Co-Authored-By: Claude Opus 5.5 --- src/config.ts | 20 ------ src/lib/account-region.ts | 3 +- src/lib/region-context.ts | 13 ++-- src/lib/regional-host.ts | 6 +- src/server-factory.ts | 4 +- tests/lib/account-region-behaviour.test.ts | 27 +------- tests/lib/instrumentation-grr-region.test.ts | 51 ++++++++------- tests/lib/regional-host.test.ts | 65 +++++--------------- tests/lib/tm-base-url.test.ts | 13 ++-- tests/setup.ts | 4 +- tests/tools/capabilityRegistryRegion.test.ts | 61 +++++++++--------- tests/tools/feedback-region.test.ts | 39 +++++------- tests/tools/tfaRcaRegion.test.ts | 17 ++--- 13 files changed, 121 insertions(+), 202 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8068b673..7d7c51b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,21 +41,6 @@ const DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL = const DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL = "https://automation.browserstack.com"; -// BROWSERSTACK_GRR_REGION: "us", "eu" or "in"; unset means not configured. Invalid values fail at startup. -export const GRR_REGIONS = ["us", "eu", "in"] as const; -export type GrrRegion = (typeof GRR_REGIONS)[number]; - -function parseGrrRegion(raw: string | undefined): GrrRegion | undefined { - const value = (raw || "").trim().toLowerCase(); - if (!value) return undefined; - if ((GRR_REGIONS as readonly string[]).includes(value)) { - return value as GrrRegion; - } - throw new Error( - `Invalid BROWSERSTACK_GRR_REGION "${raw}". Use one of: ${GRR_REGIONS.join(", ")}.`, - ); -} - /** * USE_OWN_LOCAL_BINARY_PROCESS: * If true, the system will not start a new local binary process, but will use the user's own process. @@ -92,7 +77,6 @@ export class Config { * "deferred" | "elicit". Anything else is ignored. */ public readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined, - public readonly BROWSERSTACK_GRR_REGION?: GrrRegion, ) {} } @@ -126,10 +110,6 @@ const config = new Config( : undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() || undefined, - // Process-wide, so ignored in remote (multi-tenant) mode, where it would apply to every tenant. - process.env.REMOTE_MCP === "true" - ? undefined - : parseGrrRegion(process.env.BROWSERSTACK_GRR_REGION), ); export default config; diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts index a4b163c4..3cd033da 100644 --- a/src/lib/account-region.ts +++ b/src/lib/account-region.ts @@ -1,8 +1,7 @@ import logger from "../logger.js"; -import { GRR_REGIONS, GrrRegion } from "../config.js"; import { BrowserStackConfig } from "./types.js"; import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; -import { AccountRegion } from "./region-context.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"; diff --git a/src/lib/region-context.ts b/src/lib/region-context.ts index 9034183b..b59b6930 100644 --- a/src/lib/region-context.ts +++ b/src/lib/region-context.ts @@ -1,5 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import appConfig, { GrrRegion } from "../config.js"; + +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"; @@ -23,19 +25,16 @@ export function currentRegionScope(): RegionScope | undefined { return scope.getStore(); } -/** The account's region when it is known, otherwise BROWSERSTACK_GRR_REGION. */ +/** The account's region, or undefined when it is not known. */ export function regionOf( account: AccountRegion | undefined, ): GrrRegion | undefined { - if (account && account !== "unknown") return account; - return appConfig.BROWSERSTACK_GRR_REGION; + return account && account !== "unknown" ? account : undefined; } /** True when telemetry must be limited to usage fields; an unknown account counts. */ export function isRestrictedRegion( account: AccountRegion | undefined, ): boolean { - const region = regionOf(account); - if (region) return region !== "us"; - return account === "unknown"; + return account === "unknown" || (account !== undefined && account !== "us"); } diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index b9ddb8ee..0f8d0ec2 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -1,6 +1,6 @@ -import appConfig, { GrrRegion } from "../config.js"; +import appConfig from "../config.js"; import { SessionType } from "./constants.js"; -import { currentRegionScope, regionOf } from "./region-context.js"; +import { currentRegionScope, GrrRegion, regionOf } from "./region-context.js"; type Hosts = Record; @@ -86,7 +86,7 @@ export function isRegionalHostname( ); } -/** This call's region: the account's when known, otherwise BROWSERSTACK_GRR_REGION. */ +/** This call's account region; undefined outside a call or when the lookup failed. */ export function currentRegion(): GrrRegion | undefined { return regionOf(currentRegionScope()?.account); } diff --git a/src/server-factory.ts b/src/server-factory.ts index fb8ec9de..a3f9c5d6 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -42,8 +42,8 @@ export class BrowserStackMcpServer { /** * 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 for the - * process-wide BROWSERSTACK_GRR_REGION. + * The constructor alone still routes each call by region, but lists tools as for a + * US account. */ static async create( config: BrowserStackConfig, diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts index ceeca5ac..df4920c3 100644 --- a/tests/lib/account-region-behaviour.test.ts +++ b/tests/lib/account-region-behaviour.test.ts @@ -10,15 +10,6 @@ vi.mock("../../src/lib/account-region.js", () => ({ vi.mock("../../src/lib/apiClient.js", () => ({ apiClient: { post: vi.fn().mockResolvedValue({ status: 200, data: {} }) }, })); -vi.mock("../../src/config.js", async (importOriginal) => { - const actual: any = await importOriginal(); - return { - ...actual, - default: { ...actual.default, BROWSERSTACK_GRR_REGION: undefined }, - }; -}); - -import appConfig from "../../src/config.js"; import { apiClient } from "../../src/lib/apiClient.js"; import { getRegionalHost } from "../../src/lib/regional-host.js"; import { bindAccountRegion } from "../../src/lib/tool-latency.js"; @@ -49,14 +40,9 @@ function tmHostTool(config: ReturnType) { return tools.probe.handler as () => Promise; } -function setFlag(region: string | undefined) { - (appConfig as any).BROWSERSTACK_GRR_REGION = region; -} - describe("the account's region", () => { beforeEach(() => { regions.clear(); - setFlag(undefined); (apiClient.post as any).mockClear(); }); @@ -72,19 +58,10 @@ describe("the account's region", () => { ]); }); - it("wins over BROWSERSTACK_GRR_REGION when known", async () => { - regions.set("eu-user", "eu"); - setFlag("us"); - expect(await tmHostTool(configFor("eu-user"))()).toBe( - "https://test-management-eu.browserstack.com", - ); - }); - - it("falls back to BROWSERSTACK_GRR_REGION when the lookup failed", async () => { + it("keeps the US hosts when the lookup failed", async () => { regions.set("someone", "unknown"); - setFlag("in"); expect(await tmHostTool(configFor("someone"))()).toBe( - "https://test-management-in.browserstack.com", + "https://test-management.browserstack.com", ); }); diff --git a/tests/lib/instrumentation-grr-region.test.ts b/tests/lib/instrumentation-grr-region.test.ts index 8a5812dc..c79fc060 100644 --- a/tests/lib/instrumentation-grr-region.test.ts +++ b/tests/lib/instrumentation-grr-region.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { trackMCP, withToolCall } from "../../src/lib/instrumentation"; import { apiClient } from "../../src/lib/apiClient"; -import appConfig from "../../src/config"; +import { withAccountRegion } from "../../src/lib/region-context"; vi.mock("../../src/lib/apiClient", () => ({ apiClient: { post: vi.fn().mockResolvedValue({ status: 200, data: {} }) }, @@ -10,7 +10,7 @@ vi.mock("../../src/logger", () => ({ default: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); vi.mock("../../src/config", () => ({ - default: { REMOTE_MCP: false, BROWSERSTACK_GRR_REGION: undefined }, + default: { REMOTE_MCP: false }, })); const clientInfo = { name: "claude-code", version: "1.2.3" }; @@ -21,25 +21,22 @@ const config = { const lastRow = () => (apiClient.post as any).mock.calls.at(-1)[0].body.event_properties; -function setRegion(region: string | undefined) { - (appConfig as any).BROWSERSTACK_GRR_REGION = region; -} - -describe("telemetry under BROWSERSTACK_GRR_REGION", () => { +describe("telemetry by the account's region", () => { beforeEach(() => vi.clearAllMocks()); - it.each(["eu", "in"])( + it.each(["eu", "in", "unknown"])( "drops free text but keeps usage fields when the region is %s", (region) => { - setRegion(region); - 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", - }); + withAccountRegion(region as any, () => + 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", @@ -61,11 +58,12 @@ describe("telemetry under BROWSERSTACK_GRR_REGION", () => { ); it("drops the error text of a returned failure inside withToolCall", async () => { - setRegion("eu"); - await withToolCall("listTestCases", () => clientInfo, config, async () => ({ - content: [{ type: "text", text: "Project PR-1 not found" }], - isError: true, - })); + await withAccountRegion("eu", () => + 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"); @@ -74,10 +72,11 @@ describe("telemetry under BROWSERSTACK_GRR_REGION", () => { it.each([undefined, "us"])( "sends every field when the region is %s", (region) => { - setRegion(region); - trackMCP("searchCapability", clientInfo, new Error("boom"), config, { - search_query: "checkout runs", - }); + 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 index 8a10f76e..8653620a 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; +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. +// 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, @@ -10,16 +11,22 @@ async function loadModule( vi.resetModules(); vi.doMock("../../src/config", () => ({ __esModule: true, - default: { - BROWSERSTACK_GRR_REGION: region, - BROWSERSTACK_AUTOMATION_BASE_URL: automationBaseUrl, - }, + default: { BROWSERSTACK_AUTOMATION_BASE_URL: automationBaseUrl }, })); - return import("../../src/lib/regional-host"); + 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"])( + it.each([undefined, "us", "unknown"])( "keeps every host on its US default when the region is %s", async (region) => { const { getRegionalHost } = await loadModule(region); @@ -143,45 +150,3 @@ describe("isRegionalHostname", () => { ).toBe(false); }); }); - -describe("BROWSERSTACK_GRR_REGION parsing", () => { - const original = process.env.BROWSERSTACK_GRR_REGION; - - afterEach(() => { - if (original === undefined) delete process.env.BROWSERSTACK_GRR_REGION; - else process.env.BROWSERSTACK_GRR_REGION = original; - }); - - async function loadConfig(value: string | undefined) { - vi.resetModules(); - vi.doUnmock("../../src/config"); - if (value === undefined) delete process.env.BROWSERSTACK_GRR_REGION; - else process.env.BROWSERSTACK_GRR_REGION = value; - return (await import("../../src/config")).default; - } - - it.each([ - [undefined, undefined], - ["", undefined], - ["us", "us"], - [" EU ", "eu"], - ["in", "in"], - ])("reads %j as %s", async (value, expected) => { - expect((await loadConfig(value)).BROWSERSTACK_GRR_REGION).toBe(expected); - }); - - it("is ignored in remote mode, where one value would apply to every tenant", async () => { - process.env.REMOTE_MCP = "true"; - try { - expect((await loadConfig("eu")).BROWSERSTACK_GRR_REGION).toBeUndefined(); - } finally { - delete process.env.REMOTE_MCP; - } - }); - - it("refuses an unknown region at startup instead of defaulting to US", async () => { - await expect(loadConfig("uk")).rejects.toThrow( - /Invalid BROWSERSTACK_GRR_REGION "uk"/, - ); - }); -}); diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index e13107ed..235a668d 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -108,7 +108,7 @@ describe("getTMBaseURL — failure details", () => { }); }); -describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { +describe("getTMBaseURL — account region", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -117,11 +117,16 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { vi.resetModules(); vi.doMock("../../src/config", () => ({ __esModule: true, - default: { REMOTE_MCP: false, BROWSERSTACK_GRR_REGION: region }, + default: { REMOTE_MCP: false }, })); const apiClientMod = await import("../../src/lib/apiClient"); const tmMod = await import("../../src/lib/tm-base-url"); - return { apiClient: apiClientMod.apiClient, getTMBaseURL: tmMod.getTMBaseURL }; + const { withAccountRegion } = await import("../../src/lib/region-context"); + return { + apiClient: apiClientMod.apiClient, + getTMBaseURL: (config: typeof mockConfig) => + withAccountRegion(region as any, () => tmMod.getTMBaseURL(config)), + }; } it.each([ @@ -133,7 +138,7 @@ describe("getTMBaseURL — BROWSERSTACK_GRR_REGION", () => { expect(apiClient.get).not.toHaveBeenCalled(); }); - it.each([undefined, "us"])("%s keeps the US → EU → IN probe", async (region) => { + it.each([undefined, "us", "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 }) diff --git a/tests/setup.ts b/tests/setup.ts index f7ace18e..bdafb54c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -13,8 +13,8 @@ vi.mock("sharp", () => { return { default: sharp }; }); -// No account region lookups against BrowserStack auth: tests use BROWSERSTACK_GRR_REGION. -// tests/lib/account-region.test.ts unmocks it. +// 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 024c4144..0bd1423e 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -201,36 +201,43 @@ describe("multiple environments, the way Atlas defines them", () => { }); }); -describe("tra follows BROWSERSTACK_GRR_REGION", () => { +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.BROWSERSTACK_GRR_REGION; delete process.env.CAPABILITY_REGISTRY_BASE_URL_TRA; }); - it("sends an EU deployment to the EU host, though the index declares the US one", async () => { - process.env.BROWSERSTACK_GRR_REGION = "eu"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + 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("keeps the US host when the region is unset", async () => { - delete process.env.BROWSERSTACK_GRR_REGION; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe(DECLARED); - }); + it.each([undefined, "us", "unknown"])( + "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("sends app_accessibility to its EU host too", async () => { - process.env.BROWSERSTACK_GRR_REGION = "eu"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + const resolveBaseUrl = await resolveIn("eu"); expect( await resolveBaseUrl("app_accessibility", CONFIG, { base_url: "https://api-app-accessibility.browserstack.com", @@ -239,8 +246,7 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { }); it("sends web_accessibility to its regional host", async () => { - process.env.BROWSERSTACK_GRR_REGION = "in"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + const resolveBaseUrl = await resolveIn("in"); expect( await resolveBaseUrl("web_accessibility", CONFIG, { base_url: "https://api-accessibility.browserstack.com", @@ -248,9 +254,8 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { ).toBe("https://api-accessibility-in.browserstack.com"); }); - it("does not probe the tra base_urls when a region is set", async () => { - process.env.BROWSERSTACK_GRR_REGION = "in"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + it("does not probe the tra base_urls when the account's region is known", async () => { + const resolveBaseUrl = await resolveIn("in"); const transport = vi.fn(); const source = { base_urls: [ @@ -270,26 +275,22 @@ describe("tra follows BROWSERSTACK_GRR_REGION", () => { ["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 under eu, and keeps the declared hosts when unset", + "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 } }; - process.env.BROWSERSTACK_GRR_REGION = "eu"; - let { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl(product, CONFIG, source)).toBe(euHub); - expect(await resolveBaseUrl(product, CONFIG, source, undefined, "api")).toBe(euApi); - - vi.resetModules(); - delete process.env.BROWSERSTACK_GRR_REGION; - ({ resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js")); - expect(await resolveBaseUrl(product, CONFIG, source)).toBe(hub); - expect(await resolveBaseUrl(product, CONFIG, source, undefined, "api")).toBe(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.BROWSERSTACK_GRR_REGION = "eu"; process.env.CAPABILITY_REGISTRY_BASE_URL_TRA = "https://tra-preprod.example"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + const resolveBaseUrl = await resolveIn("eu"); expect(await resolveBaseUrl("tra", CONFIG, { base_url: DECLARED })).toBe( "https://tra-preprod.example", ); diff --git a/tests/tools/feedback-region.test.ts b/tests/tools/feedback-region.test.ts index 5a716cc9..ff0e67a0 100644 --- a/tests/tools/feedback-region.test.ts +++ b/tests/tools/feedback-region.test.ts @@ -1,24 +1,28 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; const CONFIG = { "browserstack-username": "u", "browserstack-access-key": "k", } as any; -const original = process.env.BROWSERSTACK_GRR_REGION; - async function load(region: string | undefined) { vi.resetModules(); - if (region === undefined) delete process.env.BROWSERSTACK_GRR_REGION; - else process.env.BROWSERSTACK_GRR_REGION = region; + 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 server = new BrowserStackMcpServer(CONFIG); + const { withAccountRegion } = await import("../../src/lib/region-context.js"); + const server = await BrowserStackMcpServer.create(CONFIG); const instructions: string = (server.getInstance().server as any)._instructions ?? ""; - return { server, instructions, withFeedbackHint, FEEDBACK_HINT }; + const hinted = (result: unknown) => + JSON.stringify( + withAccountRegion(region as any, () => withFeedbackHint("listBuildId", result)), + ).includes(FEEDBACK_HINT); + return { server, instructions, hinted }; } const failure = () => ({ @@ -26,35 +30,24 @@ const failure = () => ({ isError: true, }); -describe("submitFeedback by BROWSERSTACK_GRR_REGION", () => { - afterEach(() => { - if (original === undefined) delete process.env.BROWSERSTACK_GRR_REGION; - else process.env.BROWSERSTACK_GRR_REGION = original; - }); - +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, withFeedbackHint, FEEDBACK_HINT } = - await load(region); + const { server, instructions, hinted } = await load(region); expect(server.getTool("submitFeedback")).toBeUndefined(); expect(instructions).not.toContain("submitFeedback"); - expect(JSON.stringify(withFeedbackHint("listBuildId", failure()))).not.toContain( - FEEDBACK_HINT, - ); + expect(hinted(failure())).toBe(false); }, ); it.each([undefined, "us"])( "is registered, instructed and hinted when the region is %s", async (region) => { - const { server, instructions, withFeedbackHint, FEEDBACK_HINT } = - await load(region); + const { server, instructions, hinted } = await load(region); expect(server.getTool("submitFeedback")).toBeDefined(); expect(instructions).toContain("submitFeedback"); - expect(JSON.stringify(withFeedbackHint("listBuildId", failure()))).toContain( - FEEDBACK_HINT, - ); + expect(hinted(failure())).toBe(true); }, ); }); diff --git a/tests/tools/tfaRcaRegion.test.ts b/tests/tools/tfaRcaRegion.test.ts index e28f6ceb..4c5d20af 100644 --- a/tests/tools/tfaRcaRegion.test.ts +++ b/tests/tools/tfaRcaRegion.test.ts @@ -4,23 +4,24 @@ async function baseUrl(region: string | undefined, o11yBaseUrl: string) { vi.resetModules(); vi.doMock("../../src/config", () => ({ __esModule: true, - default: { - BROWSERSTACK_GRR_REGION: region, - O11Y_TFA_RCA_BASE_URL: o11yBaseUrl, - }, + default: { O11Y_TFA_RCA_BASE_URL: o11yBaseUrl }, })); const { getO11yBaseUrl } = await import( "../../src/tools/tfa-rca-utils/constants" ); - return getO11yBaseUrl(); + const { withAccountRegion } = await import("../../src/lib/region-context"); + return withAccountRegion(region as any, () => getO11yBaseUrl()); } const DEFAULT = "https://api-automation.browserstack.com"; describe("getO11yBaseUrl", () => { - it("keeps the US host when no region is set", async () => { - expect(await baseUrl(undefined, DEFAULT)).toBe(DEFAULT); - }); + it.each([undefined, "us", "unknown"])( + "keeps the US host when the region is %s", + async (region) => { + expect(await baseUrl(region, DEFAULT)).toBe(DEFAULT); + }, + ); it.each([ ["eu", "https://api-automation-eu.browserstack.com"], From 1efef0ddb37ac40d780fbe5e70149fe002f0f7b3 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 16:46:51 +0530 Subject: [PATCH 20/26] fix(lib): retry a failed account region lookup Keep only an answer from BrowserStack auth. After a failure the region is "unknown" for 30 seconds, then the next call looks up again, so a network blip no longer fixes an account to the default hosts for the life of the server. Also drop BROWSERSTACK_GRR_REGION from the install guide, since it was removed. Co-Authored-By: Claude Opus 5.5 --- llms-install.md | 1 - src/lib/account-region.ts | 43 +++++++++++++++++++++++-------- tests/lib/account-region.test.ts | 44 +++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/llms-install.md b/llms-install.md index 7be19979..cf1b8598 100644 --- a/llms-install.md +++ b/llms-install.md @@ -123,7 +123,6 @@ Optional environment variables for the local server: | --- | --- | | `MCP_UPLOAD_BASE_DIR` | Directory that file-upload tools (`uploadProductRequirementFile`, `takeAppScreenshot`, `runAppTestsOnBrowserStack`, `runAppLiveSession`) may read from. Uploads are restricted to this directory. | | `BROWSERSTACK_LOCAL_OPTION_` | Passes an option to the BrowserStack Local tunnel, e.g. `BROWSERSTACK_LOCAL_OPTION_PROXYHOST`. Only needed when testing sites behind a firewall or on localhost. | -| `BROWSERSTACK_GRR_REGION` | For accounts with Geo Region Restriction (GRR): `eu` or `in` sends Test Management, Automate, App Automate, Accessibility and Test Reporting calls to that region's endpoints. Leave unset (or `us`) otherwise. | ## Verify the install diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts index 3cd033da..b1cce147 100644 --- a/src/lib/account-region.ts +++ b/src/lib/account-region.ts @@ -6,10 +6,13 @@ 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 = 30_000; interface Lookup { settled?: AccountRegion; - pending: Promise; + pending?: Promise; + failedAt?: number; } // Keyed on the config object a server was built with, so an entry belongs to that one @@ -28,7 +31,10 @@ export function regionFromToken(token: string): AccountRegion { : "unknown"; } -async function lookUp(config: BrowserStackConfig): Promise { +/** The account's region, or null when BrowserStack auth could not answer. */ +async function lookUp( + config: BrowserStackConfig, +): Promise { try { const token = await mintCentralToken({ url: AUTH_TOKEN_URL, @@ -46,13 +52,15 @@ async function lookUp(config: BrowserStackConfig): Promise { logger.warn( `Could not look up the account's region: ${(error as Error)?.message}`, ); - return "unknown"; + return null; } } /** * The account's region, looked up once per config. Returned directly once known so * callers that cannot wait (telemetry) stay synchronous; undefined without credentials. + * Only an answer is kept: after a failure this is "unknown" for RETRY_AFTER_MS, and the + * next call after that looks up again. */ export function accountRegion( config: BrowserStackConfig | undefined, @@ -63,13 +71,26 @@ export function accountRegion( ) { return undefined; } - let lookup = lookups.get(config); - if (!lookup) { - const entry: Lookup = { - pending: lookUp(config).then((region) => (entry.settled = region)), - }; - lookup = entry; - lookups.set(config, entry); + const lookup = lookups.get(config); + if (lookup?.settled) return lookup.settled; + if (lookup?.pending) return lookup.pending; + if ( + lookup?.failedAt !== undefined && + Date.now() - lookup.failedAt < RETRY_AFTER_MS + ) { + return "unknown"; } - return lookup.settled ?? lookup.pending; + + const entry: Lookup = {}; + entry.pending = lookUp(config).then((region) => { + entry.pending = undefined; + if (region === null) { + entry.failedAt = Date.now(); + return "unknown"; + } + entry.settled = region; + return region; + }); + lookups.set(config, entry); + return entry.pending; } diff --git a/tests/lib/account-region.test.ts b/tests/lib/account-region.test.ts index f8b7930d..06e5c941 100644 --- a/tests/lib/account-region.test.ts +++ b/tests/lib/account-region.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.unmock("../../src/lib/account-region.js"); @@ -20,6 +20,7 @@ vi.mock("../../src/logger.js", () => ({ import { accountRegion, regionFromToken, + RETRY_AFTER_MS, } from "../../src/lib/account-region.js"; const tokenWith = (user: unknown) => @@ -99,10 +100,51 @@ describe("accountRegion", () => { 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("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("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); + }); + }); }); From df5fef1e1b0e3037cb34f4d9ac2b7bb6925f2941 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 17:45:40 +0530 Subject: [PATCH 21/26] feat(lib): send no telemetry for EU and IN accounts Telemetry is now off for accounts in the EU or IN region. When the account's region can't be looked up, rows still carry usage fields only. Co-Authored-By: Claude Opus 5.5 --- src/lib/instrumentation.ts | 2 + src/lib/region-context.ts | 6 ++ tests/lib/account-region-behaviour.test.ts | 18 +++-- tests/lib/instrumentation-grr-region.test.ts | 77 ++++++++++++-------- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index c49c3a26..6aa88f01 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -12,6 +12,7 @@ import { AccountRegion, currentRegionScope, isRestrictedRegion, + isTelemetryOff, } from "./region-context.js"; const INSTRUMENTATION_ENDPOINT = "https://api.browserstack.com/sdk/v1/event"; @@ -122,6 +123,7 @@ function post( config: any, account: AccountRegion | undefined, ): void { + if (isTelemetryOff(account)) return; event = { ...event, event_properties: forDataRegion(event.event_properties, account), diff --git a/src/lib/region-context.ts b/src/lib/region-context.ts index b59b6930..45817559 100644 --- a/src/lib/region-context.ts +++ b/src/lib/region-context.ts @@ -32,6 +32,12 @@ export function regionOf( 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, diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts index df4920c3..6d404318 100644 --- a/tests/lib/account-region-behaviour.test.ts +++ b/tests/lib/account-region-behaviour.test.ts @@ -65,9 +65,17 @@ describe("the 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([ - ["eu", true], - ["in", true], ["unknown", true], ["us", false], ])( @@ -85,16 +93,14 @@ describe("the account's region", () => { }, ); - it("limits telemetry inside a tool call for an EU account", async () => { + 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, })), ); - const row = rows().at(-1); - expect(row).toMatchObject({ tool_name: "listTestCases", outcome: "error_result" }); - expect(row).not.toHaveProperty("error_message"); + expect(rows()).toHaveLength(0); }); it.each(["eu", "in"])( diff --git a/tests/lib/instrumentation-grr-region.test.ts b/tests/lib/instrumentation-grr-region.test.ts index c79fc060..a78782f9 100644 --- a/tests/lib/instrumentation-grr-region.test.ts +++ b/tests/lib/instrumentation-grr-region.test.ts @@ -21,44 +21,57 @@ const config = { 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", "unknown"])( - "drops free text but keeps usage fields when the region is %s", - (region) => { - withAccountRegion(region as any, () => - 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", + 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, - }); - for (const field of [ - "error_message", - "change_summary", - "search_query", - "feedback_message", - "feedback_category", - ]) - expect(row).not.toHaveProperty(field); - }, - ); + 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 inside withToolCall", async () => { - await withAccountRegion("eu", () => + 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, From f09c3c53a47da67cbd0af9dfab481c0c3be918ef Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 18:22:57 +0530 Subject: [PATCH 22/26] refactor(lib): route only EU accounts to regional hosts IN accounts keep the default hosts, as before this change, and Test Management and capability discovery still probe for them. They are still treated as region-restricted for telemetry and submitFeedback. Co-Authored-By: Claude Opus 5.5 --- src/lib/regional-host.ts | 32 +++++++++-------- src/lib/tm-base-url.ts | 18 +++++----- src/tools/capability-registry/config.ts | 6 ++-- src/tools/tfa-rca-utils/constants.ts | 4 +-- tests/lib/account-region-behaviour.test.ts | 2 +- tests/lib/regional-host.test.ts | 20 +++++------ tests/lib/tm-base-url.test.ts | 13 ++++--- tests/tools/capabilityRegistryRegion.test.ts | 36 ++++++++++++-------- tests/tools/tfaRcaRegion.test.ts | 11 +++--- 9 files changed, 75 insertions(+), 67 deletions(-) diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index 0f8d0ec2..b83b3427 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -2,77 +2,68 @@ import appConfig from "../config.js"; import { SessionType } from "./constants.js"; import { currentRegionScope, GrrRegion, regionOf } from "./region-context.js"; -type Hosts = Record; +/** 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", - in: "https://api-aps-only.browserstack.com", }; const HOSTS = { testManagement: { us: "https://test-management.browserstack.com", eu: "https://test-management-eu.browserstack.com", - in: "https://test-management-in.browserstack.com", }, automateApi: AUTOMATE_API, appAutomateApi: AUTOMATE_API, appAutomateCloud: { us: "https://api-cloud.browserstack.com", eu: AUTOMATE_API.eu, - in: AUTOMATE_API.in, }, appAutomateHub: { us: "hub.browserstack.com", eu: "hub-eu-only.browserstack.com", - in: "hub-aps-only.browserstack.com", }, automateHubUrl: { us: "https://hub.browserstack.com", eu: "https://hub-eu-only.browserstack.com", - in: "https://hub-aps-only.browserstack.com", }, appAutomateHubUrl: { us: "https://hub-cloud.browserstack.com", eu: "https://hub-eu-only.browserstack.com", - in: "https://hub-aps-only.browserstack.com", }, accessibilityApi: { us: "https://api-accessibility.browserstack.com", eu: "https://api-accessibility-eu.browserstack.com", - in: "https://api-accessibility-in.browserstack.com", }, accessibilityDashboard: { us: "https://accessibility.browserstack.com", eu: "https://accessibility-eu.browserstack.com", - in: "https://accessibility-in.browserstack.com", }, appAccessibilityApi: { us: "https://api-app-accessibility.browserstack.com", eu: "https://api-app-accessibility-eu.browserstack.com", - in: "https://api-app-accessibility-in.browserstack.com", }, appAccessibilityDashboard: { us: "https://app-accessibility.browserstack.com", eu: "https://app-accessibility-eu.browserstack.com", - in: "https://app-accessibility-in.browserstack.com", }, observabilityApi: { us: "https://api-observability.browserstack.com", eu: "https://api-observability-eu.browserstack.com", - in: "https://api-observability-in.browserstack.com", }, automationApi: { us: "https://api-automation.browserstack.com", eu: "https://api-automation-eu.browserstack.com", - in: "https://api-automation-in.browserstack.com", }, } as const satisfies Record; export type RegionalHostKind = keyof typeof HOSTS; -export function hostFor(kind: RegionalHostKind, region: GrrRegion): string { +export function hostFor(kind: RegionalHostKind, region: RoutedRegion): string { return HOSTS[kind][region]; } @@ -91,11 +82,22 @@ export function currentRegion(): GrrRegion | undefined { return regionOf(currentRegionScope()?.account); } +/** True for an account in a region other than the US, whether or not it is routed here. */ export function isNonUsRegion(): boolean { const region = currentRegion(); return region !== undefined && region !== "us"; } +/** The region whose hosts this call uses. */ +export function routedRegion(): RoutedRegion { + return currentRegion() === "eu" ? "eu" : "us"; +} + +/** True when this call goes to regional hosts rather than the defaults. */ +export function usesRegionalHosts(): boolean { + return routedRegion() !== "us"; +} + /** REST host for a session's product: App Automate or Automate. */ export function sessionApiHost(sessionType: string): string { return getRegionalHost( @@ -131,5 +133,5 @@ export function getRegionalHost(kind: RegionalHostKind): string { ); if (override) return override; } - return hostFor(kind, currentRegion() ?? "us"); + return hostFor(kind, routedRegion()); } diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index b43c4df6..4fdc0089 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -3,21 +3,23 @@ import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; import appConfig from "../config.js"; -import { getRegionalHost, hostFor, isNonUsRegion } from "./regional-host.js"; +import { getRegionalHost, usesRegionalHosts } from "./regional-host.js"; -// Probe order when no non-US region is known. -const TM_BASE_URLS = (["us", "eu", "in"] as const).map((r) => - hostFor("testManagement", r), -); +// 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", + "https://test-management-in.browserstack.com", +] as const; let cachedBaseUrl: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { - // An EU/IN region is used as given, like every other product. Unset or `us` keeps the - // probe below, so existing EU/IN accounts that never set the flag keep working. - if (isNonUsRegion()) { + // A routed region is used as given, like every other product. Any other account keeps + // the probe below. + if (usesRegionalHosts()) { return getRegionalHost("testManagement"); } diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 0c29a5da..e70d2c65 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -20,7 +20,7 @@ import { Credentials, Transport, fetchTransport } from "./egress.js"; import { FLAT_SUFFIX, INDEX_FILE, InvocationError } from "./index-loader.js"; import { getRegionalHost, - isNonUsRegion, + usesRegionalHosts, RegionalHostKind, } from "../../lib/regional-host.js"; @@ -68,7 +68,7 @@ const BUILTIN_DISCOVERY: Record< }; /** - * Hosts used when the region is non-US, ahead of discovery and the + * 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< @@ -87,7 +87,7 @@ const REGIONAL_HOSTS: Record< }; function regionalHost(product: string, host?: string): string | undefined { - if (!isNonUsRegion()) return undefined; + if (!usesRegionalHosts()) return undefined; const entry = REGIONAL_HOSTS[product]; const kind = host ? entry?.hosts?.[host] : entry?.base; return kind ? getRegionalHost(kind) : undefined; diff --git a/src/tools/tfa-rca-utils/constants.ts b/src/tools/tfa-rca-utils/constants.ts index d7479a3c..4abc5351 100644 --- a/src/tools/tfa-rca-utils/constants.ts +++ b/src/tools/tfa-rca-utils/constants.ts @@ -2,15 +2,15 @@ import { z } from "zod"; import appConfig from "../../config.js"; import { - currentRegion, hostFor, hostOverride, + routedRegion, } from "../../lib/regional-host.js"; export function getO11yBaseUrl(): string { return ( hostOverride(appConfig.O11Y_TFA_RCA_BASE_URL, "automationApi") ?? - hostFor("automationApi", currentRegion() ?? "us") + hostFor("automationApi", routedRegion()) ); } diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts index 6d404318..564f3a7c 100644 --- a/tests/lib/account-region-behaviour.test.ts +++ b/tests/lib/account-region-behaviour.test.ts @@ -54,7 +54,7 @@ describe("the account's region", () => { expect(hosts).toEqual([ "https://test-management-eu.browserstack.com", "https://test-management.browserstack.com", - "https://test-management-in.browserstack.com", + "https://test-management.browserstack.com", ]); }); diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 8653620a..3049dbfe 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -81,13 +81,12 @@ describe("getRegionalHost", () => { ); }); - it("routes Automate to api-aps-only when the region is in", async () => { + it("keeps the default hosts for a region it does not route", async () => { const { getRegionalHost } = await loadModule("in"); - expect(getRegionalHost("automateApi")).toBe( - "https://api-aps-only.browserstack.com", - ); - expect(getRegionalHost("appAutomateHub")).toBe( - "hub-aps-only.browserstack.com", + expect(getRegionalHost("automateApi")).toBe("https://api.browserstack.com"); + expect(getRegionalHost("appAutomateHub")).toBe("hub.browserstack.com"); + expect(getRegionalHost("testManagement")).toBe( + "https://test-management.browserstack.com", ); }); @@ -124,13 +123,13 @@ describe("getRegionalHost", () => { }); describe("sessionApiHost", () => { - it("picks the session's product host in the configured region", async () => { - const { sessionApiHost } = await loadModule("in"); + it("picks the session's product host in the account's region", async () => { + const { sessionApiHost } = await loadModule("eu"); expect(sessionApiHost("automate")).toBe( - "https://api-aps-only.browserstack.com", + "https://api-eu-only.browserstack.com", ); expect(sessionApiHost("app-automate")).toBe( - "https://api-aps-only.browserstack.com", + "https://api-eu-only.browserstack.com", ); }); }); @@ -141,7 +140,6 @@ describe("isRegionalHostname", () => { for (const host of [ "app-accessibility.browserstack.com", "app-accessibility-eu.browserstack.com", - "app-accessibility-in.browserstack.com", ]) { expect(isRegionalHostname("appAccessibilityDashboard", host)).toBe(true); } diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index 235a668d..88e354f9 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -129,16 +129,15 @@ describe("getTMBaseURL — account region", () => { }; } - it.each([ - ["eu", "https://test-management-eu.browserstack.com"], - ["in", "https://test-management-in.browserstack.com"], - ])("region %s uses its host directly, like every other product", async (region, url) => { - const { apiClient, getTMBaseURL } = await loadWithRegion(region); - await expect(getTMBaseURL(mockConfig)).resolves.toBe(url); + 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", "unknown"])("%s keeps the US → EU → IN probe", async (region) => { + 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 }) diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index 0bd1423e..e76446f0 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -246,31 +246,39 @@ describe("products follow the account's region", () => { }); it("sends web_accessibility to its regional host", async () => { - const resolveBaseUrl = await resolveIn("in"); + const resolveBaseUrl = await resolveIn("eu"); expect( await resolveBaseUrl("web_accessibility", CONFIG, { base_url: "https://api-accessibility.browserstack.com", }), - ).toBe("https://api-accessibility-in.browserstack.com"); + ).toBe("https://api-accessibility-eu.browserstack.com"); }); - it("does not probe the tra base_urls when the account's region is known", async () => { - const resolveBaseUrl = await resolveIn("in"); - const transport = vi.fn(); - const source = { - base_urls: [ - DECLARED, - "https://api-observability-eu.browserstack.com", - "https://api-observability-in.browserstack.com", - ], - probe_path: "/api/v1/access", - }; - expect(await resolveBaseUrl("tra", CONFIG, source, transport as any)).toBe( + 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"], diff --git a/tests/tools/tfaRcaRegion.test.ts b/tests/tools/tfaRcaRegion.test.ts index 4c5d20af..708a00e3 100644 --- a/tests/tools/tfaRcaRegion.test.ts +++ b/tests/tools/tfaRcaRegion.test.ts @@ -16,18 +16,17 @@ async function baseUrl(region: string | undefined, o11yBaseUrl: string) { const DEFAULT = "https://api-automation.browserstack.com"; describe("getO11yBaseUrl", () => { - it.each([undefined, "us", "unknown"])( + it.each([undefined, "us", "in", "unknown"])( "keeps the US host when the region is %s", async (region) => { expect(await baseUrl(region, DEFAULT)).toBe(DEFAULT); }, ); - it.each([ - ["eu", "https://api-automation-eu.browserstack.com"], - ["in", "https://api-automation-in.browserstack.com"], - ])("follows the %s region", async (region, host) => { - expect(await baseUrl(region, DEFAULT)).toBe(host); + 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 () => { From 4eabd41bf36b85d440aa5ba9576995aba4efb849 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 21:46:43 +0530 Subject: [PATCH 23/26] fix(lib): refuse to pick a host when the account's region is unknown Calls that need the account's region now return an error asking the user to retry instead of using the default hosts. Test Management still finds the region by probing. submitFeedback is also unavailable in this case. Co-Authored-By: Claude Opus 5.5 --- src/lib/feedback-hint.ts | 4 ++-- src/lib/regional-host.ts | 25 +++++++++++++++----- src/tools/capability-registry/config.ts | 7 ++++-- tests/lib/account-region-behaviour.test.ts | 6 ++--- tests/lib/regional-host.test.ts | 11 ++++++++- tests/tools/capabilityRegistryRegion.test.ts | 16 ++++++++++++- tests/tools/feedback-region.test.ts | 2 +- tests/tools/tfaRcaRegion.test.ts | 8 ++++++- 8 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/lib/feedback-hint.ts b/src/lib/feedback-hint.ts index b48021b8..bf6f66d6 100644 --- a/src/lib/feedback-hint.ts +++ b/src/lib/feedback-hint.ts @@ -1,7 +1,7 @@ -import { isNonUsRegion } from "./regional-host.js"; +import { currentRegionScope, isRestrictedRegion } from "./region-context.js"; export function feedbackEnabled(): boolean { - return !isNonUsRegion(); + return !isRestrictedRegion(currentRegionScope()?.account); } export const FEEDBACK_INSTRUCTION = diff --git a/src/lib/regional-host.ts b/src/lib/regional-host.ts index b83b3427..b78fad07 100644 --- a/src/lib/regional-host.ts +++ b/src/lib/regional-host.ts @@ -82,20 +82,33 @@ export function currentRegion(): GrrRegion | undefined { return regionOf(currentRegionScope()?.account); } -/** True for an account in a region other than the US, whether or not it is routed here. */ -export function isNonUsRegion(): boolean { - const region = currentRegion(); - return region !== undefined && region !== "us"; +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. */ +/** 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 routedRegion() !== "us"; + return currentRegion() === "eu"; } /** REST host for a session's product: App Automate or Automate. */ diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index e70d2c65..6c59b61d 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -19,6 +19,7 @@ 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, @@ -87,10 +88,12 @@ const REGIONAL_HOSTS: Record< }; function regionalHost(product: string, host?: string): string | undefined { - if (!usesRegionalHosts()) return undefined; const entry = REGIONAL_HOSTS[product]; const kind = host ? entry?.hosts?.[host] : entry?.base; - return kind ? getRegionalHost(kind) : undefined; + 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. */ diff --git a/tests/lib/account-region-behaviour.test.ts b/tests/lib/account-region-behaviour.test.ts index 564f3a7c..8973f0a0 100644 --- a/tests/lib/account-region-behaviour.test.ts +++ b/tests/lib/account-region-behaviour.test.ts @@ -58,10 +58,10 @@ describe("the account's region", () => { ]); }); - it("keeps the US hosts when the lookup failed", async () => { + it("refuses to pick a host when the lookup failed", async () => { regions.set("someone", "unknown"); - expect(await tmHostTool(configFor("someone"))()).toBe( - "https://test-management.browserstack.com", + await expect(tmHostTool(configFor("someone"))()).rejects.toThrow( + /Could not confirm your BrowserStack account's region/, ); }); diff --git a/tests/lib/regional-host.test.ts b/tests/lib/regional-host.test.ts index 3049dbfe..87428a9c 100644 --- a/tests/lib/regional-host.test.ts +++ b/tests/lib/regional-host.test.ts @@ -26,7 +26,7 @@ async function loadModule( } describe("getRegionalHost", () => { - it.each([undefined, "us", "unknown"])( + it.each([undefined, "us"])( "keeps every host on its US default when the region is %s", async (region) => { const { getRegionalHost } = await loadModule(region); @@ -47,6 +47,15 @@ describe("getRegionalHost", () => { }, ); + 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( diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index e76446f0..6bd89a01 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -228,7 +228,7 @@ describe("products follow the account's region", () => { ); }); - it.each([undefined, "us", "unknown"])( + it.each([undefined, "us"])( "keeps the US host when the region is %s", async (region) => { const resolveBaseUrl = await resolveIn(region); @@ -236,6 +236,20 @@ describe("products follow the account's region", () => { }, ); + 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( diff --git a/tests/tools/feedback-region.test.ts b/tests/tools/feedback-region.test.ts index ff0e67a0..0478ea14 100644 --- a/tests/tools/feedback-region.test.ts +++ b/tests/tools/feedback-region.test.ts @@ -31,7 +31,7 @@ const failure = () => ({ }); describe("submitFeedback by the account's region", () => { - it.each(["eu", "in"])( + it.each(["eu", "in", "unknown"])( "is not registered, instructed or hinted when the region is %s", async (region) => { const { server, instructions, hinted } = await load(region); diff --git a/tests/tools/tfaRcaRegion.test.ts b/tests/tools/tfaRcaRegion.test.ts index 708a00e3..66ac48f5 100644 --- a/tests/tools/tfaRcaRegion.test.ts +++ b/tests/tools/tfaRcaRegion.test.ts @@ -16,13 +16,19 @@ async function baseUrl(region: string | undefined, o11yBaseUrl: string) { const DEFAULT = "https://api-automation.browserstack.com"; describe("getO11yBaseUrl", () => { - it.each([undefined, "us", "in", "unknown"])( + 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", From 18512312051cf6b3bed22110fb67a827cc101c71 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 29 Oct 2026 21:46:43 +0530 Subject: [PATCH 24/26] perf(lib): share the region lookup between servers for one account Servers built per request for the same account now reuse one lookup. The region is kept for an hour, a failed lookup is retried after 5 seconds, and region values are matched in any case. Co-Authored-By: Claude Opus 5.5 --- src/lib/account-region.ts | 50 ++++++++++++++++++++++++-------- tests/lib/account-region.test.ts | 43 +++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts index b1cce147..a849e193 100644 --- a/src/lib/account-region.ts +++ b/src/lib/account-region.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import logger from "../logger.js"; import { BrowserStackConfig } from "./types.js"; import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; @@ -7,25 +8,46 @@ 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 = 30_000; +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; +const MAX_ENTRIES = 1_000; interface Lookup { settled?: AccountRegion; + settledAt?: number; pending?: Promise; failedAt?: number; } -// Keyed on the config object a server was built with, so an entry belongs to that one -// server and is collected with it; nothing is shared between servers or accounts. -const lookups = new WeakMap(); +// 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 region = payload?.user?.grr?.region; - if (region === undefined || region === null || region === "") return "us"; + 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"; @@ -57,9 +79,9 @@ async function lookUp( } /** - * The account's region, looked up once per config. Returned directly once known so - * callers that cannot wait (telemetry) stay synchronous; undefined without credentials. - * Only an answer is kept: after a failure this is "unknown" for RETRY_AFTER_MS, and the + * The account's region, looked up once per account and kept for REGION_TTL_MS. Returned + * directly once known so callers that cannot wait (telemetry) stay synchronous; undefined + * without credentials. After a failure this is "unknown" for RETRY_AFTER_MS, and the * next call after that looks up again. */ export function accountRegion( @@ -71,8 +93,11 @@ export function accountRegion( ) { return undefined; } - const lookup = lookups.get(config); - if (lookup?.settled) return lookup.settled; + const key = keyFor(config); + const lookup = lookups.get(key); + if (lookup?.settled && Date.now() - lookup.settledAt! < REGION_TTL_MS) { + return lookup.settled; + } if (lookup?.pending) return lookup.pending; if ( lookup?.failedAt !== undefined && @@ -89,8 +114,9 @@ export function accountRegion( return "unknown"; } entry.settled = region; + entry.settledAt = Date.now(); return region; }); - lookups.set(config, entry); + remember(key, entry); return entry.pending; } diff --git a/tests/lib/account-region.test.ts b/tests/lib/account-region.test.ts index 06e5c941..eec2f0e5 100644 --- a/tests/lib/account-region.test.ts +++ b/tests/lib/account-region.test.ts @@ -20,14 +20,17 @@ vi.mock("../../src/logger.js", () => ({ import { accountRegion, regionFromToken, + REGION_TTL_MS, RETRY_AFTER_MS, } from "../../src/lib/account-region.js"; const tokenWith = (user: unknown) => `h.${Buffer.from(JSON.stringify({ user })).toString("base64url")}.s`; -const config = () => ({ - "browserstack-username": "user", +// 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", }); @@ -46,6 +49,10 @@ describe("regionFromToken", () => { 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", @@ -71,7 +78,7 @@ describe("accountRegion", () => { it("mints a user profile token with the config's credentials", async () => { mintImpl = async () => tokenWith({ grr: { region: "eu" } }); - expect(await accountRegion(config())).toBe("eu"); + expect(await accountRegion(config("user"))).toBe("eu"); expect(mintCalls).toEqual([ expect.objectContaining({ url: "https://auth.browserstack.com/oauth2/v2/token", @@ -82,7 +89,7 @@ describe("accountRegion", () => { ]); }); - it("looks up once per config and answers directly once known", async () => { + 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); @@ -92,7 +99,22 @@ describe("accountRegion", () => { expect(mintCalls).toHaveLength(1); }); - it("looks up each config on its own", async () => { + 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"); @@ -133,6 +155,17 @@ describe("accountRegion", () => { expect(mintCalls).toHaveLength(1); }); + 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"); + vi.advanceTimersByTime(1); + expect(await 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"); From 390c0664646902ce48f8705a5fcd178e5d337439 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 30 Oct 2026 10:48:30 +0530 Subject: [PATCH 25/26] fix(lib): keep the last known region when a refresh fails A failed refresh no longer drops an account's known region, so an auth outage doesn't stop tools for accounts already looked up. submitFeedback stays listed when the region is unknown at startup, and each call decides whether it can send. Co-Authored-By: Claude Opus 5.5 --- src/lib/account-region.ts | 28 +++++++++++++++++----------- src/lib/feedback-hint.ts | 15 ++++++++++++++- src/server-factory.ts | 6 +++--- tests/lib/account-region.test.ts | 27 ++++++++++++++++++++++++++- tests/tools/feedback-region.test.ts | 9 ++++++++- 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts index a849e193..9c4b61ba 100644 --- a/src/lib/account-region.ts +++ b/src/lib/account-region.ts @@ -79,10 +79,11 @@ async function lookUp( } /** - * The account's region, looked up once per account and kept for REGION_TTL_MS. Returned - * directly once known so callers that cannot wait (telemetry) stay synchronous; undefined - * without credentials. After a failure this is "unknown" for RETRY_AFTER_MS, and the - * next call after that looks up again. + * The account's region, looked up once per account and refreshed after REGION_TTL_MS. + * Returned directly once known so callers that cannot wait (telemetry) stay synchronous; + * undefined without credentials. 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, @@ -93,30 +94,35 @@ export function accountRegion( ) { return undefined; } + const now = Date.now(); const key = keyFor(config); const lookup = lookups.get(key); - if (lookup?.settled && Date.now() - lookup.settledAt! < REGION_TTL_MS) { + if (lookup?.settled && now - lookup.settledAt! < REGION_TTL_MS) { return lookup.settled; } - if (lookup?.pending) return lookup.pending; + if (lookup?.pending) return lookup.settled ?? lookup.pending; if ( lookup?.failedAt !== undefined && - Date.now() - lookup.failedAt < RETRY_AFTER_MS + now - lookup.failedAt < RETRY_AFTER_MS ) { - return "unknown"; + return lookup.settled ?? "unknown"; } - const entry: Lookup = {}; + const entry: Lookup = { + settled: lookup?.settled, + settledAt: lookup?.settledAt, + }; entry.pending = lookUp(config).then((region) => { entry.pending = undefined; if (region === null) { entry.failedAt = Date.now(); - return "unknown"; + return entry.settled ?? "unknown"; } entry.settled = region; entry.settledAt = Date.now(); + entry.failedAt = undefined; return region; }); remember(key, entry); - return entry.pending; + return entry.settled ?? entry.pending; } diff --git a/src/lib/feedback-hint.ts b/src/lib/feedback-hint.ts index bf6f66d6..52d4ee0c 100644 --- a/src/lib/feedback-hint.ts +++ b/src/lib/feedback-hint.ts @@ -1,9 +1,22 @@ -import { currentRegionScope, isRestrictedRegion } from "./region-context.js"; +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."; diff --git a/src/server-factory.ts b/src/server-factory.ts index a3f9c5d6..6171434f 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -29,7 +29,7 @@ import { 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, feedbackEnabled } from "./lib/feedback-hint.js"; +import { FEEDBACK_INSTRUCTION, feedbackListed } from "./lib/feedback-hint.js"; import addFeedbackTools from "./tools/feedback.js"; /** @@ -66,7 +66,7 @@ export class BrowserStackMcpServer { { instructions: [ nodeUpgradeNotice(), - feedbackEnabled() ? FEEDBACK_INSTRUCTION : undefined, + feedbackListed() ? FEEDBACK_INSTRUCTION : undefined, ] .filter(Boolean) .join("\n"), @@ -102,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, - ...(feedbackEnabled() ? [addFeedbackTools] : []), + ...(feedbackListed() ? [addFeedbackTools] : []), ]; toolAdders.forEach((adder) => { diff --git a/tests/lib/account-region.test.ts b/tests/lib/account-region.test.ts index eec2f0e5..23bf4aa9 100644 --- a/tests/lib/account-region.test.ts +++ b/tests/lib/account-region.test.ts @@ -24,6 +24,8 @@ import { 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`; @@ -155,14 +157,37 @@ describe("accountRegion", () => { expect(mintCalls).toHaveLength(1); }); + 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(await accountRegion(cfg)).toBe("eu"); + expect(accountRegion(cfg)).toBe("eu"); expect(mintCalls).toHaveLength(2); }); diff --git a/tests/tools/feedback-region.test.ts b/tests/tools/feedback-region.test.ts index 0478ea14..156eeffb 100644 --- a/tests/tools/feedback-region.test.ts +++ b/tests/tools/feedback-region.test.ts @@ -31,7 +31,7 @@ const failure = () => ({ }); describe("submitFeedback by the account's region", () => { - it.each(["eu", "in", "unknown"])( + it.each(["eu", "in"])( "is not registered, instructed or hinted when the region is %s", async (region) => { const { server, instructions, hinted } = await load(region); @@ -41,6 +41,13 @@ describe("submitFeedback by the account's region", () => { }, ); + 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) => { From e3e965606c2c8130b6fab2e271c1550075919cb8 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Fri, 30 Oct 2026 12:12:32 +0530 Subject: [PATCH 26/26] feat(lib): find the account's region by probing when auth can't answer When BrowserStack auth can't be reached or names a region not known here, the region is taken from the first Test Management host that accepts the account, trying the regional hosts first. A probed region is asked of auth again after five minutes. Co-Authored-By: Claude Opus 5.5 --- src/lib/account-region.ts | 73 ++++++++++++++++++++++++++++---- tests/lib/account-region.test.ts | 61 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/lib/account-region.ts b/src/lib/account-region.ts index 9c4b61ba..89bf5e9a 100644 --- a/src/lib/account-region.ts +++ b/src/lib/account-region.ts @@ -1,5 +1,6 @@ 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"; @@ -11,11 +12,22 @@ const LOOKUP_TIMEOUT_MS = 5_000; 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; } @@ -53,8 +65,8 @@ export function regionFromToken(token: string): AccountRegion { : "unknown"; } -/** The account's region, or null when BrowserStack auth could not answer. */ -async function lookUp( +/** The account's region from BrowserStack auth, or null when auth could not answer. */ +async function fromToken( config: BrowserStackConfig, ): Promise { try { @@ -78,10 +90,51 @@ async function lookUp( } } +/** 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. + * 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. A refresh keeps answering the last known region, and + * 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. */ @@ -97,7 +150,7 @@ export function accountRegion( const now = Date.now(); const key = keyFor(config); const lookup = lookups.get(key); - if (lookup?.settled && now - lookup.settledAt! < REGION_TTL_MS) { + if (lookup?.settled && now - lookup.settledAt! < lookup.ttl!) { return lookup.settled; } if (lookup?.pending) return lookup.settled ?? lookup.pending; @@ -111,17 +164,19 @@ export function accountRegion( const entry: Lookup = { settled: lookup?.settled, settledAt: lookup?.settledAt, + ttl: lookup?.ttl, }; - entry.pending = lookUp(config).then((region) => { + entry.pending = lookUp(config).then((answer) => { entry.pending = undefined; - if (region === null) { + if (answer === null) { entry.failedAt = Date.now(); return entry.settled ?? "unknown"; } - entry.settled = region; + entry.settled = answer.region; entry.settledAt = Date.now(); + entry.ttl = answer.ttl; entry.failedAt = undefined; - return region; + return answer.region; }); remember(key, entry); return entry.settled ?? entry.pending; diff --git a/tests/lib/account-region.test.ts b/tests/lib/account-region.test.ts index 23bf4aa9..f2eb8ddb 100644 --- a/tests/lib/account-region.test.ts +++ b/tests/lib/account-region.test.ts @@ -13,12 +13,24 @@ vi.mock("../../src/lib/central-oauth.js", () => ({ }, 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, @@ -62,9 +74,17 @@ describe("regionFromToken", () => { }); }); +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", () => { @@ -132,6 +152,34 @@ describe("accountRegion", () => { 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"); @@ -157,6 +205,19 @@ describe("accountRegion", () => { 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();