diff --git a/.changeset/pr-282.md b/.changeset/pr-282.md new file mode 100644 index 00000000..5f24eaf8 --- /dev/null +++ b/.changeset/pr-282.md @@ -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. diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index d9c95f02..d5c135b3 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -162,13 +162,18 @@ 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 Test Reporting and Analytics this._buildIdentifier = capability['browserstack.buildIdentifier']?.toString() // @ts-expect-error ToDo: fix invalid cap - 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 @@ -197,10 +202,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 @@ -241,6 +254,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: 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 + 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: string, caps: WebdriverIO.Capabilities) { try { @@ -1197,9 +1249,11 @@ export default class BrowserstackLauncherService implements Services.ServiceInst * 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. + * happened to be exported (SDK-4748). SDK-7075 then made BROWSERSTACK_BUILD_NAME a + * buildName source - the constructor folds it into the caps of a BrowserStack session, or + * of any caps carrying bstack:options - so the env var no longer suppresses the identifier; + * it supplies the very name the identifier is appended to. This branch therefore fires + * only when neither the caps nor (for those caps) the environment name a build. */ if (!this._buildName) { this._updateCaps(capabilities, 'buildIdentifier') diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index f6216c7a..43562b43 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -918,6 +918,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 () => { @@ -1394,8 +1534,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' @@ -1428,23 +1568,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() => {