Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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; every other set value enables it — `1`, `yes`, and also `0`, `no` and `off`** — which is how `BROWSERSTACK_LOCAL` already behaves on every other BrowserStack SDK. Surrounding whitespace is ignored, so a trailing newline from `BROWSERSTACK_LOCAL=$(...)` or a `.env` file cannot flip the meaning, and a whitespace-only value counts as unset. 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
51 changes: 50 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,53 @@ 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) {
/**
* Trimmed before the compare. `isFalse()` is an exact match, so a padded `" false "` or the
* `"false\n"` that `VAR=$(cmd)` capture and `.env` files routinely produce would miss it and
* `!isFalse(...)` would ENABLE the tunnel the user was switching off. The binary trims for
* the same reason (`updateConfigWithBooleanValues`). Trimming also makes a whitespace-only
* value read as unset, since `isUndefined()` treats `''` as unset.
*/
const localEnvValue = process.env[BROWSERSTACK_LOCAL]?.trim()

if (!isUndefined(localEnvValue)) {
/**
* Only a literal `false` disables Local — any other set value enables it, `0` and `off`
* included. 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(localEnvValue)
}

/**
* 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
203 changes: 202 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,207 @@ 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. `0` / `no` / `off` are pinned deliberately: they read as "off" to a
* human but are not the literal `false`, so they enable, as they do on every other SDK.
*/
it.each(['1', 'yes', 'TRUE', 'True', '0', 'no', 'off'])('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)
})

/**
* The padded variants are what CI-injected env, `.env` files and `VAR=$(cmd)` capture
* actually produce. Without the trim they miss the exact `isFalse()` compare and enable
* the tunnel the user was switching off.
*/
it.each(['false', 'FALSE', 'False', ' false ', 'false\n', 'false\r\n', '\tFalse '])('should disable Local for the literal %j', 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')
})

/**
* A whitespace-only value trims to `''`, which `isUndefined()` reads as unset — so it must
* leave the config alone rather than enable Local off a blank string.
*/
it.each([' ', '\n', '\t'])('should treat a whitespace-only BROWSERSTACK_LOCAL (%j) as unset', async (value) => {
process.env.BROWSERSTACK_LOCAL = value
const service = new BrowserstackLauncher({ browserstackLocal: false, 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 keep honouring a config-enabled tunnel when BROWSERSTACK_LOCAL is whitespace-only', async () => {
process.env.BROWSERSTACK_LOCAL = ' '
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('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