diff --git a/.changeset/pr-183.md b/.changeset/pr-183.md new file mode 100644 index 00000000..1f57d278 --- /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 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. 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..a568fbee --- /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; 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 new file mode 100644 index 00000000..733d023b --- /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 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. 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/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 423ea915..47aa6337 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -187,22 +187,63 @@ 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) - 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 + const browserStackBinaryUrl = process.env.BROWSERSTACK_BINARY_URL || null + + 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 } + } - const finalBinaryPath = await this.downloadLatestBinary(nestedKeyValue(response, ['url']), cliDir) + // 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 + } + + // 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)) + } + + 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..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) @@ -824,12 +833,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 11e1bdd4..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')) { + 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')) { // 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') + 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 32bab846..986dca79 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -441,6 +441,104 @@ 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('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 + 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', () => { diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index 0007f817..8737bbe0 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -214,6 +214,39 @@ 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 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 + } + }) + + 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() }) @@ -253,6 +286,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', () => {