From 8c05c350fc1a6ae8c48f385e7717567bef00da8d Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 19:16:23 +0530 Subject: [PATCH 1/6] feat(cli): apply the server SDK config override On the CLI flow the binary forwards the server's sdk_config as serverSdkConfig with the list of overridden keys. The service deep-merges exactly those keys into its options in the main process and every worker, mapping the keys getBinConfig renames back to their option names. In onPrepare a server parallelsPerPlatform becomes each capability's maxInstances, with maxInstances raised to fit, and server platforms layer onto the capability at the same index. Without the key list nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../browserstack-service/src/cli/cliUtils.ts | 97 ++++++++++++++ .../browserstack-service/src/cli/index.ts | 28 +++- .../browserstack-service/src/constants.ts | 2 + packages/browserstack-service/src/launcher.ts | 36 ++++- packages/browserstack-service/src/util.ts | 4 + .../tests/cli/cliUtils.test.ts | 124 ++++++++++++++++++ .../tests/cli/index.test.ts | 55 ++++++++ .../tests/launcher.test.ts | 76 +++++++++++ 8 files changed, 419 insertions(+), 3 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index b505e967..0506d1a2 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -26,6 +26,8 @@ import { isFalse, isTurboScale, shouldAddServiceVersion, + isObject, + isSafeKey, } from '../util.js' import PerformanceTester from '../instrumentation/performance/performance-tester.js' import { EVENTS as PerformanceEvents } from '../instrumentation/performance/constants.js' @@ -153,6 +155,101 @@ export class CLIUtils { return Object.keys(resolved).length > 0 ? resolved : undefined } + /** + * Applies the server config the binary forwards to the service options: exactly the listed + * keys, deep-merged, with the keys getBinConfig renames mapped back to their option names. + * `platforms` has no option; the launcher maps it onto the capabilities. + * @returns the keys applied + */ + static applyServerSdkConfig(options: Record, serverConfig: Record, keys: unknown[]): string[] { + const server = JSON.parse(JSON.stringify(serverConfig)) as Record + const updates: Record = {} + const applied: string[] = [] + for (const key of keys) { + if (typeof key !== 'string' || !isSafeKey(key) || !Object.prototype.hasOwnProperty.call(server, key)) { + continue + } + if (key === 'browserStackLocalOptions') { + updates.opts = this.mergeServerConfigValue(options.opts, server[key]) + } else if (key === 'testContextOptions') { + Object.assign(updates, this.getOptionsFromTestContextOptions(server[key])) + } else if (key !== 'platforms') { + updates[key] = this.mergeServerConfigValue(options[key], server[key]) + } + applied.push(key) + } + Object.assign(options, updates) + return applied + } + + /** + * Objects merge recursively, arrays element-wise (extra source elements are appended), any + * other value, null included, replaces. + */ + static mergeServerConfigValue(target: unknown, source: unknown): unknown { + if (isObject(target) && isObject(source)) { + const merged: Record = { ...(target as Record) } + for (const [key, value] of Object.entries(source as Record)) { + if (isSafeKey(key)) { + merged[key] = this.mergeServerConfigValue(merged[key], value) + } + } + return merged + } + if (Array.isArray(target) && Array.isArray(source)) { + const merged = [...target] + source.forEach((value, index) => { + merged[index] = this.mergeServerConfigValue(target[index], value) + }) + return merged + } + return source + } + + /** + * The service options getBinConfig derives testContextOptions from. + */ + static getOptionsFromTestContextOptions(testContextOptions: unknown): Record { + if (!isObject(testContextOptions)) { + return {} + } + const contextOptions = testContextOptions as Record + const options: Record = {} + if ('skipSessionName' in contextOptions) { + options.setSessionName = !isTrue(contextOptions.skipSessionName) + } + if ('skipSessionStatus' in contextOptions) { + options.setSessionStatus = !isTrue(contextOptions.skipSessionStatus) + } + for (const key of ['sessionNameOmitTestTitle', 'sessionNamePrependTopLevelSuiteTitle', 'sessionNameFormat']) { + if (key in contextOptions) { + options[key] = contextOptions[key] + } + } + return options + } + + /** + * Layers a server platforms[i] entry onto capability i, undoing getBinConfig's flattening: a + * key the capability's bstack:options holds goes back there, and so does a new key when the + * capability has bstack:options, unless it is vendor-prefixed. + */ + static applyServerPlatform(capability: Record, platform: unknown) { + if (!isObject(platform)) { + return + } + const bstackOptions = isObject(capability['bstack:options']) ? capability['bstack:options'] as Record : undefined + for (const [key, value] of Object.entries(platform as Record)) { + if (!isSafeKey(key)) { + continue + } + const intoBstackOptions = bstackOptions && key !== 'bstack:options' + && (key in bstackOptions || (!(key in capability) && !key.includes(':'))) + const target = intoBstackOptions ? bstackOptions : capability + target[key] = this.mergeServerConfigValue(target[key], value) + } + } + static getSdkVersion() { return bstackServiceVersion } diff --git a/packages/browserstack-service/src/cli/index.ts b/packages/browserstack-service/src/cli/index.ts index 101e8678..b5b951f1 100644 --- a/packages/browserstack-service/src/cli/index.ts +++ b/packages/browserstack-service/src/cli/index.ts @@ -12,7 +12,7 @@ import TestHubModule from './modules/testHubModule.js' import type { ChildProcess } from 'node:child_process' import type { StartBinSessionResponse } from '../grpc/index.js' import type BaseModule from './modules/baseModule.js' -import { BROWSERSTACK_ACCESSIBILITY, BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, CLI_STOP_TIMEOUT, TESTOPS_BUILD_COMPLETED_ENV, TESTOPS_SCREENSHOT_ENV } from '../constants.js' +import { BROWSERSTACK_ACCESSIBILITY, BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, CLI_STOP_TIMEOUT, TESTOPS_BUILD_COMPLETED_ENV, TESTOPS_SCREENSHOT_ENV, SERVER_SDK_CONFIG_KEY, SERVER_SDK_CONFIG_KEYS_KEY } from '../constants.js' import type { Options } from '@wdio/types' import TestOpsConfig from '../testOps/testOpsConfig.js' import WdioMochaTestFramework from './frameworks/wdioMochaTestFramework.js' @@ -21,7 +21,7 @@ import type TestFramework from './frameworks/testFramework.js' import WdioAutomationFramework from './frameworks/wdioAutomationFramework.js' import WebdriverIOModule from './modules/webdriverIOModule.js' import AccessibilityModule from './modules/accessibilityModule.js' -import { isTurboScale, processAccessibilityResponse, shouldAddServiceVersion } from '../util.js' +import { isObject, isTurboScale, processAccessibilityResponse, shouldAddServiceVersion } from '../util.js' import ObservabilityModule from './modules/observabilityModule.js' import type { BrowserstackConfig, BrowserstackOptions } from '../types.js' import PercyModule from './modules/percyModule.js' @@ -52,6 +52,7 @@ export class BrowserstackCLI { SDK_CLI_BIN_PATH: string | null = null logger = BStackLogger options: BrowserstackConfig & BrowserstackOptions | {} + serverConfigKeys: string[] = [] constructor() { this.initialized = false @@ -130,6 +131,7 @@ export class BrowserstackCLI { this.logger.info(`loadModules: binSessionId=${this.binSessionId}`) this.setConfig(startBinResponse) + this.applyServerConfig() APIUtils.updateURLSForGRR(this.config.apis as GRRUrls) this.setupTestFramework() @@ -173,6 +175,28 @@ export class BrowserstackCLI { this.configureModules() } + /** + * Deep-merges the server config the binary forwards into this process's service options, + * applying exactly the keys the binary lists as overridden. Runs before any reader of the + * options; without both fields (older binaries, no override) nothing changes. Any failure + * keeps the local options. + */ + applyServerConfig() { + try { + const serverConfig = this.config[SERVER_SDK_CONFIG_KEY] + const keys = this.config[SERVER_SDK_CONFIG_KEYS_KEY] + if (!isObject(serverConfig) || !Array.isArray(keys)) { + return + } + this.serverConfigKeys = CLIUtils.applyServerSdkConfig(this.options as Record, serverConfig as Record, keys) + if (this.serverConfigKeys.length) { + this.logger.info(`applyServerConfig: server config applied keys=${this.serverConfigKeys.join(',')}`) + } + } catch (error) { + this.logger.error(`applyServerConfig: keeping the local config, error=${util.format(error)}`) + } + } + private logBuildStartErrors(testhub: unknown) { if (process.env.WDIO_WORKER_ID) { return diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index 72066a96..21fc7331 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -46,6 +46,8 @@ export const BSTACK_SERVICE_VERSION = bstackServiceVersion export const UPDATED_CLI_ENDPOINT = 'sdk/v1/update_cli' export const CLI_STOP_TIMEOUT = 3000 +export const SERVER_SDK_CONFIG_KEY = 'serverSdkConfig' +export const SERVER_SDK_CONFIG_KEYS_KEY = 'serverSdkConfigKeys' export const NOT_ALLOWED_KEYS_IN_CAPS = ['includeTagsInTestingScope', 'excludeTagsInTestingScope', 'skipAppOverride', 'testManagementOptions', 'preferScenarioName', 'sessionNamePrependTopLevelSuiteTitle', 'sessionNameOmitTestTitle'] export const BROWSERSTACK_TEST_PLAN_ID = 'BROWSERSTACK_TEST_PLAN_ID' diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index 9fce6e12..9ea27483 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -23,7 +23,8 @@ import { VALID_APP_EXTENSION, BROWSERSTACK_PERCY, BROWSERSTACK_OBSERVABILITY, - WDIO_NAMING_PREFIX + WDIO_NAMING_PREFIX, + SERVER_SDK_CONFIG_KEY } from './constants.js' import { launchTestSession, @@ -358,6 +359,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst CLIUtils.setFrameworkDetail(WDIO_NAMING_PREFIX + config.framework, 'WebdriverIO') // TODO: make this constant const binconfig = CLIUtils.getBinConfig(config, capabilities, this._options, this._buildTag) await BrowserstackCLI.getInstance().bootstrap(this._options, config, binconfig) + this._applyServerConfigToRunner(config, capabilities) BStackLogger.debug(`Is CLI running ${BrowserstackCLI.getInstance().isRunning()}`) PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.START) } @@ -973,6 +975,38 @@ export default class BrowserstackLauncherService implements Services.ServiceInst } } + /** + * The runner schedules workers after onPrepare from each capability's maxInstances and + * config.maxInstances, so a server parallelsPerPlatform is mapped onto them here, and server + * platforms onto the capability at the same index. Platforms the server appends have no + * capability and stay inert. + */ + _applyServerConfigToRunner(config: Options.Testrunner, capabilities: Capabilities.RemoteCapabilities) { + const cli = BrowserstackCLI.getInstance() + const keys = cli.serverConfigKeys + if (!keys.length || !Array.isArray(capabilities)) { + return + } + const caps = capabilities as Record[] + if (keys.includes('accessibility')) { + this._accessibilityAutomation = isTrue(this._options.accessibility) + } + if (keys.includes('platforms')) { + const platforms = (cli.getConfig()[SERVER_SDK_CONFIG_KEY] as Record | undefined)?.platforms + if (Array.isArray(platforms)) { + caps.forEach((capability, index) => CLIUtils.applyServerPlatform(capability, platforms[index])) + } + } + const parallelsPerPlatform = Number((this._options as unknown as Record).parallelsPerPlatform) + if (keys.includes('parallelsPerPlatform') && Number.isInteger(parallelsPerPlatform) && parallelsPerPlatform > 0) { + caps.forEach((capability) => { + capability.maxInstances = parallelsPerPlatform + }) + config.maxInstances = Math.max(config.maxInstances || 0, caps.length * parallelsPerPlatform) + BStackLogger.info(`Server parallelsPerPlatform=${parallelsPerPlatform}: maxInstances=${parallelsPerPlatform} per capability, maxInstances=${config.maxInstances}`) + } + } + _updateCaps(capabilities?: Capabilities.RemoteCapabilities, capType?: string, value?: string) { if (Array.isArray(capabilities)) { capabilities diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 34a49a6c..eb9bd607 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1658,6 +1658,10 @@ export const isObject = (object: any) => { return object !== null && typeof object === 'object' && !Array.isArray(object) } +export function isSafeKey(key: string): boolean { + return !['__proto__', 'constructor', 'prototype'].includes(key) +} + export const ObjectsAreEqual = (object1: any, object2: any) => { const objectKeys1 = Object.keys(object1) const objectKeys2 = Object.keys(object2) diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 4efe8e4c..f2289811 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -229,6 +229,130 @@ describe('CLIUtils', () => { }) }) + describe('applyServerSdkConfig', () => { + it('applies exactly the listed keys and leaves echoes alone', () => { + const options: Record = { parallelsPerPlatform: 1, buildName: 'local', testObservability: true } + const applied = CLIUtils.applyServerSdkConfig(options, { parallelsPerPlatform: 3, buildName: 'server-echo', testObservability: false }, ['parallelsPerPlatform']) + expect(applied).toEqual(['parallelsPerPlatform']) + expect(options).toEqual({ parallelsPerPlatform: 3, buildName: 'local', testObservability: true }) + }) + + it('deep-merges objects, merges arrays element-wise and lets null replace', () => { + const options: Record = { + accessibilityOptions: { wcagVersion: 'wcag21aa', includeIssueType: { bestPractice: true } }, + testObservabilityOptions: { buildTag: ['a', 'b'] }, + percyOptions: { version: 'v2' }, + } + CLIUtils.applyServerSdkConfig(options, { + accessibilityOptions: { wcagVersion: 'wcag2a' }, + testObservabilityOptions: { buildTag: ['x'] }, + percyOptions: null, + }, ['accessibilityOptions', 'testObservabilityOptions', 'percyOptions']) + expect(options).toEqual({ + accessibilityOptions: { wcagVersion: 'wcag2a', includeIssueType: { bestPractice: true } }, + testObservabilityOptions: { buildTag: ['x', 'b'] }, + percyOptions: null, + }) + }) + + it('maps the keys getBinConfig renamed back to the service options', () => { + const options: Record = { opts: { forceLocal: true }, setSessionName: true, setSessionStatus: true, sessionNameFormat: '' } + const sent = JSON.parse(CLIUtils.getBinConfig({ user: 'u', key: 'k' } as Options.Testrunner, [{ browserName: 'chrome' }], options as BrowserstackConfig & BrowserstackOptions)) + expect(sent.browserStackLocalOptions).toEqual({ forceLocal: true }) + expect(sent.testContextOptions.skipSessionName).toBe(false) + + const applied = CLIUtils.applyServerSdkConfig(options, { + browserStackLocalOptions: { localIdentifier: 'srv-id' }, + testContextOptions: { skipSessionName: true, sessionNameFormat: '{testTitle}' }, + }, ['browserStackLocalOptions', 'testContextOptions']) + expect(applied).toEqual(['browserStackLocalOptions', 'testContextOptions']) + expect(options).toEqual({ opts: { forceLocal: true, localIdentifier: 'srv-id' }, setSessionName: false, setSessionStatus: true, sessionNameFormat: '{testTitle}' }) + }) + + it('reports platforms as applied without storing them as an option', () => { + const options: Record = {} + expect(CLIUtils.applyServerSdkConfig(options, { platforms: [{ browserVersion: 'latest-1' }] }, ['platforms'])).toEqual(['platforms']) + expect(options).toEqual({}) + }) + + it('skips listed keys the server config does not carry, non-string keys and unsafe keys', () => { + const options: Record = { debug: false } + const server = JSON.parse('{"__proto__":{"polluted":true},"debug":true}') + expect(CLIUtils.applyServerSdkConfig(options, server, ['missing', 7, '__proto__', 'debug'])).toEqual(['debug']) + expect(options).toEqual({ debug: true }) + expect(({} as Record).polluted).toBeUndefined() + }) + + it('does not share objects with the server config', () => { + const server = { accessibilityOptions: { wcagVersion: 'wcag2a' } } + const options: Record = {} + CLIUtils.applyServerSdkConfig(options, server, ['accessibilityOptions']) + ;(options.accessibilityOptions as Record).wcagVersion = 'changed' + expect(server.accessibilityOptions.wcagVersion).toBe('wcag2a') + }) + }) + + describe('applyServerPlatform', () => { + it('puts flattened bstack:options keys back under bstack:options and keeps top-level keys on top', () => { + const capability: Record = { + browserName: 'chrome', + browserVersion: 'latest', + 'goog:chromeOptions': { args: ['--headless'] }, + 'bstack:options': { os: 'Windows', osVersion: '11', buildName: 'local' }, + } + CLIUtils.applyServerPlatform(capability, { + browserVersion: 'latest-1', + osVersion: '10', + 'goog:chromeOptions': { args: ['--incognito'] }, + seleniumVersion: '4.21.0', + 'ms:edgeOptions': { args: [] }, + }) + expect(capability).toEqual({ + browserName: 'chrome', + browserVersion: 'latest-1', + 'goog:chromeOptions': { args: ['--incognito'] }, + 'ms:edgeOptions': { args: [] }, + 'bstack:options': { os: 'Windows', osVersion: '10', buildName: 'local', seleniumVersion: '4.21.0' }, + }) + }) + + it('keeps every key at the top level of a capability without bstack:options', () => { + const capability: Record = { browserName: 'chrome' } + CLIUtils.applyServerPlatform(capability, { browserVersion: 'latest-1' }) + expect(capability).toEqual({ browserName: 'chrome', browserVersion: 'latest-1' }) + }) + + it('ignores a non-object platform', () => { + const capability: Record = { browserName: 'chrome' } + CLIUtils.applyServerPlatform(capability, undefined) + CLIUtils.applyServerPlatform(capability, ['x']) + expect(capability).toEqual({ browserName: 'chrome' }) + }) + + it('updates bstack:options when the capability holds the key at both levels', () => { + const capability: Record = { + browserName: 'chrome', + browserVersion: 'latest', + 'bstack:options': { browserVersion: 'latest' } + } + CLIUtils.applyServerPlatform(capability, { browserVersion: 'latest-1' }) + expect(capability).toEqual({ + browserName: 'chrome', + browserVersion: 'latest', + 'bstack:options': { browserVersion: 'latest-1' } + }) + }) + + it('maps a key getBinConfig left out of the platform because it is also a top-level option back to bstack:options', () => { + const capability: Record = { browserName: 'chrome', 'bstack:options': { os: 'Windows', buildName: 'cap-build' } } + const sent = JSON.parse(CLIUtils.getBinConfig({ user: 'u', key: 'k' } as Options.Testrunner, [capability], { buildName: 'top-build' } as BrowserstackConfig & BrowserstackOptions)) + expect(sent.platforms[0]).toEqual({ browserName: 'chrome', os: 'Windows' }) + + CLIUtils.applyServerPlatform(capability, { buildName: 'srv-build' }) + expect(capability).toEqual({ browserName: 'chrome', 'bstack:options': { os: 'Windows', buildName: 'srv-build' } }) + }) + }) + describe('getSdkVersion', () => { it('returns the bstack service version', () => { const version = CLIUtils.getSdkVersion() diff --git a/packages/browserstack-service/tests/cli/index.test.ts b/packages/browserstack-service/tests/cli/index.test.ts index 5a1a9fa8..3cc734f3 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -458,6 +458,61 @@ describe('BrowserstackCLI', () => { errorSpy.mockRestore() } }) + + it('merges the server config into the options right after setConfig', () => { + const options = { parallelsPerPlatform: 1 } + browserstackCLI.options = options + mockStartBinResponse.config = JSON.stringify({ ...mockConfig, serverSdkConfig: { parallelsPerPlatform: 2 }, serverSdkConfigKeys: ['parallelsPerPlatform'] }) + + browserstackCLI.loadModules(mockStartBinResponse) + + expect(options.parallelsPerPlatform).toBe(2) + expect(browserstackCLI.serverConfigKeys).toEqual(['parallelsPerPlatform']) + }) + }) + + describe('applyServerConfig', () => { + let browserstackCLI: BrowserstackCLI + + beforeEach(() => { + browserstackCLI = new BrowserstackCLI() + }) + + it('merges exactly the listed keys into the service options', () => { + const options = { parallelsPerPlatform: 1, buildName: 'local', setSessionName: true } + browserstackCLI.options = options + browserstackCLI.config = { + serverSdkConfig: { parallelsPerPlatform: 3, buildName: 'echo', testContextOptions: { skipSessionName: true } }, + serverSdkConfigKeys: ['parallelsPerPlatform', 'testContextOptions'], + } + browserstackCLI.applyServerConfig() + expect(options).toEqual({ parallelsPerPlatform: 3, buildName: 'local', setSessionName: false }) + expect(browserstackCLI.serverConfigKeys).toEqual(['parallelsPerPlatform', 'testContextOptions']) + }) + + it('changes nothing without a key list, as from an older binary', () => { + const options = { parallelsPerPlatform: 1 } + browserstackCLI.options = options + for (const config of [{ serverSdkConfig: { parallelsPerPlatform: 3 } }, { serverSdkConfig: { parallelsPerPlatform: 3 }, serverSdkConfigKeys: 'parallelsPerPlatform' }, {}]) { + browserstackCLI.config = config + browserstackCLI.applyServerConfig() + } + expect(options).toEqual({ parallelsPerPlatform: 1 }) + expect(browserstackCLI.serverConfigKeys).toEqual([]) + }) + + it('keeps the local options when the merge fails', () => { + const errorSpy = vi.spyOn(cliLogger.BStackLogger, 'error').mockImplementation(() => {}) + const options = { parallelsPerPlatform: 1 } + browserstackCLI.options = options + browserstackCLI.config = { serverSdkConfig: { parallelsPerPlatform: 3 }, serverSdkConfigKeys: ['parallelsPerPlatform'] } + const mergeSpy = vi.spyOn(CLIUtils, 'applyServerSdkConfig').mockImplementation(() => { throw new Error('boom') }) + browserstackCLI.applyServerConfig() + expect(options).toEqual({ parallelsPerPlatform: 1 }) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('applyServerConfig: keeping the local config')) + errorSpy.mockRestore() + mergeSpy.mockRestore() + }) }) describe('isRunning', () => { diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index 05b61f1d..160d8223 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -17,6 +17,7 @@ import * as bstackLogger from '../src/bstackLogger.js' import { RERUN_ENV, RERUN_TESTS_ENV, BROWSERSTACK_TESTHUB_UUID } from '../src/constants.js' import * as FunnelInstrumentation from '../src/instrumentation/funnelInstrumentation.js' import TestOpsConfig from '../src/testOps/testOpsConfig.js' +import { BrowserstackCLI } from '../src/cli/index.js' vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('browserstack-local') @@ -88,6 +89,81 @@ describe('onPrepare', () => { expect(log.debug).toHaveBeenCalledWith('app is not defined in browserstack-service config, skipping ...') }) + describe('server config from the CLI', () => { + const mochaConfig = () => ({ user: 'foobaruser', key: '12345', framework: 'mocha', maxInstances: 10, capabilities: [] }) as any + const twoCaps = () => [ + { browserName: 'chrome', browserVersion: 'latest', maxInstances: 5, 'bstack:options': { os: 'Windows', osVersion: '11' } }, + { browserName: 'edge', browserVersion: 'latest', 'bstack:options': { os: 'Windows', osVersion: '11' } }, + ] as any[] + const bootstrapWith = (serverSdkConfig: Record | undefined, serverSdkConfigKeys: unknown) => + vi.spyOn(BrowserstackCLI.getInstance(), 'bootstrap').mockImplementation(async function (this: BrowserstackCLI, options: any) { + this.options = options + this.config = { serverSdkConfig, serverSdkConfigKeys } + this.applyServerConfig() + }) + + afterEach(() => { + vi.mocked(BrowserstackCLI.getInstance().bootstrap).mockRestore() + BrowserstackCLI.getInstance().config = {} + BrowserstackCLI.getInstance().serverConfigKeys = [] + BrowserstackCLI.getInstance().options = {} + }) + + it('maps a server parallelsPerPlatform onto each capability\'s maxInstances and raises maxInstances to fit', async () => { + bootstrapWith({ parallelsPerPlatform: 6 }, ['parallelsPerPlatform']) + const config = mochaConfig() + const capabilities = twoCaps() + const service = new BrowserstackLauncher({ testObservability: false, parallelsPerPlatform: 1 } as any, capabilities, config) + await service.onPrepare(config, capabilities) + expect(capabilities.map((cap) => cap.maxInstances)).toEqual([6, 6]) + expect(config.maxInstances).toBe(12) + }) + + it('lowers maxInstances to a smaller server parallelsPerPlatform and keeps a larger config.maxInstances', async () => { + bootstrapWith({ parallelsPerPlatform: 1 }, ['parallelsPerPlatform']) + const config = mochaConfig() + const capabilities = twoCaps() + const service = new BrowserstackLauncher({ testObservability: false, parallelsPerPlatform: 3 } as any, capabilities, config) + await service.onPrepare(config, capabilities) + expect(capabilities.map((cap) => cap.maxInstances)).toEqual([1, 1]) + expect(config.maxInstances).toBe(10) + }) + + it('layers server platforms onto the capability at the same index and ignores appended ones', async () => { + bootstrapWith({ platforms: [{ browserVersion: 'latest-1', osVersion: '10' }, {}, { browserName: 'safari' }] }, ['platforms']) + const config = mochaConfig() + const capabilities = twoCaps() + const service = new BrowserstackLauncher({ testObservability: false } as any, capabilities, config) + await service.onPrepare(config, capabilities) + expect(capabilities).toHaveLength(2) + expect(capabilities[0]).toMatchObject({ browserName: 'chrome', browserVersion: 'latest-1', maxInstances: 5, 'bstack:options': { os: 'Windows', osVersion: '10' } }) + expect(capabilities[1]).toMatchObject({ browserName: 'edge', browserVersion: 'latest', 'bstack:options': { os: 'Windows', osVersion: '11' } }) + expect(config.maxInstances).toBe(10) + }) + + it('leaves the runner config alone when parallelsPerPlatform is only echoed or no key list arrives', async () => { + for (const [serverSdkConfig, keys] of [[{ parallelsPerPlatform: 6, buildName: 'b' }, ['buildName']], [{ parallelsPerPlatform: 6 }, undefined]] as const) { + bootstrapWith(serverSdkConfig, keys) + const config = mochaConfig() + const capabilities = twoCaps() + const service = new BrowserstackLauncher({ testObservability: false, parallelsPerPlatform: 1 } as any, capabilities, config) + await service.onPrepare(config, capabilities) + expect(capabilities.map((cap) => cap.maxInstances)).toEqual([5, undefined]) + expect(config.maxInstances).toBe(10) + } + }) + + it('does not bootstrap the CLI, nor map parallelsPerPlatform, for a framework outside the CLI flow', async () => { + const bootstrapSpy = bootstrapWith({ parallelsPerPlatform: 6 }, ['parallelsPerPlatform']) + const config = { ...mochaConfig(), framework: 'jasmine' } + const capabilities = twoCaps() + const service = new BrowserstackLauncher({ testObservability: false } as any, capabilities, config) + await service.onPrepare(config, capabilities) + expect(bootstrapSpy).not.toHaveBeenCalled() + expect(capabilities.map((cap) => cap.maxInstances)).toEqual([5, undefined]) + }) + }) + it('should not call local if browserstackLocal is undefined', async () => { const service = new BrowserstackLauncher({ testObservability: false, percy: false } as any, caps, { user: 'foobaruser', From 09c98296905d3521f2fdc83ed5873d8e02b1aa16 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 19:52:05 +0530 Subject: [PATCH 2/6] fix(cli): ignore a server sessionNameFormat The option is a function; a server value can only be a string, which the automate module then called, so no session was named or marked. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/src/cli/cliUtils.ts | 5 +++-- .../browserstack-service/tests/cli/cliUtils.test.ts | 2 +- .../browserstack-service/tests/cli/index.test.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 0506d1a2..93d3953b 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -207,7 +207,8 @@ export class CLIUtils { } /** - * The service options getBinConfig derives testContextOptions from. + * The service options getBinConfig derives testContextOptions from. sessionNameFormat is not + * mapped: the option is a function, and a server value can only be a string. */ static getOptionsFromTestContextOptions(testContextOptions: unknown): Record { if (!isObject(testContextOptions)) { @@ -221,7 +222,7 @@ export class CLIUtils { if ('skipSessionStatus' in contextOptions) { options.setSessionStatus = !isTrue(contextOptions.skipSessionStatus) } - for (const key of ['sessionNameOmitTestTitle', 'sessionNamePrependTopLevelSuiteTitle', 'sessionNameFormat']) { + for (const key of ['sessionNameOmitTestTitle', 'sessionNamePrependTopLevelSuiteTitle']) { if (key in contextOptions) { options[key] = contextOptions[key] } diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index f2289811..e05dcd4e 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -266,7 +266,7 @@ describe('CLIUtils', () => { testContextOptions: { skipSessionName: true, sessionNameFormat: '{testTitle}' }, }, ['browserStackLocalOptions', 'testContextOptions']) expect(applied).toEqual(['browserStackLocalOptions', 'testContextOptions']) - expect(options).toEqual({ opts: { forceLocal: true, localIdentifier: 'srv-id' }, setSessionName: false, setSessionStatus: true, sessionNameFormat: '{testTitle}' }) + expect(options).toEqual({ opts: { forceLocal: true, localIdentifier: 'srv-id' }, setSessionName: false, setSessionStatus: true, sessionNameFormat: '' }) }) it('reports platforms as applied without storing them as an option', () => { diff --git a/packages/browserstack-service/tests/cli/index.test.ts b/packages/browserstack-service/tests/cli/index.test.ts index 3cc734f3..0283fcb3 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -490,6 +490,18 @@ describe('BrowserstackCLI', () => { expect(browserstackCLI.serverConfigKeys).toEqual(['parallelsPerPlatform', 'testContextOptions']) }) + it('keeps the local sessionNameFormat formatter when the server sends one, which can only be a string', () => { + const sessionNameFormat = () => 'local-name' + const options = { setSessionName: false, sessionNameFormat } + browserstackCLI.options = options + browserstackCLI.config = { + serverSdkConfig: { testContextOptions: { skipSessionName: false, sessionNameFormat: 'srv-fmt' } }, + serverSdkConfigKeys: ['testContextOptions'], + } + browserstackCLI.applyServerConfig() + expect(options).toEqual({ setSessionName: true, sessionNameFormat }) + }) + it('changes nothing without a key list, as from an older binary', () => { const options = { parallelsPerPlatform: 1 } browserstackCLI.options = options From 4d0de76c40fefb88c79d2377e501cfee2d24ee22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:45:37 +0000 Subject: [PATCH 3/6] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-302.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-302.md diff --git a/.changeset/pr-302.md b/.changeset/pr-302.md new file mode 100644 index 00000000..da92e863 --- /dev/null +++ b/.changeset/pr-302.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": minor +--- + +- Server-side configuration overrides from BrowserStack now take effect for WebdriverIO v8 builds, for example `parallelsPerPlatform` (parallel sessions per capability). Your wdio.conf remains the fallback. From 8f8fba8c307dcf79bccbf6e0758d03d3a863fb08 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 21:50:49 +0530 Subject: [PATCH 4/6] refactor(cli): copy server config keys one by one through isSafeKey Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/src/cli/cliUtils.ts | 12 ++++++++++-- .../browserstack-service/tests/cli/cliUtils.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 93d3953b..65d9ecb9 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -172,16 +172,24 @@ export class CLIUtils { if (key === 'browserStackLocalOptions') { updates.opts = this.mergeServerConfigValue(options.opts, server[key]) } else if (key === 'testContextOptions') { - Object.assign(updates, this.getOptionsFromTestContextOptions(server[key])) + this.assignSafeKeys(updates, this.getOptionsFromTestContextOptions(server[key])) } else if (key !== 'platforms') { updates[key] = this.mergeServerConfigValue(options[key], server[key]) } applied.push(key) } - Object.assign(options, updates) + this.assignSafeKeys(options, updates) return applied } + static assignSafeKeys(target: Record, source: Record) { + for (const [key, value] of Object.entries(source)) { + if (isSafeKey(key)) { + target[key] = value + } + } + } + /** * Objects merge recursively, arrays element-wise (extra source elements are appended), any * other value, null included, replaces. diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index e05dcd4e..3e01e858 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -292,6 +292,16 @@ describe('CLIUtils', () => { }) }) + describe('assignSafeKeys', () => { + it('copies every key except prototype keys', () => { + const target: Record = { a: 1 } + const source = JSON.parse('{"b":2,"__proto__":{"polluted":true},"constructor":{"x":1},"prototype":{"y":1}}') + CLIUtils.assignSafeKeys(target, source) + expect(target).toEqual({ a: 1, b: 2 }) + expect(({} as Record).polluted).toBeUndefined() + }) + }) + describe('applyServerPlatform', () => { it('puts flattened bstack:options keys back under bstack:options and keeps top-level keys on top', () => { const capability: Record = { From 41dc016951b54e695e5ee764a53138cd756a8b0d Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 22:08:59 +0530 Subject: [PATCH 5/6] fix(cli): clone the server platform entry and re-read bstack:options per key Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/src/cli/cliUtils.ts | 5 +++-- .../tests/cli/cliUtils.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 65d9ecb9..5711291d 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -247,11 +247,12 @@ export class CLIUtils { if (!isObject(platform)) { return } - const bstackOptions = isObject(capability['bstack:options']) ? capability['bstack:options'] as Record : undefined - for (const [key, value] of Object.entries(platform as Record)) { + const entry = JSON.parse(JSON.stringify(platform)) as Record + for (const [key, value] of Object.entries(entry)) { if (!isSafeKey(key)) { continue } + const bstackOptions = isObject(capability['bstack:options']) ? capability['bstack:options'] as Record : undefined const intoBstackOptions = bstackOptions && key !== 'bstack:options' && (key in bstackOptions || (!(key in capability) && !key.includes(':'))) const target = intoBstackOptions ? bstackOptions : capability diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 3e01e858..b347e7e3 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -339,6 +339,20 @@ describe('CLIUtils', () => { expect(capability).toEqual({ browserName: 'chrome' }) }) + it('keeps routing into bstack:options after the entry merges a bstack:options key', () => { + const capability: Record = { browserName: 'chrome', 'bstack:options': { os: 'Windows', osVersion: '11' } } + CLIUtils.applyServerPlatform(capability, { 'bstack:options': { debug: true }, osVersion: '10' }) + expect(capability['bstack:options']).toEqual({ os: 'Windows', osVersion: '10', debug: true }) + }) + + it('does not share objects with the server platform entry', () => { + const capability: Record = { browserName: 'chrome' } + const platform = { 'goog:chromeOptions': { args: ['--incognito'] } } + CLIUtils.applyServerPlatform(capability, platform) + platform['goog:chromeOptions'].args.push('--mutated') + expect(capability['goog:chromeOptions']).toEqual({ args: ['--incognito'] }) + }) + it('updates bstack:options when the capability holds the key at both levels', () => { const capability: Record = { browserName: 'chrome', From 4d197511050a32357b29d2ec3fa462f2c90da63a Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 22:29:03 +0530 Subject: [PATCH 6/6] fix(cli): copy bstack:options before writing a server platform key into it Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/src/cli/cliUtils.ts | 7 +++++-- packages/browserstack-service/tests/cli/cliUtils.test.ts | 9 +++++++++ packages/browserstack-service/tests/launcher.test.ts | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 5711291d..0a5a48a1 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -255,8 +255,11 @@ export class CLIUtils { const bstackOptions = isObject(capability['bstack:options']) ? capability['bstack:options'] as Record : undefined const intoBstackOptions = bstackOptions && key !== 'bstack:options' && (key in bstackOptions || (!(key in capability) && !key.includes(':'))) - const target = intoBstackOptions ? bstackOptions : capability - target[key] = this.mergeServerConfigValue(target[key], value) + if (intoBstackOptions) { + capability['bstack:options'] = { ...bstackOptions, [key]: this.mergeServerConfigValue(bstackOptions[key], value) } + } else { + capability[key] = this.mergeServerConfigValue(capability[key], value) + } } } diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index b347e7e3..b1806f34 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -345,6 +345,15 @@ describe('CLIUtils', () => { expect(capability['bstack:options']).toEqual({ os: 'Windows', osVersion: '10', debug: true }) }) + it('does not write into a bstack:options object shared with another capability', () => { + const shared = { os: 'Windows', osVersion: '11' } + const first: Record = { browserName: 'chrome', 'bstack:options': shared } + const second: Record = { browserName: 'edge', 'bstack:options': shared } + CLIUtils.applyServerPlatform(first, { osVersion: '10' }) + expect(first['bstack:options']).toEqual({ os: 'Windows', osVersion: '10' }) + expect(second['bstack:options']).toEqual({ os: 'Windows', osVersion: '11' }) + }) + it('does not share objects with the server platform entry', () => { const capability: Record = { browserName: 'chrome' } const platform = { 'goog:chromeOptions': { args: ['--incognito'] } } diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index 160d8223..f7c954be 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -143,6 +143,7 @@ describe('onPrepare', () => { it('leaves the runner config alone when parallelsPerPlatform is only echoed or no key list arrives', async () => { for (const [serverSdkConfig, keys] of [[{ parallelsPerPlatform: 6, buildName: 'b' }, ['buildName']], [{ parallelsPerPlatform: 6 }, undefined]] as const) { + BrowserstackCLI.getInstance().serverConfigKeys = [] bootstrapWith(serverSdkConfig, keys) const config = mochaConfig() const capabilities = twoCaps()