Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/pr-283.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.
59 changes: 57 additions & 2 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,17 @@ 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 Observability
this._buildIdentifier = capability['browserstack.buildIdentifier']?.toString()
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 @@ -160,10 +165,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 @@ -205,6 +218,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: Capabilities.DesiredCapabilities | 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: any, caps: any) {
try {
Expand Down Expand Up @@ -1085,7 +1137,10 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
/**
* The dashboard appends the identifier to the buildName, so it needs one. SDK-4748:
* BROWSERSTACK_BUILD_NAME used to force this branch too, discarding any configured
* identifier; this service never reads that var as a buildName source, so it no longer does.
* identifier. SDK-7075 made that var a buildName source - the constructor folds it into
* the caps of a BrowserStack session, or of any caps carrying bstack:options - so this
* branch now fires only when neither the caps nor (for those caps) the environment supply
* a buildName.
*/
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 @@ -775,6 +775,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 @@ -1254,8 +1394,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 @@ -1288,23 +1428,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