diff --git a/.changeset/pr-275.md b/.changeset/pr-275.md new file mode 100644 index 00000000..22fd7334 --- /dev/null +++ b/.changeset/pr-275.md @@ -0,0 +1,7 @@ +--- +"@wdio/browserstack-service": patch +--- + +- SDK-6948: with `BROWSERSTACK_STAGING_ENV` set to a bare env name (e.g. `k8s`), `*.bsstag.com` hubs are now treated as BrowserStack, so session status marking and Automate <-> TRA session linking work on internal staging envs. Production behaviour is unchanged. +- SDK-6948: the session REST base URL in `service.ts` now uses the binary-supplied API host (`APIUtils`) instead of a hardcoded production host. For GRR-enabled accounts this is the regional api host the binary already uses to mark sessions. The classic (non-CLI) flow on a named staging env is not supported; `_printSessionURL` logs a non-session reply at debug instead of throwing. +- SDK-6948: `BROWSERSTACK_STAGING_ENV` is trimmed (blank means unset), and `bsstag` hosts are matched case-insensitively. diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 8854e15c..1fa78f76 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -37,6 +37,7 @@ import { EVENTS } from './instrumentation/performance/constants.js' import { BrowserstackCLI } from './cli/index.js' import { drainSkipReports, markTestStarted, reportSuiteSkipped } from './cli/skipReporter.js' import { CLIUtils } from './cli/cliUtils.js' +import APIUtils from './cli/apiUtils.js' import { _fetch as fetch } from './fetchWrapper.js' import AutomationFramework from './cli/frameworks/automationFramework.js' @@ -246,12 +247,18 @@ export default class BrowserstackService implements Services.ServiceInstance { // Ensure capabilities are not null in case of multiremote + // Resolved here, after beforeSession's CLI bootstrap has applied the binary-supplied + // GRR/staging hosts (APIUtils.updateURLSForGRR). Without the CLI, or when the config server + // returns the default grr_urls, these are the production hosts; for a GRR-enabled account + // they follow the regional api host the binary already uses to mark the session. + this._sessionBaseUrl = `${APIUtils.BROWSERSTACK_AUTOMATE_API_URL}/automate/sessions` + if (this._isAppAutomate()) { - this._sessionBaseUrl = 'https://api-cloud.browserstack.com/app-automate/sessions' + this._sessionBaseUrl = `${APIUtils.BROWSERSTACK_AA_API_CLOUD_URL}/app-automate/sessions` } if (this._turboScale) { - this._sessionBaseUrl = 'https://api.browserstack.com/automate-turboscale/v1/sessions' + this._sessionBaseUrl = `${APIUtils.BROWSERSTACK_AUTOMATE_API_URL}/automate-turboscale/v1/sessions` } this._scenariosThatRan = [] @@ -1340,7 +1347,14 @@ export default class BrowserstackService implements Services.ServiceInstance { headers }) const res = response.clone() - browserUrl = (await res.json()).automation_session.browser_url + const session = (await res.json())?.automation_session + if (!session) { + // e.g. a 401 when the classic (non-CLI) flow targets a named staging env, whose + // session REST host is only known to the CLI; don't throw out of before(). + BStackLogger.debug(`Could not fetch Browserstack session URL (${response.status}) at ${sessionUrl}`) + return + } + browserUrl = session.browser_url } if (!this._browser) { diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 5aa1e9bb..32715d54 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1142,6 +1142,28 @@ export function getLtsSessionId(): string { return process.env[BROWSERSTACK_LTS_SESSION_ID] || '' } +/** + * 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 { // NOTE: do NOT branch on isLoadTestingSession() here. getCloudProvider is // shared with Automate-side guards (automateModule onBefore/onAfterTest, @@ -1157,11 +1179,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') || 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' @@ -1270,7 +1292,7 @@ export function isBrowserstackInfra(config: BrowserstackConfig & Options.Testrun // In case hostname is not present anywhere in the config, it returns true by default as hostname is not a mandatory parameter in the config const isBrowserstack = (str: string ): boolean => { - return str === 'browserstack.com' || str.endsWith('.browserstack.com') + return str === 'browserstack.com' || str.endsWith('.browserstack.com') || isStagingEnvBsstagHost(str) } if ((config.hostname) && !isBrowserstack(config.hostname)) { diff --git a/packages/browserstack-service/tests/service.test.ts b/packages/browserstack-service/tests/service.test.ts index aa32efbc..84dee61a 100644 --- a/packages/browserstack-service/tests/service.test.ts +++ b/packages/browserstack-service/tests/service.test.ts @@ -15,6 +15,7 @@ import { TestFrameworkState } from '../src/cli/states/testFrameworkState.js' import { HookState } from '../src/cli/states/hookState.js' import { AutomationFrameworkConstants } from '../src/cli/frameworks/constants/automationFrameworkConstants.js' import { AutomationFrameworkState } from '../src/cli/states/automationFrameworkState.js' +import APIUtils from '../src/cli/apiUtils.js' const jasmineSuiteTitle = 'Jasmine__TopLevel__Suite' const sessionBaseUrl = 'https://api.browserstack.com/automate/sessions' @@ -457,6 +458,19 @@ describe('_printSessionURL', () => { expect(isBrowserstackSessionSpy).toHaveBeenCalled() }) + it('logs at debug instead of throwing 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') + vi.mocked(fetch).mockReturnValueOnce(Promise.resolve(Response.json({ message: 'Unauthorized' }, { status: 401 }))) + + await expect(service._printSessionURL()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('Could not fetch Browserstack session URL (401)')) + expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('session: ')) + }) + describe('if cant print', () => { describe('no browser object', () => { const logInfoSpy = vi.spyOn(log, 'info').mockImplementation((string) => string) @@ -722,6 +736,58 @@ describe('before', () => { expect(service['_sessionBaseUrl']).toEqual('https://api.browserstack.com/automate-turboscale/v1/sessions') }) + it('uses the GRR/staging api host applied by the CLI for the session base url (SDK-6948)', () => { + const original = APIUtils.BROWSERSTACK_AUTOMATE_API_URL + APIUtils.BROWSERSTACK_AUTOMATE_API_URL = 'https://apik8s.bsstag.com' + try { + const service = new BrowserstackService({} as any, [{}] as any, { + user: 'foo', + key: 'bar', + capabilities: {} + }) + service.before(service['_config'] as any, [], browser) + + expect(service['_sessionBaseUrl']).toEqual('https://apik8s.bsstag.com/automate/sessions') + } finally { + APIUtils.BROWSERSTACK_AUTOMATE_API_URL = original + } + }) + + it('uses the GRR app-automate host for the App Automate session base url (SDK-6948)', () => { + const original = APIUtils.BROWSERSTACK_AA_API_CLOUD_URL + APIUtils.BROWSERSTACK_AA_API_CLOUD_URL = 'https://api-cloud-k8s.bsstag.com' + try { + const service = new BrowserstackService({} as any, [{}] as any, { + user: 'foo', + key: 'bar', + capabilities: {} + }) + vi.spyOn(service as any, '_isAppAutomate').mockReturnValue(true) + service.before(service['_config'] as any, [], browser) + + expect(service['_sessionBaseUrl']).toEqual('https://api-cloud-k8s.bsstag.com/app-automate/sessions') + } finally { + APIUtils.BROWSERSTACK_AA_API_CLOUD_URL = original + } + }) + + it('uses the GRR automate host for the TurboScale session base url (SDK-6948)', () => { + const original = APIUtils.BROWSERSTACK_AUTOMATE_API_URL + APIUtils.BROWSERSTACK_AUTOMATE_API_URL = 'https://api-eu.browserstack.com' + try { + const service = new BrowserstackService({ turboScale: true } as any, {}, { + user: 'foo', + key: 'bar', + capabilities: {} + }) + service.before(service['_config'] as any, [], browser) + + expect(service['_sessionBaseUrl']).toEqual('https://api-eu.browserstack.com/automate-turboscale/v1/sessions') + } finally { + APIUtils.BROWSERSTACK_AUTOMATE_API_URL = original + } + }) + it('should overwrite execute command to route browserstack_executor via executeScript', async () => { (browser as any).isBidi = true const service = new BrowserstackService({} as any, [{}] as any, { user: 'foo', key: 'bar', capabilities: {} }) diff --git a/packages/browserstack-service/tests/util.test.ts b/packages/browserstack-service/tests/util.test.ts index a6e2e6bc..d659a41b 100644 --- a/packages/browserstack-service/tests/util.test.ts +++ b/packages/browserstack-service/tests/util.test.ts @@ -566,6 +566,99 @@ describe('getCloudProvider', () => { }) }) +describe('SDK-6948 BROWSERSTACK_STAGING_ENV bsstag host recognition', () => { + const originalStagingEnv = process.env.BROWSERSTACK_STAGING_ENV + const originalBrowserstackEnv = process.env.BROWSERSTACK_ENV + + beforeEach(() => { + delete process.env.BROWSERSTACK_ENV + }) + const stagingConfig = { user: 'foo', key: 'a'.repeat(20), hostname: 'hub-k8s.bsstag.com' } as any + + 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 hosts as browserstack for a named staging env', () => { + process.env.BROWSERSTACK_STAGING_ENV = 'k8s' + expect(utils.isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toBe(true) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('browserstack') + expect(getCloudProvider({ + isMultiremote: true, + instances: ['browserA'], + browserA: { options: { hostname: 'hub-k8s.bsstag.com' } } + } as unknown as WebdriverIO.MultiRemoteBrowser)).toEqual('browserstack') + expect(utils.isBrowserstackInfra(stagingConfig)).toBe(true) + }) + + it('leaves prod behaviour unchanged when BROWSERSTACK_STAGING_ENV is unset', () => { + delete process.env.BROWSERSTACK_STAGING_ENV + expect(utils.isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toBe(false) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('unknown_grid') + expect(getCloudProvider({ options: { hostname: 'hub.browserstack.com' } } as any)).toEqual('browserstack') + expect(utils.isBrowserstackInfra(stagingConfig)).toBe(false) + }) + + it('ignores a dotted (full host) BROWSERSTACK_STAGING_ENV value', () => { + process.env.BROWSERSTACK_STAGING_ENV = 'sdk-k8s.bsstag.com' + expect(utils.isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toBe(false) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('unknown_grid') + expect(utils.isBrowserstackInfra(stagingConfig)).toBe(false) + }) + + it('does not treat non-bsstag hosts as browserstack for a named staging env', () => { + process.env.BROWSERSTACK_STAGING_ENV = 'k8s' + expect(utils.isStagingEnvBsstagHost('hub.bsstag.com.evil.example')).toBe(false) + expect(utils.isStagingEnvBsstagHost('bsstag.com.attacker.io')).toBe(false) + expect(utils.isStagingEnvBsstagHost('evilbsstag.com')).toBe(false) + expect(utils.isStagingEnvBsstagHost('bsstag.com')).toBe(true) + expect(getCloudProvider({ options: { hostname: 'evilbsstag.com' } } as any)).toEqual('unknown_grid') + }) + + it('trims BROWSERSTACK_STAGING_ENV and matches the host case-insensitively', () => { + process.env.BROWSERSTACK_STAGING_ENV = ' k8s ' + expect(utils.isStagingEnvBsstagHost('HUB-K8S.BSSTAG.COM')).toBe(true) + expect(getCloudProvider({ options: { hostname: 'Hub-K8s.BsStag.com' } } as any)).toEqual('browserstack') + }) + + it.each(['staging', 'stag', 'preprod', 'pre-prod', ' PreProd '])('treats bsstag hosts as browserstack for BROWSERSTACK_ENV=%j', (env) => { + delete process.env.BROWSERSTACK_STAGING_ENV + process.env.BROWSERSTACK_ENV = env + expect(utils.isStagingEnvBsstagHost('hub-preprod.bsstag.com')).toBe(true) + expect(getCloudProvider({ options: { hostname: 'hub-preprod.bsstag.com' } } as any)).toEqual('browserstack') + expect(utils.isBrowserstackInfra({ ...stagingConfig, hostname: 'hub-preprod.bsstag.com' })).toBe(true) + }) + + it.each(['production', 'prod', ''])('keeps prod behaviour for BROWSERSTACK_ENV=%j', (env) => { + delete process.env.BROWSERSTACK_STAGING_ENV + process.env.BROWSERSTACK_ENV = env + expect(utils.isStagingEnvBsstagHost('hub-preprod.bsstag.com')).toBe(false) + expect(getCloudProvider({ options: { hostname: 'hub-preprod.bsstag.com' } } as any)).toEqual('unknown_grid') + }) + + it('rejects look-alike hosts for BROWSERSTACK_ENV=staging', () => { + delete process.env.BROWSERSTACK_STAGING_ENV + process.env.BROWSERSTACK_ENV = 'staging' + expect(utils.isStagingEnvBsstagHost('evilbsstag.com')).toBe(false) + expect(utils.isStagingEnvBsstagHost('hub.bsstag.com.attacker.io')).toBe(false) + }) + + it('treats a blank BROWSERSTACK_STAGING_ENV as unset', () => { + process.env.BROWSERSTACK_STAGING_ENV = ' ' + expect(utils.isStagingEnvBsstagHost('hub-k8s.bsstag.com')).toBe(false) + expect(getCloudProvider({ options: { hostname: 'hub-k8s.bsstag.com' } } as any)).toEqual('unknown_grid') + }) +}) + describe('isBrowserstackSession', () => { it('return false if run locally', () => { expect(isBrowserstackSession({})).toEqual(false)