diff --git a/.changeset/pr-237.md b/.changeset/pr-237.md new file mode 100644 index 00000000..9eba345c --- /dev/null +++ b/.changeset/pr-237.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 4db272ea..5d3ee474 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -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 @@ -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', @@ -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 } /** diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index ec6b0157..e2ae01e5 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -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' @@ -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': { @@ -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': { @@ -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() => {