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
7 changes: 7 additions & 0 deletions .changeset/pr-236.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@wdio/browserstack-service": patch
---

- Fixed a configured `buildIdentifier` being dropped from the build name when `BROWSERSTACK_BUILD_NAME` was set via environment variable.
- `BROWSERSTACK_BUILD_RUN_IDENTIFIER` and `BROWSERSTACK_BUILD_IDENTIFIER` are now honoured as build-identifier overrides.
- Placeholders such as `${CUSTOM_DATE}` in `buildIdentifier` are now substituted from the environment.
93 changes: 78 additions & 15 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ type BrowserstackLocal = BrowserstackLocalLauncher.Local & {
stop(callback: (err?: Error) => void): void
}

// Tokens with dedicated resolution inside _handleBuildIdentifier; the generic ${ENV_VAR}
// sweep must not reprocess them.
const RESERVED_BUILD_IDENTIFIER_TOKENS = new Set(['DATE_TIME', 'BUILD_NUMBER'])

export default class BrowserstackLauncherService implements Services.ServiceInstance {
browserstackLocal?: BrowserstackLocal
private _buildName?: string
Expand Down Expand Up @@ -1150,17 +1154,53 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
}

_handleBuildIdentifier(capabilities?: Capabilities.TestrunnerCapabilities) {
/**
* buildIdentifier resolution precedence, per the SDK-wide contract
* (CLI args > env vars > config file > script):
* 1. BROWSERSTACK_BUILD_IDENTIFIER - explicit env override
* 2. BROWSERSTACK_BUILD_RUN_IDENTIFIER - per-run signal, typically injected by CI
* 3. service options in wdio.conf.js / bstack:options in the capabilities, both of
* which onPrepare has already folded into this._buildIdentifier
* wdio exposes no CLI arg for buildIdentifier, so tier 1 of the contract is absent here.
* Mirrors browserstack-node-agent computeBuildIdentifier(), browserstack-python-sdk
* ENV_CAPS_TO_CONFIG['buildIdentifier'] and browserstack-csharp-sdk GetBuildIdentifier().
*/
const envBuildIdentifier = [
process.env.BROWSERSTACK_BUILD_IDENTIFIER,
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER
].find((value) => value && value.trim())
if (envBuildIdentifier) {
this._buildIdentifier = envBuildIdentifier.trim()
}

if (!this._buildIdentifier) {
return
}

if ((!this._buildName || process.env.BROWSERSTACK_BUILD_NAME) && this._buildIdentifier) {
/**
* 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.
*/
if (!this._buildName) {
this._updateCaps(capabilities, 'buildIdentifier')
/**
* Clear the in-memory field as well as the capability. launchTestSession reads
* this._buildIdentifier for the build-start payload's build_identifier, so leaving a
* resolved value here would report an identifier that was never applied anywhere
* visible. With the env tier above this is reachable with no user configuration at
* all, since CI commonly injects BROWSERSTACK_BUILD_RUN_IDENTIFIER globally.
*/
this._buildIdentifier = undefined
this.browserStackConfig.buildIdentifier = undefined
BStackLogger.warn('Skipping buildIdentifier as buildName is not passed.')
return
}

if (this._buildIdentifier && this._buildIdentifier.includes('${DATE_TIME}')){
if (this._buildIdentifier.includes('${DATE_TIME}')) {
const formattedDate = new Intl.DateTimeFormat('en-GB', {
month: 'short',
day: '2-digit',
Expand All @@ -1170,24 +1210,47 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
.format(new Date())
.replace(/ |, /g, '-')
this._buildIdentifier = this._buildIdentifier.replace('${DATE_TIME}', formattedDate)
this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier)
}

if (!this._buildIdentifier.includes('${BUILD_NUMBER}')) {
return
if (this._buildIdentifier.includes('${BUILD_NUMBER}')) {
const ciInfo = getCiInfo()
if (ciInfo !== null && ciInfo.build_number) {
this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', 'CI '+ ciInfo.build_number)
} else {
const localBuildNumber = this._getLocalBuildNumber()
if (localBuildNumber) {
this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', localBuildNumber)
}
}
}

const ciInfo = getCiInfo()
if (ciInfo !== null && ciInfo.build_number) {
this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', 'CI '+ ciInfo.build_number)
this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier)
} else {
const localBuildNumber = this._getLocalBuildNumber()
if (localBuildNumber) {
this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', localBuildNumber)
this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier)
/**
* Resolve any remaining ${ENV_VAR} placeholder against process.env, so an identifier
* such as '${CUSTOM_DATE}' behaves the same whatever source it arrived from.
*
* DATE_TIME and BUILD_NUMBER are excluded: both are resolved above by dedicated logic,
* and BUILD_NUMBER is deliberately left literal when neither getCiInfo() nor
* _getLocalBuildNumber() can supply one. Without the exclusion this sweep would pick up
* a raw process.env.BUILD_NUMBER on CI vendors getCiInfo() does not recognise, yielding a
* value without the 'CI ' prefix every other resolution path applies.
*
* A variable that is unset, empty or whitespace-only leaves its literal placeholder
* rather than blanking that part of the identifier, so nothing is silently lost.
*/
this._buildIdentifier = this._buildIdentifier.replace(
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
(match, varName) => {
if (RESERVED_BUILD_IDENTIFIER_TOKENS.has(varName)) {
return match
}
const envValue = process.env[varName]

return envValue && envValue.trim() ? envValue : match
}
}
)

this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier)
this.browserStackConfig.buildIdentifier = this._buildIdentifier
}

_updateBrowserStackPercyConfig() {
Expand Down
188 changes: 186 additions & 2 deletions packages/browserstack-service/tests/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,16 @@ describe('_handleBuildIdentifier', () => {
capabilities: []
}

afterEach(() => {
delete process.env.BROWSERSTACK_BUILD_NAME
delete process.env.BROWSERSTACK_BUILD_IDENTIFIER
delete process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER
// BUILD_NUMBER is not a BrowserStack variable, but the ${BUILD_NUMBER} token is
// resolved from it. Leaving it set would make the "stays literal" assertions depend
// on the ambient environment rather than on the code.
delete process.env.BUILD_NUMBER
})

it('should update ${BUILD_NUMBER}', async() => {
const caps: any = [{
'bstack:options': {
Expand Down Expand Up @@ -1329,7 +1339,43 @@ describe('_handleBuildIdentifier', () => {
expect(caps[0]).toMatchObject(updatedcaps[0])
})

it('should delete buildIdentifier if BROWSERSTACK_BUILD_NAME is defined as env var', async() => {
/**
* 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.
*/
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'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: '#${BUILD_NUMBER}'
}
}]
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]['bstack:options']?.buildIdentifier).toEqual('#1')
})

it('should keep a literal buildIdentifier untouched when BROWSERSTACK_BUILD_NAME is defined as env var', async() => {
process.env.BROWSERSTACK_BUILD_NAME = 'browserstack wdio build'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: '2026-09-27_14-35-36'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
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() => {
process.env.BROWSERSTACK_BUILD_NAME = 'browserstack wdio build'
const caps: any = [{
'bstack:options': {
Expand All @@ -1345,7 +1391,145 @@ describe('_handleBuildIdentifier', () => {

service._handleBuildIdentifier(caps)
expect(caps[0]).toMatchObject(updatedcaps[0])
delete process.env.BROWSERSTACK_BUILD_NAME
expect(caps[0]['bstack:options']?.buildIdentifier).toBeUndefined()
})

it('should prefer BROWSERSTACK_BUILD_RUN_IDENTIFIER over the configured buildIdentifier', async() => {
process.env.BROWSERSTACK_BUILD_NAME = 'browserstack wdio build'
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER = 'test_run_20260927_143536'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: '#${BUILD_NUMBER}'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('test_run_20260927_143536')
})

it('should prefer BROWSERSTACK_BUILD_IDENTIFIER over BROWSERSTACK_BUILD_RUN_IDENTIFIER', async() => {
process.env.BROWSERSTACK_BUILD_IDENTIFIER = 'explicit-env-identifier'
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER = 'per-run-identifier'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: 'from-caps'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('explicit-env-identifier')
})

it('should set buildIdentifier from env when none is configured anywhere', async() => {
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER = 'per-run-identifier'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('per-run-identifier')
})

it('should ignore a blank env buildIdentifier and keep the configured one', async() => {
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER = ' '
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: 'from-caps'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('from-caps')
})

it('should not set buildIdentifier from env when buildName is absent', async() => {
process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER = 'per-run-identifier'
const caps: any = [{
'bstack:options': {}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toBeUndefined()
// Also assert the in-memory field: launchTestSession forwards it as the build-start
// payload's build_identifier, so a stale value here would report an identifier that
// was never applied to any capability.
expect((service as any)._buildIdentifier).toBeUndefined()
})

it('should leave ${BUILD_NUMBER} literal rather than reading a raw BUILD_NUMBER env var', async() => {
// getCiInfo() recognises a fixed vendor list; on CI it does not know (GitHub Actions,
// TeamCity) a bare BUILD_NUMBER may still be exported. The generic ${ENV_VAR} sweep must
// not pick that up, or the identifier renders without the 'CI ' prefix every other
// resolution path applies.
process.env.BUILD_NUMBER = '394'
vi.spyOn(utils, 'getCiInfo').mockReturnValue(null as any)
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: '#${BUILD_NUMBER}'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)
vi.spyOn(service, '_getLocalBuildNumber').mockReturnValue(null)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('#${BUILD_NUMBER}')
})

it('should leave a placeholder literal when its env var is set but empty', async() => {
// `?? match` would only guard nullish, so an exported-but-empty variable would blank
// that part of the identifier instead of leaving the placeholder visible.
process.env.CUSTOM_DATE = ' '
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: 'run-${CUSTOM_DATE}'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('run-${CUSTOM_DATE}')
delete process.env.CUSTOM_DATE
})

it('should substitute an arbitrary ${ENV_VAR} placeholder in buildIdentifier', async() => {
process.env.CUSTOM_DATE = '2026-09-27_14-35-36'
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: 'run-${CUSTOM_DATE}'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('run-2026-09-27_14-35-36')
delete process.env.CUSTOM_DATE
})

it('should leave an unset ${ENV_VAR} placeholder literal', async() => {
delete process.env.NOT_SET_ANYWHERE
const caps: any = [{
'bstack:options': {
buildName: 'browserstack wdio build',
buildIdentifier: 'run-${NOT_SET_ANYWHERE}'
}
}]
const service = new BrowserstackLauncher(options as any, caps, config)

service._handleBuildIdentifier(caps)
expect(caps[0]['bstack:options']?.buildIdentifier).toEqual('run-${NOT_SET_ANYWHERE}')
})

it('should not evaluate buildIdentifier if buildIdentifier is not present in the caps', async() => {
Expand Down
Loading