From 7ef36e1360009bdeb98ce134ad09007f0b44e881 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Mon, 7 Sep 2026 21:40:41 +0530 Subject: [PATCH 1/8] fix(util): recognize *.bsstag.com hosts as BrowserStack (v8, SDK-6948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCloudProvider and isBrowserstackInfra only matched browserstack.com, so a session against an internal staging env (*.bsstag.com) was classified as non-BrowserStack — the service skipped its instrumentation + GRR host rewriting and the WebDriver hub stayed on prod. Recognize bsstag/.bsstag.com in both guards. Ports #56/#65 (main/v9) to the v8 release line. Co-Authored-By: Claude Opus 4.8 --- .changeset/sdk-6948-v8-bsstag-host-recognition.md | 7 +++++++ packages/browserstack-service/src/util.ts | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/sdk-6948-v8-bsstag-host-recognition.md diff --git a/.changeset/sdk-6948-v8-bsstag-host-recognition.md b/.changeset/sdk-6948-v8-bsstag-host-recognition.md new file mode 100644 index 00000000..a3d41ce3 --- /dev/null +++ b/.changeset/sdk-6948-v8-bsstag-host-recognition.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +fix(util): recognize `*.bsstag.com` hosts as BrowserStack (internal staging, SDK-6948) + +`getCloudProvider` and `isBrowserstackInfra` only matched `browserstack.com`, so a session pointed at an internal staging env (`*.bsstag.com`) was classified as non-BrowserStack — the service then skipped its instrumentation and GRR host rewriting, and the WebDriver hub stayed on production. Recognize `bsstag` / `.bsstag.com` in both guards. Ports the main/v9 fix (#56/#65) to the v8 line. diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 11e1bdd4..30a1c3f3 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1034,11 +1034,11 @@ export function getCloudProvider(browser: WebdriverIO.Browser | WebdriverIO.Mult // Loop through all instances for (const instanceName of browser.instances) { const instance = (browser as any)[instanceName] as WebdriverIO.Browser - if (instance.options && instance.options.hostname && instance.options.hostname.includes('browserstack')) { + if (instance.options && instance.options.hostname && (instance.options.hostname.includes('browserstack') || instance.options.hostname.includes('bsstag'))) { return 'browserstack' } } - } else if (browser.options && browser.options.hostname && browser.options.hostname.includes('browserstack')) { // Single browser instance + } else if (browser.options && browser.options.hostname && (browser.options.hostname.includes('browserstack') || browser.options.hostname.includes('bsstag'))) { // Single browser instance return 'browserstack' } return 'unknown_grid' @@ -1168,7 +1168,7 @@ export function isBrowserstackInfra(config: BrowserstackConfig & Options.Testrun // a utility function to check if the hostname is browserstack const isBrowserstack = (str: string ): boolean => { - return str.includes('browserstack.com') + return str.includes('browserstack.com') || str.includes('bsstag.com') } if ((config.hostname) && !isBrowserstack(config.hostname)) { From 25529d20f41c4cc1951962a07b33cb238a327361 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Thu, 24 Sep 2026 16:34:45 +0530 Subject: [PATCH 2/8] fix(cli): fall back to BROWSERSTACK_BINARY_URL when update_cli fails (SDK-6948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_cli runs during bootstrap — before the binary spawns and before GRR localizes hosts — so it always targets prod api.browserstack.com. On an internal staging run (BROWSERSTACK_STAGING_ENV) the staging creds are rejected there with 401, leaving the CLI binary path empty and crashing the launcher with spawn('') -> ERR_INVALID_ARG_VALUE ('file' cannot be empty). Wrap the update_cli call: on failure, if BROWSERSTACK_BINARY_URL is set, download the binary from that URL and use it; otherwise re-throw (behaviour unchanged when the var is unset). Adds unit tests for both paths. Validated end-to-end against devapplca: update_cli 401 -> fallback download from a served zip -> binary spawned -> build-start reached. Co-Authored-By: Claude Opus 4.8 --- .changeset/sdk-6948-v8-binary-url-fallback.md | 7 ++++ .../browserstack-service/src/cli/cliUtils.ts | 24 +++++++++++-- .../tests/cli/cliUtils.test.ts | 35 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .changeset/sdk-6948-v8-binary-url-fallback.md diff --git a/.changeset/sdk-6948-v8-binary-url-fallback.md b/.changeset/sdk-6948-v8-binary-url-fallback.md new file mode 100644 index 00000000..0700043f --- /dev/null +++ b/.changeset/sdk-6948-v8-binary-url-fallback.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +fix(cli): fall back to `BROWSERSTACK_BINARY_URL` when `update_cli` fails (SDK-6948) + +`update_cli` runs during bootstrap — before the binary is spawned and before GRR localizes the API hosts — so it always targets production `api.browserstack.com`. On an internal staging run (`BROWSERSTACK_STAGING_ENV`) the staging credentials are rejected there (`401`), which left the CLI binary path empty and crashed the launcher with `spawn('') … ERR_INVALID_ARG_VALUE: 'file' cannot be empty`. When `update_cli` fails and `BROWSERSTACK_BINARY_URL` is set, download the binary from that URL instead so the run can proceed. Opt-in only — with the variable unset the original error propagates exactly as before. diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 423ea915..d704af66 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -187,11 +187,31 @@ export class CLIUtils { const nullDevice = platform() === 'win32' ? 'NUL' : '/dev/null' queryParams.cli_version = await this.runShellCommand(`${existingCliPath} version 2>${nullDevice}`) } - const response = await this.requestToUpdateCLI(queryParams, config) + const browserStackBinaryUrl = process.env.BROWSERSTACK_BINARY_URL || null + + let response + try { + response = await this.requestToUpdateCLI(queryParams, config) + } catch (err) { + // The version-check call (update_cli) failed — e.g. an internal staging run where + // update_cli is served from a host the provided creds are not valid on (staging creds + // against the prod api host -> 401). If an explicit binary URL was supplied, use it so + // the run is not blocked on this call. Opt-in only: with no BROWSERSTACK_BINARY_URL the + // error propagates exactly as before, so production behaviour is unchanged. + if (!isNullOrEmpty(browserStackBinaryUrl)) { + const status = (err as { response?: { statusCode?: number } })?.response?.statusCode ?? (err as Error)?.message + logger.warn(`update_cli request failed (${status}); falling back to BROWSERSTACK_BINARY_URL`) + const fallbackBinaryPath = await this.downloadLatestBinary(browserStackBinaryUrl as string, cliDir) + PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) + return fallbackBinaryPath + } + PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) + throw err + } + if (nestedKeyValue(response, ['updated_cli_version'])) { logger.debug(`Need to update binary, current binary version: ${queryParams.cli_version}`) - const browserStackBinaryUrl = process.env.BROWSERSTACK_BINARY_URL || null if (!isNullOrEmpty(browserStackBinaryUrl)) { logger.debug(`Using BROWSERSTACK_BINARY_URL: ${browserStackBinaryUrl}`) response.url = browserStackBinaryUrl diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 32bab846..521ef13f 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -441,6 +441,41 @@ describe('CLIUtils', () => { mockConfig ) }) + + it('falls back to BROWSERSTACK_BINARY_URL when update_cli fails (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + const fallbackPath = '/mock/cli/dir/binary-fallback' + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + // update_cli fails (e.g. staging creds against the prod api host -> 401) + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue({ response: { statusCode: 401 } }) + vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue(fallbackPath) + + const result = await CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig) + + expect(result).toBe(fallbackPath) + expect(CLIUtils.downloadLatestBinary).toHaveBeenCalledWith('https://example.com/staging-binary.zip', mockCliDir) + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + + it('re-throws when update_cli fails and no BROWSERSTACK_BINARY_URL is set (unchanged behaviour)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + delete process.env.BROWSERSTACK_BINARY_URL + try { + const err = new Error('Unauthorized') + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue(err) + const downloadSpy = vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue('/should/not/be/used') + + await expect(CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig)).rejects.toBe(err) + expect(downloadSpy).not.toHaveBeenCalled() + } finally { + if (saved !== undefined) { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) }) describe('setupCliPath', () => { From c4d95457deaa604c1985bff15f279801acb15119 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Tue, 13 Oct 2026 14:14:07 +0530 Subject: [PATCH 3/8] fix: align v8 staging-env handling with main/v9 #224 and #275 (SDK-6948) - util: treat *.bsstag.com as BrowserStack only on a named staging env (BROWSERSTACK_STAGING_ENV bare token, trimmed; or BROWSERSTACK_ENV staging/stag/preprod/pre-prod), exact suffix match, case-insensitive. Replaces the unconditional `includes('bsstag')` match. - cli: BROWSERSTACK_BINARY_URL fallback also covers a 2xx update_cli reply with no url and no cached binary; a failed fallback download uses the cached binary when present; the update_cli error is logged; the perf span closes on every path. - service: _printSessionURL logs a failed or empty session lookup at debug instead of throwing (v8 uses got, which throws on 404/401). - tests: bsstag gating, fallback download failures, 2xx-without-url, _printSessionURL 404 / missing automation_session. Addresses review comments 4256353854 and 4256353859. Co-Authored-By: Claude Opus 5.5 --- .changeset/sdk-6948-v8-binary-url-fallback.md | 2 +- .../sdk-6948-v8-bsstag-host-recognition.md | 4 +- .../browserstack-service/src/cli/cliUtils.ts | 73 ++++++++++++------- packages/browserstack-service/src/service.ts | 25 +++++-- packages/browserstack-service/src/util.ts | 28 ++++++- .../tests/cli/cliUtils.test.ts | 63 ++++++++++++++++ .../tests/service.test.ts | 28 +++++++ .../browserstack-service/tests/util.test.ts | 68 +++++++++++++++++ 8 files changed, 253 insertions(+), 38 deletions(-) diff --git a/.changeset/sdk-6948-v8-binary-url-fallback.md b/.changeset/sdk-6948-v8-binary-url-fallback.md index 0700043f..a568fbee 100644 --- a/.changeset/sdk-6948-v8-binary-url-fallback.md +++ b/.changeset/sdk-6948-v8-binary-url-fallback.md @@ -4,4 +4,4 @@ fix(cli): fall back to `BROWSERSTACK_BINARY_URL` when `update_cli` fails (SDK-6948) -`update_cli` runs during bootstrap — before the binary is spawned and before GRR localizes the API hosts — so it always targets production `api.browserstack.com`. On an internal staging run (`BROWSERSTACK_STAGING_ENV`) the staging credentials are rejected there (`401`), which left the CLI binary path empty and crashed the launcher with `spawn('') … ERR_INVALID_ARG_VALUE: 'file' cannot be empty`. When `update_cli` fails and `BROWSERSTACK_BINARY_URL` is set, download the binary from that URL instead so the run can proceed. Opt-in only — with the variable unset the original error propagates exactly as before. +`update_cli` runs during bootstrap — before the binary is spawned and before GRR localizes the API hosts — so it always targets production `api.browserstack.com`. On an internal staging run (`BROWSERSTACK_STAGING_ENV`) the staging credentials are rejected there (`401`), which left the CLI binary path empty and crashed the launcher with `spawn('') … ERR_INVALID_ARG_VALUE: 'file' cannot be empty`. When `update_cli` fails and `BROWSERSTACK_BINARY_URL` is set, download the binary from that URL instead so the run can proceed; the same applies when a 2xx reply carries no download URL and no binary is cached. If that download fails, the cached binary is used when there is one, and both errors are logged. Opt-in only — with the variable unset the original error propagates exactly as before. Ports main/v9 #224 to the v8 line. diff --git a/.changeset/sdk-6948-v8-bsstag-host-recognition.md b/.changeset/sdk-6948-v8-bsstag-host-recognition.md index a3d41ce3..2c0a4dda 100644 --- a/.changeset/sdk-6948-v8-bsstag-host-recognition.md +++ b/.changeset/sdk-6948-v8-bsstag-host-recognition.md @@ -2,6 +2,6 @@ "@wdio/browserstack-service": patch --- -fix(util): recognize `*.bsstag.com` hosts as BrowserStack (internal staging, SDK-6948) +fix(util): recognize `*.bsstag.com` hosts as BrowserStack on internal staging envs (SDK-6948) -`getCloudProvider` and `isBrowserstackInfra` only matched `browserstack.com`, so a session pointed at an internal staging env (`*.bsstag.com`) was classified as non-BrowserStack — the service then skipped its instrumentation and GRR host rewriting, and the WebDriver hub stayed on production. Recognize `bsstag` / `.bsstag.com` in both guards. Ports the main/v9 fix (#56/#65) to the v8 line. +`getCloudProvider` and `isBrowserstackInfra` only matched `browserstack.com`, so a session pointed at an internal staging env (`*.bsstag.com`) was classified as non-BrowserStack: session marking and Automate <-> TRA session linking did not work there. `*.bsstag.com` hosts are now treated as BrowserStack when `BROWSERSTACK_STAGING_ENV` is a bare env name (e.g. `k8s`, trimmed; blank means unset) or `BROWSERSTACK_ENV` is `staging`/`stag`/`preprod`/`pre-prod`. Hosts are matched case-insensitively. Production behaviour is unchanged. `_printSessionURL` now logs a failed session lookup at debug instead of failing the hook. Ports main/v9 #275 to the v8 line. diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index d704af66..47aa6337 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -189,40 +189,61 @@ export class CLIUtils { } const browserStackBinaryUrl = process.env.BROWSERSTACK_BINARY_URL || null - let response - try { - response = await this.requestToUpdateCLI(queryParams, config) - } catch (err) { - // The version-check call (update_cli) failed — e.g. an internal staging run where - // update_cli is served from a host the provided creds are not valid on (staging creds - // against the prod api host -> 401). If an explicit binary URL was supplied, use it so - // the run is not blocked on this call. Opt-in only: with no BROWSERSTACK_BINARY_URL the - // error propagates exactly as before, so production behaviour is unchanged. - if (!isNullOrEmpty(browserStackBinaryUrl)) { - const status = (err as { response?: { statusCode?: number } })?.response?.statusCode ?? (err as Error)?.message - logger.warn(`update_cli request failed (${status}); falling back to BROWSERSTACK_BINARY_URL`) - const fallbackBinaryPath = await this.downloadLatestBinary(browserStackBinaryUrl as string, cliDir) - PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) - return fallbackBinaryPath + const fallbackToBinaryUrl = async (reason: unknown) => { + logger.warn(`update_cli request failed (${reason}); falling back to BROWSERSTACK_BINARY_URL`) + try { + return await this.downloadLatestBinary(browserStackBinaryUrl as string, cliDir) + } catch (downloadErr) { + // A cached binary is still better than failing the run; keep the download error visible. + if (isNullOrEmpty(existingCliPath)) { + logger.error(`BROWSERSTACK_BINARY_URL download failed (${util.format(downloadErr)}) after update_cli failed (${reason})`) + throw downloadErr + } + logger.warn(`BROWSERSTACK_BINARY_URL download failed (${util.format(downloadErr)}); using cached binary ${existingCliPath}`) + return existingCliPath } - PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) - throw err } - if (nestedKeyValue(response, ['updated_cli_version'])) { - logger.debug(`Need to update binary, current binary version: ${queryParams.cli_version}`) + // Single close for the span on every path, including a rejected download. + try { + let response + try { + response = await this.requestToUpdateCLI(queryParams, config) + } catch (err) { + // The version-check call (update_cli) failed — e.g. an internal staging run where + // update_cli is served from a host the provided creds are not valid on (staging creds + // against the prod api host -> 401). If an explicit binary URL was supplied, use it so + // the run is not blocked on this call. Opt-in only: with no BROWSERSTACK_BINARY_URL the + // error propagates exactly as before, so production behaviour is unchanged. + if (!isNullOrEmpty(browserStackBinaryUrl)) { + logger.debug(`update_cli error: ${util.format(err)}`) + const status = (err as { response?: { statusCode?: number } })?.response?.statusCode ?? (err as Error)?.message + return await fallbackToBinaryUrl(status) + } + throw err + } - if (!isNullOrEmpty(browserStackBinaryUrl)) { - logger.debug(`Using BROWSERSTACK_BINARY_URL: ${browserStackBinaryUrl}`) - response.url = browserStackBinaryUrl + // A 2xx reply with neither `updated_cli_version` nor `url` means there is nothing to update: + // keep a cached binary, and only fetch from the explicit binary URL when there is none. + if (!isNullOrEmpty(browserStackBinaryUrl) && isNullOrEmpty(existingCliPath) && + !nestedKeyValue(response, ['updated_cli_version']) && !nestedKeyValue(response, ['url'])) { + return await fallbackToBinaryUrl(JSON.stringify(response)) } - const finalBinaryPath = await this.downloadLatestBinary(nestedKeyValue(response, ['url']), cliDir) + if (nestedKeyValue(response, ['updated_cli_version'])) { + logger.debug(`Need to update binary, current binary version: ${queryParams.cli_version}`) + + if (!isNullOrEmpty(browserStackBinaryUrl)) { + logger.debug(`Using BROWSERSTACK_BINARY_URL: ${browserStackBinaryUrl}`) + response.url = browserStackBinaryUrl + } + + return await this.downloadLatestBinary(nestedKeyValue(response, ['url']), cliDir) + } + return existingCliPath + } finally { PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) - return finalBinaryPath } - PerformanceTester.end(PerformanceEvents.SDK_CLI_CHECK_UPDATE) - return existingCliPath } static getCliDir() { diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index c14c85cf..d0c04898 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -824,12 +824,25 @@ export default class BrowserstackService implements Services.ServiceInstance { responseType: 'json' } - if (this._turboScale) { - const response = await got(sessionUrl, reqOpts) - browserUrl = response.body.url - } else { - const response = await got(sessionUrl, reqOpts) - browserUrl = response.body.automation_session.browser_url + try { + if (this._turboScale) { + const response = await got(sessionUrl, reqOpts) + browserUrl = response.body.url + } else { + const response = await got(sessionUrl, reqOpts) + const session = response.body?.automation_session + if (!session) { + BStackLogger.debug(`Could not fetch Browserstack session URL (${response.statusCode}) at ${sessionUrl}`) + return + } + browserUrl = session.browser_url + } + } catch (err) { + // SDK-6948: on a named staging env the session REST host may not serve this session + // (e.g. classic non-CLI flow). Printing the link is informational, so don't fail the hook. + const status = (err as { response?: { statusCode?: number } })?.response?.statusCode ?? (err as Error)?.message + BStackLogger.debug(`Could not fetch Browserstack session URL (${status}) at ${sessionUrl}`) + return } if (!this._browser) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 30a1c3f3..f6319e58 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1029,16 +1029,38 @@ export function getUniqueIdentifierForCucumber(world: ITestCaseHookParameter): s return world.pickle.uri + '_' + world.pickle.astNodeIds.join(',') } +/** + * SDK-6948: a named internal staging env (`BROWSERSTACK_STAGING_ENV` set to a bare token such as + * `k8s`) serves its hub/cdp from `*.bsstag.com` (e.g. `hub-k8s.bsstag.com`). Treat those hosts as + * BrowserStack so session marking and Automate <-> TRA linking work there. A dotted value (full + * host) or an unset/blank variable leaves the production-only host checks unchanged. The env value is + * trimmed and the hostname compared case-insensitively (shared rule across SDKs). + * `BROWSERSTACK_ENV` (trimmed, lowercased) of staging/stag/preprod/pre-prod also selects the bsstag + * hosts, matching node-agent constants and the binary's getEnvironmentUrl. + */ +const BSTAG_BROWSERSTACK_ENVS = ['staging', 'stag', 'preprod', 'pre-prod'] + +export function isStagingEnvBsstagHost(hostname: string): boolean { + const stagingEnv = process.env.BROWSERSTACK_STAGING_ENV?.trim() + const namedStagingEnv = Boolean(stagingEnv) && !stagingEnv!.includes('.') + const browserstackEnv = process.env.BROWSERSTACK_ENV?.trim().toLowerCase() || '' + if (!namedStagingEnv && !BSTAG_BROWSERSTACK_ENVS.includes(browserstackEnv)) { + return false + } + const host = hostname.toLowerCase() + return host === 'bsstag.com' || host.endsWith('.bsstag.com') +} + export function getCloudProvider(browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): string { if (browser && 'instances' in browser) { // Loop through all instances for (const instanceName of browser.instances) { const instance = (browser as any)[instanceName] as WebdriverIO.Browser - if (instance.options && instance.options.hostname && (instance.options.hostname.includes('browserstack') || instance.options.hostname.includes('bsstag'))) { + if (instance.options && instance.options.hostname && (instance.options.hostname.includes('browserstack') || isStagingEnvBsstagHost(instance.options.hostname))) { return 'browserstack' } } - } else if (browser.options && browser.options.hostname && (browser.options.hostname.includes('browserstack') || browser.options.hostname.includes('bsstag'))) { // Single browser instance + } else if (browser.options && browser.options.hostname && (browser.options.hostname.includes('browserstack') || isStagingEnvBsstagHost(browser.options.hostname))) { // Single browser instance return 'browserstack' } return 'unknown_grid' @@ -1168,7 +1190,7 @@ export function isBrowserstackInfra(config: BrowserstackConfig & Options.Testrun // a utility function to check if the hostname is browserstack const isBrowserstack = (str: string ): boolean => { - return str.includes('browserstack.com') || str.includes('bsstag.com') + return str.includes('browserstack.com') || isStagingEnvBsstagHost(str) } if ((config.hostname) && !isBrowserstack(config.hostname)) { diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 521ef13f..986dca79 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -461,6 +461,69 @@ describe('CLIUtils', () => { } }) + it('uses the cached binary when the BROWSERSTACK_BINARY_URL download fails (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue({ response: { statusCode: 401 } }) + vi.spyOn(CLIUtils, 'downloadLatestBinary').mockRejectedValue(new Error('download failed')) + + const result = await CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig) + + expect(result).toBe(mockExistingPath) + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + + it('re-throws the download error when the BROWSERSTACK_BINARY_URL download fails and no binary is cached (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + const downloadErr = new Error('download failed') + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockRejectedValue({ response: { statusCode: 401 } }) + vi.spyOn(CLIUtils, 'downloadLatestBinary').mockRejectedValue(downloadErr) + + await expect(CLIUtils.checkAndUpdateCli('', mockCliDir, mockConfig)).rejects.toBe(downloadErr) + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + + it('falls back to BROWSERSTACK_BINARY_URL when a 2xx reply has neither url nor updated_cli_version and no binary is cached (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockResolvedValue({ message: 'Unauthorized' }) + vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue('/mock/cli/dir/binary-fallback') + + const result = await CLIUtils.checkAndUpdateCli('', mockCliDir, mockConfig) + + expect(result).toBe('/mock/cli/dir/binary-fallback') + expect(CLIUtils.downloadLatestBinary).toHaveBeenCalledWith('https://example.com/staging-binary.zip', mockCliDir) + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + + it('keeps the cached binary when a 2xx reply has neither url nor updated_cli_version (SDK-6948)', async () => { + const saved = process.env.BROWSERSTACK_BINARY_URL + process.env.BROWSERSTACK_BINARY_URL = 'https://example.com/staging-binary.zip' + try { + vi.spyOn(CLIUtils, 'runShellCommand').mockResolvedValue('1.0.0') + vi.spyOn(CLIUtils, 'requestToUpdateCLI').mockResolvedValue({ success: true }) + const downloadSpy = vi.spyOn(CLIUtils, 'downloadLatestBinary').mockResolvedValue('/should/not/be/used') + + const result = await CLIUtils.checkAndUpdateCli(mockExistingPath, mockCliDir, mockConfig) + + expect(result).toBe(mockExistingPath) + expect(downloadSpy).not.toHaveBeenCalled() + } finally { + if (saved === undefined) { delete process.env.BROWSERSTACK_BINARY_URL } else { process.env.BROWSERSTACK_BINARY_URL = saved } + } + }) + it('re-throws when update_cli fails and no BROWSERSTACK_BINARY_URL is set (unchanged behaviour)', async () => { const saved = process.env.BROWSERSTACK_BINARY_URL delete process.env.BROWSERSTACK_BINARY_URL diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 0007f817..0f035434 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -253,6 +253,34 @@ describe('_printSessionURL', () => { expect(isBrowserstackSessionSpy).toHaveBeenCalled() }) + it('logs at debug instead of throwing when the session REST call fails (SDK-6948)', async () => { + browser.isMultiremote = false + service['_browser'] = browser + vi.spyOn(utils, 'isBrowserstackSession').mockReturnValue(true) + const logInfoSpy = vi.spyOn(log, 'info').mockImplementation((string) => string) + const debugSpy = vi.spyOn(bstackLogger.BStackLogger, 'debug') + logInfoSpy.mockClear() + vi.mocked(got).mockRejectedValueOnce(Object.assign(new Error('Response code 404 (Not Found)'), { response: { statusCode: 404 } })) + + await expect(service._printSessionURL()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Could not fetch Browserstack session URL (404)')) + expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('session: ')) + }) + + it('logs at debug when the session REST reply has no automation_session (SDK-6948)', async () => { + browser.isMultiremote = false + service['_browser'] = browser + vi.spyOn(utils, 'isBrowserstackSession').mockReturnValue(true) + const logInfoSpy = vi.spyOn(log, 'info').mockImplementation((string) => string) + const debugSpy = vi.spyOn(bstackLogger.BStackLogger, 'debug') + logInfoSpy.mockClear() + vi.mocked(got).mockResolvedValueOnce({ statusCode: 200, body: { message: 'Unauthorized' } } as any) + + await expect(service._printSessionURL()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Could not fetch Browserstack session URL (200)')) + expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('session: ')) + }) + describe('if cant print', () => { describe('no browser object', () => { const logInfoSpy = vi.spyOn(log, 'info').mockImplementation((string) => string) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index 2b507b4d..a557f140 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -17,6 +17,8 @@ import { getCiInfo, getCloudProvider, isBrowserstackSession, + isBrowserstackInfra, + isStagingEnvBsstagHost, getUniqueIdentifier, getUniqueIdentifierForCucumber, removeAnsiColors, @@ -467,6 +469,72 @@ describe('getCloudProvider', () => { } as unknown as WebdriverIO.MultiRemoteBrowser expect(getCloudProvider(browser)).toEqual('browserstack') }) + it('return unknown_grid for a non-BrowserStack hub', () => { + expect(getCloudProvider({ options: { hostname: 'selenium.example.com' } } as any)).toEqual('unknown_grid') + }) +}) + +describe('SDK-6948 BROWSERSTACK_STAGING_ENV bsstag host recognition', () => { + const originalStagingEnv = process.env.BROWSERSTACK_STAGING_ENV + const originalBrowserstackEnv = process.env.BROWSERSTACK_ENV + const creds = { user: 'user', key: 'a'.repeat(20) } + const multiremote = (hostname: string) => ({ + isMultiremote: true, + instances: ['browserA'], + browserA: { options: { hostname } } + } as unknown as WebdriverIO.MultiRemoteBrowser) + + beforeEach(() => { + delete process.env.BROWSERSTACK_STAGING_ENV + delete process.env.BROWSERSTACK_ENV + }) + + afterEach(() => { + if (originalStagingEnv === undefined) { delete process.env.BROWSERSTACK_STAGING_ENV } else { process.env.BROWSERSTACK_STAGING_ENV = originalStagingEnv } + if (originalBrowserstackEnv === undefined) { delete process.env.BROWSERSTACK_ENV } else { process.env.BROWSERSTACK_ENV = originalBrowserstackEnv } + }) + + it('treats *.bsstag.com as BrowserStack when BROWSERSTACK_STAGING_ENV is a bare env name', () => { + process.env.BROWSERSTACK_STAGING_ENV = 'k8s' + expect(isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toEqual(true) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('browserstack') + expect(getCloudProvider(multiremote('hub-k8s.bsstag.com'))).toEqual('browserstack') + expect(isBrowserstackInfra({ ...creds, hostname: 'hub-k8s.bsstag.com' } as any)).toEqual(true) + expect(isBrowserstackInfra(creds as any, [{ hostname: 'hub-k8s.bsstag.com' }] as any)).toEqual(true) + }) + + it('trims BROWSERSTACK_STAGING_ENV and matches the host case-insensitively', () => { + process.env.BROWSERSTACK_STAGING_ENV = ' k8s ' + expect(isStagingEnvBsstagHost('HUB-K8S.BSSTAG.COM')).toEqual(true) + }) + + it('treats *.bsstag.com as BrowserStack when BROWSERSTACK_ENV is a staging/preprod value', () => { + for (const env of ['staging', 'stag', 'Preprod', ' pre-prod ']) { + process.env.BROWSERSTACK_ENV = env + expect(isStagingEnvBsstagHost('hub-preprod.bsstag.com')).toEqual(true) + } + }) + + it('keeps production behaviour when no staging env is set', () => { + expect(isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toEqual(false) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('unknown_grid') + expect(isBrowserstackInfra({ ...creds, hostname: 'hub-k8s.bsstag.com' } as any)).toEqual(false) + expect(isBrowserstackInfra({ ...creds, hostname: 'hub.browserstack.com' } as any)).toEqual(true) + }) + + it('keeps production behaviour for a blank or dotted BROWSERSTACK_STAGING_ENV', () => { + process.env.BROWSERSTACK_STAGING_ENV = ' ' + expect(isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toEqual(false) + process.env.BROWSERSTACK_STAGING_ENV = 'sdk-k8s.bsstag.com' + expect(isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toEqual(false) + }) + + it('does not match look-alike hosts', () => { + process.env.BROWSERSTACK_STAGING_ENV = 'k8s' + expect(isStagingEnvBsstagHost('hub.notbsstag.com')).toEqual(false) + expect(isStagingEnvBsstagHost('bsstag.com.example.com')).toEqual(false) + expect(isBrowserstackInfra({ ...creds, hostname: 'selenium.example.com' } as any)).toEqual(false) + }) }) describe('isBrowserstackSession', () => { From ccb370dd92a4c6bc773aee8b103c68de826b83a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:45:03 +0000 Subject: [PATCH 4/8] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-183.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pr-183.md diff --git a/.changeset/pr-183.md b/.changeset/pr-183.md new file mode 100644 index 00000000..47c383be --- /dev/null +++ b/.changeset/pr-183.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +- SDK-6948: `*.bsstag.com` hubs are now recognised as BrowserStack on named internal staging envs (`BROWSERSTACK_STAGING_ENV` / `BROWSERSTACK_ENV`), so session marking and TRA linking work there. +- SDK-6948: `update_cli` failures fall back to `BROWSERSTACK_BINARY_URL`. A failed fallback download uses the cached binary. +- SDK-6948: `_printSessionURL` logs a failed session lookup at debug instead of throwing. From d91e7293ac857f18e5b25b29e3d3a548ee622552 Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Tue, 13 Oct 2026 14:37:37 +0530 Subject: [PATCH 5/8] fix(service): make session update non-fatal on HTTP error replies (SDK-6948) With *.bsstag.com sessions now recognised on named staging envs, _update PUTs/PATCHes the session REST host there. got rejects on a non-2xx reply, so a 401/404 surfaced as a beforeSuite/beforeTest/beforeFeature hook error (via _setSessionName) and skipped the rest of after(). main/v9 uses fetch, which resolves on HTTP errors; match that: log HTTP-status errors at debug and keep rejecting on network errors. Tests: PUT 401 and TurboScale PATCH 404 resolve with a debug log; a network error still rejects. Addresses review comment 4256713959. Co-Authored-By: Claude Opus 5.5 --- packages/browserstack-service/src/service.ts | 13 ++++++-- .../tests/service.test.ts | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index d0c04898..00ed0244 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -794,18 +794,27 @@ export default class BrowserstackService implements Services.ServiceInstance { } const sessionUrl = `${this._sessionBaseUrl}/${sessionId}.json` BStackLogger.debug(`Updating Browserstack session at ${sessionUrl} with request body: `, requestBody) + // SDK-6948: an HTTP error reply (e.g. 401/404 from a named staging env's session REST host) + // must not fail the calling hook. got rejects on non-2xx; main/v9 uses fetch, which resolves, + // so only HTTP-status errors are swallowed here and network errors still propagate. + const onHttpError = (err: { response?: { statusCode?: number } }) => { + if (!err?.response) { + throw err + } + BStackLogger.debug(`Could not update Browserstack session (${err.response.statusCode}) at ${sessionUrl}`) + } if (this._turboScale) { return got.patch(sessionUrl, { json: requestBody, username: this._config.user, password: this._config.key - }) + }).catch(onHttpError) } return got.put(sessionUrl, { json: requestBody, username: this._config.user, password: this._config.key - }) + }).catch(onHttpError) } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.PRINT_BUILDLINK) diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 0f035434..5a728f3a 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -214,6 +214,37 @@ describe('_update', () => { expect(got.put).toBeCalledTimes(1) }) + it('logs at debug instead of rejecting when the session update gets an HTTP error (SDK-6948)', async () => { + service['_browser'] = browser + const debugSpy = vi.spyOn(bstackLogger.BStackLogger, 'debug') + vi.mocked(got.put).mockRejectedValueOnce(Object.assign(new Error('Response code 401 (Unauthorized)'), { response: { statusCode: 401 } })) + + await expect(service._update('sessionId', { name: 'x' })).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Could not update Browserstack session (401)')) + }) + + it('logs at debug when a TurboScale session update gets an HTTP error (SDK-6948)', async () => { + service['_browser'] = browser + service['_turboScale'] = true + const originalPatch = (got as any).patch + ;(got as any).patch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('Response code 404 (Not Found)'), { response: { statusCode: 404 } })) + try { + await expect(service._update('sessionId', { name: 'x' })).resolves.toBeUndefined() + expect((got as any).patch).toHaveBeenCalledTimes(1) + } finally { + (got as any).patch = originalPatch + service['_turboScale'] = false + } + }) + + it('still rejects on a network error (no HTTP response)', async () => { + service['_browser'] = browser + const networkErr = new Error('ECONNRESET') + vi.mocked(got.put).mockRejectedValueOnce(networkErr) + + await expect(service._update('sessionId', {})).rejects.toBe(networkErr) + }) + afterEach(() => { getCloudProviderSpy.mockClear() }) From 61570d313eb5116653038cd81b912b9e0a9e062d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:08:08 +0000 Subject: [PATCH 6/8] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-183.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-183.md b/.changeset/pr-183.md index 47c383be..a0a8a1d0 100644 --- a/.changeset/pr-183.md +++ b/.changeset/pr-183.md @@ -4,4 +4,4 @@ - SDK-6948: `*.bsstag.com` hubs are now recognised as BrowserStack on named internal staging envs (`BROWSERSTACK_STAGING_ENV` / `BROWSERSTACK_ENV`), so session marking and TRA linking work there. - SDK-6948: `update_cli` failures fall back to `BROWSERSTACK_BINARY_URL`. A failed fallback download uses the cached binary. -- SDK-6948: `_printSessionURL` logs a failed session lookup at debug instead of throwing. +- SDK-6948: `_printSessionURL` and `_update` log a failed session lookup or update (HTTP error) at debug instead of throwing. From d0a8fbaeda5069ce5a5e64135994ff9f507067ad Mon Sep 17 00:00:00 2001 From: Aakash Hotchandani Date: Tue, 13 Oct 2026 14:53:48 +0530 Subject: [PATCH 7/8] docs(changeset): scope bsstag session marking to the CLI flow; assert TurboScale debug log (SDK-6948) - Changeset: session marking and TRA linking on named staging envs work in the CLI flow, where the binary supplies the staging session REST host (APIUtils.updateURLSForGRR). The classic non-CLI flow on a named staging env is documented as unsupported (same as main/v9 #275); its session update is skipped at debug. - service.test.ts: the TurboScale _update test now asserts the 'Could not update Browserstack session (404)' debug log. Addresses review comments 4256880250 and 4256880265. Co-Authored-By: Claude Opus 5.5 --- .changeset/sdk-6948-v8-bsstag-host-recognition.md | 2 +- packages/browserstack-service/tests/service.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/sdk-6948-v8-bsstag-host-recognition.md b/.changeset/sdk-6948-v8-bsstag-host-recognition.md index 2c0a4dda..733d023b 100644 --- a/.changeset/sdk-6948-v8-bsstag-host-recognition.md +++ b/.changeset/sdk-6948-v8-bsstag-host-recognition.md @@ -4,4 +4,4 @@ fix(util): recognize `*.bsstag.com` hosts as BrowserStack on internal staging envs (SDK-6948) -`getCloudProvider` and `isBrowserstackInfra` only matched `browserstack.com`, so a session pointed at an internal staging env (`*.bsstag.com`) was classified as non-BrowserStack: session marking and Automate <-> TRA session linking did not work there. `*.bsstag.com` hosts are now treated as BrowserStack when `BROWSERSTACK_STAGING_ENV` is a bare env name (e.g. `k8s`, trimmed; blank means unset) or `BROWSERSTACK_ENV` is `staging`/`stag`/`preprod`/`pre-prod`. Hosts are matched case-insensitively. Production behaviour is unchanged. `_printSessionURL` now logs a failed session lookup at debug instead of failing the hook. Ports main/v9 #275 to the v8 line. +`getCloudProvider` and `isBrowserstackInfra` only matched `browserstack.com`, so a session pointed at an internal staging env (`*.bsstag.com`) was classified as non-BrowserStack: session marking and Automate <-> TRA session linking did not work there. `*.bsstag.com` hosts are now treated as BrowserStack when `BROWSERSTACK_STAGING_ENV` is a bare env name (e.g. `k8s`, trimmed; blank means unset) or `BROWSERSTACK_ENV` is `staging`/`stag`/`preprod`/`pre-prod`. Hosts are matched case-insensitively. Production behaviour is unchanged. With the CLI flow (the default), session marking and Automate <-> TRA session linking now work on those envs, because the binary supplies the staging session REST host. The classic (non-CLI) flow on a named staging env is not supported: it has no staging session REST host, so the session name/status update is skipped at debug instead of failing the hook. `_printSessionURL` and `_update` log a failed session lookup or update (HTTP error reply) at debug instead of throwing. Ports main/v9 #275 to the v8 line. diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 5a728f3a..8737bbe0 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -226,11 +226,13 @@ describe('_update', () => { it('logs at debug when a TurboScale session update gets an HTTP error (SDK-6948)', async () => { service['_browser'] = browser service['_turboScale'] = true + const debugSpy = vi.spyOn(bstackLogger.BStackLogger, 'debug') const originalPatch = (got as any).patch ;(got as any).patch = vi.fn().mockRejectedValueOnce(Object.assign(new Error('Response code 404 (Not Found)'), { response: { statusCode: 404 } })) try { await expect(service._update('sessionId', { name: 'x' })).resolves.toBeUndefined() expect((got as any).patch).toHaveBeenCalledTimes(1) + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Could not update Browserstack session (404)')) } finally { (got as any).patch = originalPatch service['_turboScale'] = false From 0f1d17323973ee1cc2dcc5f914b2ed08b5375657 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:24:17 +0000 Subject: [PATCH 8/8] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-183.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-183.md b/.changeset/pr-183.md index a0a8a1d0..1f57d278 100644 --- a/.changeset/pr-183.md +++ b/.changeset/pr-183.md @@ -2,6 +2,6 @@ "@wdio/browserstack-service": patch --- -- SDK-6948: `*.bsstag.com` hubs are now recognised as BrowserStack on named internal staging envs (`BROWSERSTACK_STAGING_ENV` / `BROWSERSTACK_ENV`), so session marking and TRA linking work there. +- SDK-6948: `*.bsstag.com` hubs are now recognised as BrowserStack on named internal staging envs (`BROWSERSTACK_STAGING_ENV` / `BROWSERSTACK_ENV`), so with the CLI flow (default) session marking and TRA linking work there. The classic (non-CLI) flow on a named staging env is not supported; its session update is skipped at debug. - SDK-6948: `update_cli` failures fall back to `BROWSERSTACK_BINARY_URL`. A failed fallback download uses the cached binary. - SDK-6948: `_printSessionURL` and `_update` log a failed session lookup or update (HTTP error) at debug instead of throwing.