diff --git a/.changeset/pr-236.md b/.changeset/pr-236.md new file mode 100644 index 00000000..9eba345c --- /dev/null +++ b/.changeset/pr-236.md @@ -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. diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 390e4e80..5d98b825 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -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 @@ -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', @@ -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() { diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index 292cae42..f01d6e90 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -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': { @@ -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': { @@ -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() => {