Skip to content
5 changes: 5 additions & 0 deletions .changeset/pr-282.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wdio/browserstack-service": patch
---

- Fixed `BROWSERSTACK_PROJECT_NAME` and `BROWSERSTACK_BUILD_NAME` being ignored — the environment now takes precedence over the project and build names in your config file, matching the other BrowserStack SDKs.
62 changes: 58 additions & 4 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,18 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
capability['browserstack.wdioService'] = BSTACK_SERVICE_VERSION
}
}
const envNames = this._applyEnvBuildNames(capability)

// Need this details for sending data to Test Reporting and Analytics
this._buildIdentifier = capability['browserstack.buildIdentifier']?.toString()
// @ts-expect-error ToDo: fix invalid cap
this._buildName = capability.build?.toString()
this._buildName = envNames.buildName ?? capability.build?.toString()
if (envNames.projectName) {
this._projectName = envNames.projectName
}
} else {
capability['bstack:options'].wdioService = BSTACK_SERVICE_VERSION
this._applyEnvBuildNames(capability)
this._buildName = capability['bstack:options'].buildName
this._projectName = capability['bstack:options'].projectName
this._buildTag = capability['bstack:options'].buildTag
Expand Down Expand Up @@ -197,10 +202,18 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
(caps.capabilities as WebdriverIO.Capabilities)['browserstack.wdioService'] = BSTACK_SERVICE_VERSION
}
}
const envNames = this._applyEnvBuildNames(caps.capabilities as WebdriverIO.Capabilities)
if (envNames.buildName) {
this._buildName = envNames.buildName
}
if (envNames.projectName) {
this._projectName = envNames.projectName
}
this._buildIdentifier = (caps.capabilities as WebdriverIO.Capabilities)['browserstack.buildIdentifier']
} else {
const bstackOptions = (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']
bstackOptions!.wdioService = BSTACK_SERVICE_VERSION
this._applyEnvBuildNames(caps.capabilities as WebdriverIO.Capabilities)
this._buildName = bstackOptions!.buildName
this._projectName = bstackOptions!.projectName
this._buildTag = bstackOptions!.buildTag
Expand Down Expand Up @@ -241,6 +254,45 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
}
}

/**
* SDK-7075: BROWSERSTACK_PROJECT_NAME / BROWSERSTACK_BUILD_NAME take precedence over the
* config file, as every other SDK and the binary's own env->config map already do. They are
* written back into the caps so the session itself carries them: into bstack:options for W3C
* caps (including one this constructor just created), and into the top-level project/build
* keys that legacy caps name a build with - added on a BrowserStack session that has no
* bstack:options, otherwise only overwritten where already present. Blank values are ignored.
* Returns the values applied, for the Test Reporting fields - none for the caps of a
* non-BrowserStack session, which are left untouched so the reported names match the session.
*/
private _applyEnvBuildNames (capability: WebdriverIO.Capabilities): { projectName?: string, buildName?: string } {
const bstackOptions = capability['bstack:options']
if (!bstackOptions && !isBStackSession(this._config)) {
return {}
}
const projectName = process.env.BROWSERSTACK_PROJECT_NAME?.trim() || undefined
const buildName = process.env.BROWSERSTACK_BUILD_NAME?.trim() || undefined
if (bstackOptions) {
if (projectName) {
bstackOptions.projectName = projectName
}
if (buildName) {
bstackOptions.buildName = buildName
}
}
// Legacy caps name a build with top-level keys. Without bstack:options they are the only
// place the name can go; next to bstack:options, any the caps already carry are aligned
// too, so the session never sends two different names.
const legacyCaps = capability as Record<string, unknown>
const writeLegacy = (key: string) => !bstackOptions || key in legacyCaps
if (projectName && writeLegacy('project')) {
legacyCaps.project = projectName
}
if (buildName && writeLegacy('build')) {
legacyCaps.build = buildName
}
return { projectName, buildName }
}

@PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_SETUP)
async onWorkerStart (cid: string, caps: WebdriverIO.Capabilities) {
try {
Expand Down Expand Up @@ -1197,9 +1249,11 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
* A buildIdentifier is only meaningful next to a buildName - the dashboard appends it
* to that name. BROWSERSTACK_BUILD_NAME used to force this branch as well, which
* silently discarded every explicitly configured buildIdentifier whenever that env var
* happened to be exported (SDK-4748). This service never reads BROWSERSTACK_BUILD_NAME
* as a buildName source, and unlike the yml-driven SDKs it has no default identifier to
* suppress, so the env var no longer takes part in this decision.
* happened to be exported (SDK-4748). SDK-7075 then made BROWSERSTACK_BUILD_NAME a
* buildName source - the constructor folds it into the caps of a BrowserStack session, or
* of any caps carrying bstack:options - so the env var no longer suppresses the identifier;
* it supplies the very name the identifier is appended to. This branch therefore fires
* only when neither the caps nor (for those caps) the environment name a build.
*/
if (!this._buildName) {
this._updateCaps(capabilities, 'buildIdentifier')
Expand Down
165 changes: 155 additions & 10 deletions packages/browserstack-service/tests/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,146 @@ describe('constructor', () => {
delete process.env[RERUN_TESTS_ENV]
})

describe('BROWSERSTACK_PROJECT_NAME / BROWSERSTACK_BUILD_NAME (SDK-7075)', () => {
const setEnvNames = () => {
process.env.BROWSERSTACK_PROJECT_NAME = 'project from env'
process.env.BROWSERSTACK_BUILD_NAME = 'build from env'
}

afterEach(() => {
delete process.env.BROWSERSTACK_PROJECT_NAME
delete process.env.BROWSERSTACK_BUILD_NAME
})

it('should prefer the env vars over the values in the caps', async () => {
setEnvNames()
const caps: any = [{ 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } }]
const service: any = new BrowserstackLauncher(options as any, caps, config)

expect(caps).toEqual([
{ 'bstack:options': { wdioService: pkg.version, projectName: 'project from env', buildName: 'build from env' } }
])
expect(service._projectName).toEqual('project from env')
expect(service._buildName).toEqual('build from env')
})

it('should keep the caps projectName / buildName when the env vars are not set', async () => {
const caps: any = [{ 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } }]
new BrowserstackLauncher(options as any, caps, config)

expect(caps).toEqual([
{ 'bstack:options': { wdioService: pkg.version, projectName: 'project from config', buildName: 'build from config' } }
])
})

it('should ignore blank env values', async () => {
process.env.BROWSERSTACK_PROJECT_NAME = ' '
process.env.BROWSERSTACK_BUILD_NAME = ''
const caps: any = [{ 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } }]
new BrowserstackLauncher(options as any, caps, config)

expect(caps[0]['bstack:options']).toMatchObject({ projectName: 'project from config', buildName: 'build from config' })
})

it('should apply the env vars to the bstack:options the service creates for extension caps', async () => {
setEnvNames()
const caps: any = [{ 'goog:chromeOptions': {} }]
const service: any = new BrowserstackLauncher(options as any, caps, config)

expect(caps).toEqual([{
'goog:chromeOptions': {},
'bstack:options': { wdioService: pkg.version, projectName: 'project from env', buildName: 'build from env' }
}])
expect(service._projectName).toEqual('project from env')
expect(service._buildName).toEqual('build from env')
})

it('should apply the env vars to the top-level project / build keys of legacy caps', async () => {
setEnvNames()
const caps: any = [{ project: 'project from config', build: 'build from config' }]
const service: any = new BrowserstackLauncher(options as any, caps, config)

expect(caps).toEqual([{
'browserstack.wdioService': pkg.version,
project: 'project from env',
build: 'build from env'
}])
expect(service._projectName).toEqual('project from env')
expect(service._buildName).toEqual('build from env')
})

it('should align top-level project / build keys that sit next to bstack:options', async () => {
setEnvNames()
const caps: any = [
{ 'bstack:options': { buildName: 'build from config' }, project: 'project from config', build: 'build from config' },
{ 'bstack:options': {} }
]
new BrowserstackLauncher(options as any, caps, config)

expect(caps[0]).toEqual({
'bstack:options': { wdioService: pkg.version, projectName: 'project from env', buildName: 'build from env' },
project: 'project from env',
build: 'build from env'
})
expect(caps[1]).toEqual({
'bstack:options': { wdioService: pkg.version, projectName: 'project from env', buildName: 'build from env' }
})
})

it('should prefer the env vars for an object of capabilities', async () => {
setEnvNames()
const caps: any = { browserA: { capabilities: { 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } } } }
new BrowserstackLauncher(options as BrowserstackConfig & Options.Testrunner, caps, config)

expect(caps).toEqual({ 'browserA': { 'capabilities': { 'bstack:options': { 'wdioService': pkg.version, 'projectName': 'project from env', 'buildName': 'build from env' } } } })
})

it('should apply the env vars for an object of capabilities without bstack:options', async () => {
setEnvNames()
const caps: any = { browserA: { capabilities: { 'goog:chromeOptions': {} } }, browserB: { capabilities: {} } }
const service: any = new BrowserstackLauncher(options as BrowserstackConfig & Options.Testrunner, caps, config)

expect(caps.browserA.capabilities['bstack:options']).toEqual({ wdioService: pkg.version, projectName: 'project from env', buildName: 'build from env' })
expect(caps.browserB.capabilities).toMatchObject({ project: 'project from env', build: 'build from env' })
expect(service._projectName).toEqual('project from env')
expect(service._buildName).toEqual('build from env')
})

it('should leave a non-BrowserStack session untouched', async () => {
setEnvNames()
const spy = vi.spyOn(utils, 'isBStackSession').mockReturnValue(false)
try {
const caps: any = [{ build: 'build from config' }]
const service: any = new BrowserstackLauncher(options as any, caps, config)

expect(caps).toEqual([{ build: 'build from config' }])
expect(service._buildName).toEqual('build from config')
expect(service._projectName).toBeUndefined()
} finally {
spy.mockRestore()
}
})

it('should trim padded env values', async () => {
process.env.BROWSERSTACK_PROJECT_NAME = ' project from env '
process.env.BROWSERSTACK_BUILD_NAME = ' build from env '
const caps: any = [{ 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } }]
new BrowserstackLauncher(options as any, caps, config)

expect(caps[0]['bstack:options']).toMatchObject({ projectName: 'project from env', buildName: 'build from env' })
})

