Skip to content
Merged
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-237.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.
70 changes: 55 additions & 15 deletions packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type BrowserstackLocal = BrowserstackLocalLauncher.Local & {
stop(callback: (err?: Error) => void): void
}

// Resolved by dedicated logic in _handleBuildIdentifier; the generic ${ENV_VAR} sweep skips 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 @@ -1062,17 +1065,39 @@ export default class BrowserstackLauncherService implements Services.ServiceInst
}

_handleBuildIdentifier(capabilities?: Capabilities.RemoteCapabilities) {
/**
* Precedence per the SDK-wide contract (env > config file): BROWSERSTACK_BUILD_IDENTIFIER,
* then BROWSERSTACK_BUILD_RUN_IDENTIFIER, then the service options / caps value already
* folded into this._buildIdentifier. wdio has no CLI arg, so that tier is absent here.
*/
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) {
/**
* 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.
*/
if (!this._buildName) {
this._updateCaps(capabilities, 'buildIdentifier')
// Clear the field too, not just the cap: launchTestSession sends it as the
// build-start build_identifier, which would otherwise report a value never applied.
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 @@ -1082,24 +1107,39 @@ 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 remaining ${ENV_VAR} placeholders (e.g. ${CUSTOM_DATE}) against process.env.
* Reserved tokens are skipped - BUILD_NUMBER is deliberately left literal when unresolvable,
* and picking up a raw one here would drop the 'CI ' prefix. Unset/blank stays literal.
*/
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
}

/**
Expand Down
185 changes: 182 additions & 3 deletions 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 @@ -1157,6 +1157,15 @@ 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} resolves from it, so leaving it set makes the
// "stays literal" assertions depend on the ambient environment.
delete process.env.BUILD_NUMBER
})

it('should update ${BUILD_NUMBER}', async() => {
const caps: any = [{
'bstack:options': {
Expand Down Expand Up @@ -1243,7 +1252,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 @@ -1259,7 +1304,141 @@ 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()
// launchTestSession forwards this field as build_identifier, so a stale
// value would report an identifier 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() knows a fixed vendor list; GitHub Actions / TeamCity still export a bare
// BUILD_NUMBER. The sweep must not pick it up, or the 'CI ' prefix is lost.
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` guards only nullish, so an exported-but-empty var would blank the identifier.
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