Skip to content
5 changes: 5 additions & 0 deletions .changeset/pr-151.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
4 changes: 4 additions & 0 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
mergeChromeOptions,
normalizeTestReportingConfig,
normalizeTestReportingEnvVariables,
normalizeLocalEnvVariables,
isValidEnabledValue,
isMultiRemoteCaps,
validateSkipAppOverride
Expand Down Expand Up @@ -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
Expand Down
42 changes: 41 additions & 1 deletion packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
171 changes: 170 additions & 1 deletion packages/browserstack-service/tests/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading