diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md new file mode 100644 index 0000000..d5720ae --- /dev/null +++ b/.changeset/pr-151.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": patch +--- + +- Fixed `BROWSERSTACK_LOCAL` and `BROWSERSTACK_LOCAL_IDENTIFIER` being ignored. Both env vars now configure BrowserStack Local — the tunnel is launched and the `local` / `localIdentifier` capabilities reach the session — and take precedence over `browserstackLocal` / `opts.localIdentifier` in `wdio.conf.js`, matching the other BrowserStack SDKs. Only a literal `BROWSERSTACK_LOCAL=false` disables Local; any other set value enables it, so `BROWSERSTACK_LOCAL=1` behaves the same here as on every other SDK. An identifier on its own still does not enable Local. diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 839033e..0f75ac9 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -129,6 +129,12 @@ export const BSTACK_A11Y_POLLING_TIMEOUT = 'BSTACK_A11Y_POLLING_TIMEOUT' // Whether session is a accessibility session export const BROWSERSTACK_ACCESSIBILITY = 'BROWSERSTACK_ACCESSIBILITY' +// Whether to route the session through a BrowserStack Local tunnel +export const BROWSERSTACK_LOCAL = 'BROWSERSTACK_LOCAL' + +// Identifier of the BrowserStack Local tunnel to bind the session to +export const BROWSERSTACK_LOCAL_IDENTIFIER = 'BROWSERSTACK_LOCAL_IDENTIFIER' + // Whether session is a test reporting session (new name) export const BROWSERSTACK_TEST_REPORTING = 'BROWSERSTACK_TEST_REPORTING' diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 4db272e..1181eb4 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -44,6 +44,7 @@ import { mergeChromeOptions, normalizeTestReportingConfig, normalizeTestReportingEnvVariables, + normalizeLocalEnvVariables, isValidEnabledValue, isMultiRemoteCaps, validateSkipAppOverride @@ -98,6 +99,9 @@ export default class BrowserstackLauncherService implements Services.ServiceInst normalizeTestReportingConfig(this._options) normalizeTestReportingEnvVariables() + + //normalizing BrowserStack Local config from env variables + normalizeLocalEnvVariables(this._options) this.browserStackConfig = BrowserStackConfig.getInstance(_options, _config) if (Array.isArray(capabilities)) { capabilities diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 11e1bdd..e18e211 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -47,7 +47,9 @@ import { APP_ALLY_ISSUES_ENDPOINT, TEST_REPORTING_PROJECT_NAME, CLI_DEBUG_LOGS_FILE, - WDIO_NAMING_PREFIX + WDIO_NAMING_PREFIX, + BROWSERSTACK_LOCAL, + BROWSERSTACK_LOCAL_IDENTIFIER } from './constants.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' @@ -1289,6 +1291,44 @@ export function normalizeTestReportingEnvVariables(){ } +/** + * Resolve BrowserStack Local settings from the environment onto the service options. + * + * `BROWSERSTACK_LOCAL` / `BROWSERSTACK_LOCAL_IDENTIFIER` are the SDK-wide env vars for Local + * (the `browserstackLocal` / `localIdentifier` entries of the binary's EnvCapsMapping). This + * service reads Local purely off the `wdio.conf.js` service options, so without this the env + * vars were silently dropped — no tunnel was launched and no `local` / `localIdentifier` + * capability reached the session (SDK-7075). + * + * The env var wins over `wdio.conf.js`, matching the binary's `updateConfigWithEnvVars` and + * `getObservabilityUser` / `getObservabilityKey` / `getObservabilityProject` below. + */ +export function normalizeLocalEnvVariables(_options: BrowserstackConfig & Options.Testrunner) { + if (!isUndefined(process.env[BROWSERSTACK_LOCAL])) { + /** + * Only a literal `false` disables Local — any other set value enables it. This is the + * binary's semantics, not a looser reading of it: `updateConfigWithBooleanValues` + * coerces only `'true'`/`'false'` and leaves every other string as-is, and + * `getLocalConfig()` then truthiness-checks the result. So `BROWSERSTACK_LOCAL=1` + * enables Local on every other SDK, and must here too. + * + * Using `isTrue()` instead would resolve `1` / `yes` to `false` and — because the env + * var wins — would silently switch OFF a tunnel that `browserstackLocal: true` in + * `wdio.conf.js` had switched on. + */ + _options.browserstackLocal = !isFalse(process.env[BROWSERSTACK_LOCAL]) + } + + /** + * An identifier on its own must not turn Local on — enablement keys off `browserstackLocal` + * alone, the same way `getLocalConfig()` does in the binary. A stale + * `BROWSERSTACK_LOCAL_IDENTIFIER` left in a CI environment therefore stays inert. + */ + if (!isUndefined(process.env[BROWSERSTACK_LOCAL_IDENTIFIER])) { + _options.opts = { ..._options.opts, localIdentifier: process.env[BROWSERSTACK_LOCAL_IDENTIFIER] } + } +} + export function getObservabilityUser(options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner) { if (process.env.BROWSERSTACK_USERNAME) { return process.env.BROWSERSTACK_USERNAME diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index ec6b015..a8888b3 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' // @ts-expect-error mock feature import { Local, mockStart } from 'browserstack-local' import got from 'got' @@ -617,6 +617,175 @@ describe('onPrepare', () => { }) }) +describe('onPrepare with BrowserStack Local env variables (SDK-7075)', () => { + const caps: any = [{}] + const config = { + user: 'foobaruser', + key: '12345678901234567890', + capabilities: [] + } + vi.spyOn(utils, 'launchTestSession').mockImplementation(() => {}) + vi.spyOn(utils, 'isBStackSession').mockImplementation(() => {return true}) + + beforeEach(() => { + delete process.env.BROWSERSTACK_LOCAL + delete process.env.BROWSERSTACK_LOCAL_IDENTIFIER + }) + + afterEach(() => { + delete process.env.BROWSERSTACK_LOCAL + delete process.env.BROWSERSTACK_LOCAL_IDENTIFIER + }) + + it('should start Local when BROWSERSTACK_LOCAL is set and the config says nothing', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeDefined() + expect(capabilities[0]['bstack:options']).toEqual({ local: true, 'testhubBuildUuid': buildHashedId, 'buildProductMap': productMap }) + }) + + it('should add the "localIdentifier" capability from BROWSERSTACK_LOCAL_IDENTIFIER', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + process.env.BROWSERSTACK_LOCAL_IDENTIFIER = 'local_identifier_TFnBzD' + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeDefined() + expect(capabilities[0]['bstack:options']).toEqual({ local: true, localIdentifier: 'local_identifier_TFnBzD', 'testhubBuildUuid': buildHashedId, 'buildProductMap': productMap }) + }) + + it('should add the "browserstack.localIdentifier" capability when no "bstack:options" is present', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + process.env.BROWSERSTACK_LOCAL_IDENTIFIER = 'local_identifier_TFnBzD' + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + const capabilities = [{}] + + await service.onPrepare(config, capabilities) + + expect(capabilities[0]).toEqual({ 'browserstack.local': true, 'browserstack.localIdentifier': 'local_identifier_TFnBzD', 'browserstack.testhubBuildUuid': buildHashedId, 'browserstack.buildProductMap': productMap }) + }) + + it('should pass the identifier from BROWSERSTACK_LOCAL_IDENTIFIER to the Local binary', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + process.env.BROWSERSTACK_LOCAL_IDENTIFIER = 'local_identifier_TFnBzD' + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + + await service.onPrepare(config, [{ 'bstack:options': {} }]) + + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ localIdentifier: 'local_identifier_TFnBzD' }), + expect.anything() + ) + }) + + it('should let BROWSERSTACK_LOCAL override browserstackLocal from the config', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + const service = new BrowserstackLauncher({ browserstackLocal: false, testObservability: false, percy: false } as any, caps, config) + + await service.onPrepare(config, [{ 'bstack:options': {} }]) + + expect(service.browserstackLocal).toBeDefined() + }) + + it('should let BROWSERSTACK_LOCAL=false override browserstackLocal from the config', async () => { + process.env.BROWSERSTACK_LOCAL = 'false' + const service = new BrowserstackLauncher({ browserstackLocal: true, testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeUndefined() + expect(capabilities[0]['bstack:options']).not.toHaveProperty('local') + }) + + it('should let BROWSERSTACK_LOCAL_IDENTIFIER override opts.localIdentifier from the config', async () => { + process.env.BROWSERSTACK_LOCAL = 'true' + process.env.BROWSERSTACK_LOCAL_IDENTIFIER = 'from-env' + const service = new BrowserstackLauncher({ + browserstackLocal: true, + opts: { localIdentifier: 'from-config' }, + testObservability: false, + percy: false + } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(capabilities[0]['bstack:options']).toEqual({ local: true, localIdentifier: 'from-env', 'testhubBuildUuid': buildHashedId, 'buildProductMap': productMap }) + }) + + /** + * The binary coerces only 'true'/'false' and truthiness-checks the rest, so every + * other SDK enables Local on `BROWSERSTACK_LOCAL=1`. Resolving these to `false` here + * would both diverge from that and — since env wins — switch OFF a tunnel the config + * had switched on. + */ + it.each(['1', 'yes', 'TRUE', 'True'])('should enable Local for the set value %s', async (value) => { + process.env.BROWSERSTACK_LOCAL = value + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeDefined() + expect(capabilities[0]['bstack:options']).toHaveProperty('local', true) + }) + + it('should not let a non-"false" value switch off a config-enabled tunnel', async () => { + process.env.BROWSERSTACK_LOCAL = '1' + const service = new BrowserstackLauncher({ browserstackLocal: true, testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeDefined() + expect(capabilities[0]['bstack:options']).toHaveProperty('local', true) + }) + + it.each(['false', 'FALSE', 'False'])('should disable Local for the literal %s', async (value) => { + process.env.BROWSERSTACK_LOCAL = value + const service = new BrowserstackLauncher({ browserstackLocal: true, testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeUndefined() + expect(capabilities[0]['bstack:options']).not.toHaveProperty('local') + }) + + it('should not enable Local from BROWSERSTACK_LOCAL_IDENTIFIER alone', async () => { + process.env.BROWSERSTACK_LOCAL_IDENTIFIER = 'local_identifier_TFnBzD' + const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeUndefined() + expect(capabilities[0]['bstack:options']).not.toHaveProperty('localIdentifier') + }) + + it('should keep honouring the config when no Local env variable is set', async () => { + const service = new BrowserstackLauncher({ + browserstackLocal: true, + opts: { localIdentifier: 'from-config' }, + testObservability: false, + percy: false + } as any, caps, config) + const capabilities = [{ 'bstack:options': {} }] + + await service.onPrepare(config, capabilities) + + expect(service.browserstackLocal).toBeDefined() + expect(capabilities[0]['bstack:options']).toEqual({ local: true, localIdentifier: 'from-config', 'testhubBuildUuid': buildHashedId, 'buildProductMap': productMap }) + }) +}) + describe('onComplete', () => { it('should do nothing if browserstack local is turned on, but not running', () => { const service = new BrowserstackLauncher({} as any, [{}] as any, {} as any)