it('should override only the name the env sets', async () => {
process.env.BROWSERSTACK_BUILD_NAME = 'build from env'
const caps: any = [{ 'bstack:options': { projectName: 'project from config', buildName: 'build from config' } }]
const service: any = new BrowserstackLauncher(options as any, caps, config)

expect(caps[0]['bstack:options']).toMatchObject({ projectName: 'project from config', buildName: 'build from env' })
expect(service._projectName).toEqual('project from config')
expect(service._buildName).toEqual('build from env')
})
})

describe('#non-bstack session', () => {
const spy = vi.spyOn(utils, 'isBStackSession')
it('should not add service version to caps', async () => {
Expand Down Expand Up @@ -1394,8 +1534,8 @@ describe('_handleBuildIdentifier', () => {

/**
* SDK-4748: BROWSERSTACK_BUILD_NAME used to delete an explicitly configured
* buildIdentifier outright. It is not a buildName source for this service, so it must
* not influence the identifier at all once a buildName is present in the caps.
* buildIdentifier outright. Since SDK-7075 it is a buildName source, so it must not
* suppress the identifier - it names the build the identifier is appended to.
*/
it('should keep buildIdentifier when BROWSERSTACK_BUILD_NAME is defined as env var and buildName is in caps', async() => {
process.env.BROWSERSTACK_BUILD_NAME = 'browserstack wdio build'
Expand Down Expand Up @@ -1428,23 +1568,28 @@ describe('_handleBuildIdentifier', () => {
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('2026-09-27_14-35-36')
})

it('should still delete buildIdentifier if buildName is absent and BROWSERSTACK_BUILD_NAME is defined as env var', async() => {
/**
* SDK-7075: the constructor now folds BROWSERSTACK_BUILD_NAME into the caps, so a buildName
* IS present even when the caps themselves carried none, and the identifier is appended to
* that env-sourced name instead of being dropped. A buildName counts as absent only when
* neither the caps nor the environment supply one - see the BUILD_RUN_IDENTIFIER test below.
*/
it('should resolve buildIdentifier against the env buildName when the caps carry no buildName', async() => {
process.env.BROWSERSTACK_BUILD_NAME = 'browserstack wdio build'
const caps: any = [{
'bstack:options': {
buildIdentifier: '#${BUILD_NUMBER}'
}
}]
const updatedcaps: any = [{
'bstack:options': {
wdioService: pkg.version
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

vi.spyOn(utils, 'getCiInfo').mockReturnValueOnce(null)
vi.spyOn(service, '_getLocalBuildNumber').mockReturnValueOnce('1')
vi.spyOn(service, '_updateLocalBuildCache').mockImplementation(() => {})
service._handleBuildIdentifier(caps)
expect(caps[0]).toMatchObject(updatedcaps[0])
expect(caps[0]['bstack:options']?.buildIdentifier).toBeUndefined()

expect(caps[0]['bstack:options']?.buildName).toEqual('browserstack wdio build')
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('#1')
})

it('should prefer BROWSERSTACK_BUILD_RUN_IDENTIFIER over the configured buildIdentifier', async() => {
Expand Down
Loading