From 88a9c37784252355c7ed0f8b8be89b3b18e3dafc Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 17 Aug 2026 16:16:03 +0530 Subject: [PATCH 01/10] fix(browserstack-service): honour BROWSERSTACK_LOCAL / BROWSERSTACK_LOCAL_IDENTIFIER (SDK-7075) The service resolved BrowserStack Local purely from the `wdio.conf.js` service options, so the SDK-wide `BROWSERSTACK_LOCAL` / `BROWSERSTACK_LOCAL_IDENTIFIER` env vars were silently dropped: no tunnel was launched and no `local` / `localIdentifier` capability reached the session, leaving every session with `isLocalEnabled: false`. Add `normalizeLocalEnvVariables()` and call it from the launcher constructor alongside the existing `normalizeTestReporting*` normalizers. The env var wins over `wdio.conf.js`, matching `updateConfigWithEnvVars` in browserstack-binary and `getObservabilityUser` / `getObservabilityKey` here. An identifier on its own still does not enable Local, mirroring `getLocalConfig()` in the binary. Co-Authored-By: Claude Opus 5 (1M context) --- .../sdk-7075-honour-local-env-vars-v8.md | 5 + .../browserstack-service/src/constants.ts | 6 + packages/browserstack-service/src/launcher.ts | 4 + packages/browserstack-service/src/util.ts | 31 +++- .../tests/launcher.test.ts | 132 +++++++++++++++++- 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 .changeset/sdk-7075-honour-local-env-vars-v8.md diff --git a/.changeset/sdk-7075-honour-local-env-vars-v8.md b/.changeset/sdk-7075-honour-local-env-vars-v8.md new file mode 100644 index 0000000..9dca9d4 --- /dev/null +++ b/.changeset/sdk-7075-honour-local-env-vars-v8.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. 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..ce1d81f 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,33 @@ 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])) { + _options.browserstackLocal = isTrue(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..a01c969 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,136 @@ 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 }) + }) + + 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) From 51fbefa443210cd18a2760b832e4eb34639d1d0e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:16:51 +0000 Subject: [PATCH 02/10] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-151.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-151.md diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md new file mode 100644 index 0000000..cf1e655 --- /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 — matching the other BrowserStack SDKs. From e80df31a54eb10705186a383c35863b152cfa8aa Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 3 Sep 2026 17:26:49 +0530 Subject: [PATCH 03/10] fix(browserstack-service): only a literal false disables Local (SDK-7075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeLocalEnvVariables()` resolved BROWSERSTACK_LOCAL with `isTrue()`, which matches only the literal string "true". Every other set value — `1`, `yes` — resolved to `false`, and because the env var takes precedence over `wdio.conf.js`, that silently switched OFF a tunnel `browserstackLocal: true` had switched on. Pre-fix the config won and the tunnel started, so this was a regression. It also inverted the cross-SDK parity the change exists to provide. The binary stores the raw env string (core/config/env.js getEnvCaps), coerces only 'true'/'false' (core/config/index.js updateConfigWithBooleanValues) and then truthiness-checks it in getLocalConfig(), so BROWSERSTACK_LOCAL=1 enables Local on every other SDK. `!isFalse()` reproduces those semantics exactly for every set value; the empty string is still caught by the existing isUndefined() guard, so an exported-but-empty var cannot disable a config-enabled tunnel. Tests: 8 new cases in launcher.test.ts (130 total, was 122). Non-vacuous — restoring isTrue() fails 3 of them ('1', 'yes', and the config-enabled-tunnel regression guard). 45/45 test files pass; tsc -p tsconfig.prod.json clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../sdk-7075-honour-local-env-vars-v8.md | 2 +- packages/browserstack-service/src/util.ts | 13 ++++++- .../tests/launcher.test.ts | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.changeset/sdk-7075-honour-local-env-vars-v8.md b/.changeset/sdk-7075-honour-local-env-vars-v8.md index 9dca9d4..d5720ae 100644 --- a/.changeset/sdk-7075-honour-local-env-vars-v8.md +++ b/.changeset/sdk-7075-honour-local-env-vars-v8.md @@ -2,4 +2,4 @@ "@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. An identifier on its own still does not enable Local. +- 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/util.ts b/packages/browserstack-service/src/util.ts index ce1d81f..e18e211 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1305,7 +1305,18 @@ export function normalizeTestReportingEnvVariables(){ */ export function normalizeLocalEnvVariables(_options: BrowserstackConfig & Options.Testrunner) { if (!isUndefined(process.env[BROWSERSTACK_LOCAL])) { - _options.browserstackLocal = isTrue(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]) } /** diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index a01c969..a8888b3 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -720,6 +720,45 @@ describe('onPrepare with BrowserStack Local env variables (SDK-7075)', () => { 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) From b10f1fcb97f45ffad804bfa9aed748787779a15e Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 3 Sep 2026 18:32:17 +0530 Subject: [PATCH 04/10] chore(changeset): drop the duplicate auto-generated changeset (SDK-7075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.changeset/pr-151.md` (auto-generated by github-actions[bot] in 51fbefa from the PR template) and `.changeset/sdk-7075-honour-local-env-vars-v8.md` are both `patch` changesets for `@wdio/browserstack-service`, so `changeset version` would emit two near-identical bullets in the v8 CHANGELOG for one fix. Keep the hand-written one — it documents the env-over-config precedence and the false-only-disables semantics, which the generated copy omits. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/pr-151.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/pr-151.md diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md deleted file mode 100644 index cf1e655..0000000 --- a/.changeset/pr-151.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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 — matching the other BrowserStack SDKs. From 05f95b46e4437ce019c287f4a43546d1f73f121d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:09 +0000 Subject: [PATCH 05/10] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-151.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-151.md diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md new file mode 100644 index 0000000..cf1e655 --- /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 — matching the other BrowserStack SDKs. From 047ba25d5cafa948284c986c20e62735b2c5dab0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:24:48 +0000 Subject: [PATCH 06/10] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-151.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md index cf1e655..d5720ae 100644 --- a/.changeset/pr-151.md +++ b/.changeset/pr-151.md @@ -2,4 +2,4 @@ "@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 — matching the other BrowserStack SDKs. +- 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. From 46e012fc4b9b57ef43808218d9d3c71caeeb3214 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Thu, 3 Sep 2026 18:55:02 +0530 Subject: [PATCH 07/10] chore(changeset): keep only the bot-generated changeset (SDK-7075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses b10f1fc, which deleted the wrong one. `.changeset/pr-151.md` is generated by github-actions[bot] verbatim from the PR body's "Release notes (customer-facing)" section and is regenerated on every push — so deleting it does not stick, and holding a second hand-written changeset for the same package means `changeset version` emits two near-identical bullets in the v8 CHANGELOG for one fix. The full release note (env-over-config precedence, false-only-disables, identifier alone does not enable) now lives in that PR body section, so the generated changeset carries it. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/sdk-7075-honour-local-env-vars-v8.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/sdk-7075-honour-local-env-vars-v8.md diff --git a/.changeset/sdk-7075-honour-local-env-vars-v8.md b/.changeset/sdk-7075-honour-local-env-vars-v8.md deleted file mode 100644 index d5720ae..0000000 --- a/.changeset/sdk-7075-honour-local-env-vars-v8.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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. From 917981c753fdee59c100849e912d8c07ab65b843 Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 7 Sep 2026 21:01:08 +0530 Subject: [PATCH 08/10] fix(browserstack-service): trim BROWSERSTACK_LOCAL before the false compare (SDK-7075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isFalse()` is an exact, untrimmed compare, so a padded `" false "` or the `"false\n"` that `VAR=$(cmd)` capture and `.env` files routinely produce missed it and `!isFalse(...)` ENABLED the tunnel the user was switching off — the inverse of intent, on the disable path this normalization exists to add. Env wins over `wdio.conf.js`, so it also overrode an explicit `browserstackLocal: false`. Trimming once at the call site also makes a whitespace-only value read as unset, since `isUndefined()` already treats `''` as unset; previously it enabled Local off a blank string. Matches the binary's `updateConfigWithBooleanValues`, which trims before coercing (`core/config/index.js:204`). Note the binary's env-only fallback path skips that trim and shares the original bug — tracked separately, not replicated here. Tests: 11 new cases (padded/newline `false`, whitespace-only, and `0`/`no`/`off` pinned as deliberate enables). Removing the `.trim()` fails exactly 7 of them. --- .changeset/pr-151.md | 2 +- packages/browserstack-service/src/util.ts | 23 +++++++---- .../tests/launcher.test.ts | 38 +++++++++++++++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md index d5720ae..00b7a50 100644 --- a/.changeset/pr-151.md +++ b/.changeset/pr-151.md @@ -2,4 +2,4 @@ "@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. +- 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. diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index e18e211..438cdfc 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1304,19 +1304,28 @@ export function normalizeTestReportingEnvVariables(){ * `getObservabilityUser` / `getObservabilityKey` / `getObservabilityProject` below. */ export function normalizeLocalEnvVariables(_options: BrowserstackConfig & Options.Testrunner) { - if (!isUndefined(process.env[BROWSERSTACK_LOCAL])) { + /** + * 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. 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. + * 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(process.env[BROWSERSTACK_LOCAL]) + _options.browserstackLocal = !isFalse(localEnvValue) } /** diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index a8888b3..7aaf9a0 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -724,9 +724,10 @@ describe('onPrepare with BrowserStack Local env variables (SDK-7075)', () => { * 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. + * 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'])('should enable Local for the set value %s', async (value) => { + 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': {} }] @@ -748,7 +749,12 @@ describe('onPrepare with BrowserStack Local env variables (SDK-7075)', () => { expect(capabilities[0]['bstack:options']).toHaveProperty('local', true) }) - it.each(['false', 'FALSE', 'False'])('should disable Local for the literal %s', async (value) => { + /** + * 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': {} }] @@ -759,6 +765,32 @@ describe('onPrepare with BrowserStack Local env variables (SDK-7075)', () => { 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) From 0261607d97c1814bb3582b5dc887c04a334a4bb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:31:28 +0000 Subject: [PATCH 09/10] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-151.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md index 00b7a50..d5720ae 100644 --- a/.changeset/pr-151.md +++ b/.changeset/pr-151.md @@ -2,4 +2,4 @@ "@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. +- 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. From 6e6f181a1ad93948efacded57a62cc0797288800 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:34:32 +0000 Subject: [PATCH 10/10] chore(changeset): auto-generate from PR template (patch) --- .changeset/pr-151.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pr-151.md b/.changeset/pr-151.md index d5720ae..00b7a50 100644 --- a/.changeset/pr-151.md +++ b/.changeset/pr-151.md @@ -2,4 +2,4 @@ "@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. +- 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.