From 56c6719496e66b02cc4c7672b9dd28e3a1bb408a Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 16:50:41 +0530 Subject: [PATCH 1/8] 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 wdio: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 | 29 ++++- .../browserstack-service/src/constants.ts | 2 + packages/browserstack-service/src/launcher.ts | 35 +++++- packages/browserstack-service/src/util.ts | 2 +- .../tests/cli/cliUtils.test.ts | 101 ++++++++++++++++++ .../tests/cli/index.test.ts | 61 +++++++++++ .../tests/launcher.test.ts | 74 +++++++++++++ 8 files changed, 397 insertions(+), 4 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 5b1454aa..e308ee86 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -23,6 +23,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' @@ -177,6 +179,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 capability) + && (key in bstackOptions || !key.includes(':')) + const target = intoBstackOptions ? bstackOptions : capability + target[key] = this.mergeServerConfigValue(target[key], value) + } + } + static getSdkVersion() { return BSTACK_SERVICE_VERSION } diff --git a/packages/browserstack-service/src/cli/index.ts b/packages/browserstack-service/src/cli/index.ts index 91c813c4..f215bad6 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, BINARY_BUSY_ERROR_CODES, MAX_SPAWN_RETRIES, SPAWN_RETRY_DELAY_MS } from '../constants.js' +import { BROWSERSTACK_ACCESSIBILITY, BROWSERSTACK_OBSERVABILITY, BROWSERSTACK_TESTHUB_JWT, BROWSERSTACK_TESTHUB_UUID, CLI_STOP_TIMEOUT, TESTOPS_BUILD_COMPLETED_ENV, TESTOPS_SCREENSHOT_ENV, BINARY_BUSY_ERROR_CODES, MAX_SPAWN_RETRIES, SPAWN_RETRY_DELAY_MS, 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' @@ -22,7 +22,7 @@ import WebdriverIOModule from './modules/webdriverIOModule.js' import AccessibilityModule from './modules/accessibilityModule.js' import CustomTagsModule from './modules/customTagsModule.js' import UploadAttachmentModule from './modules/uploadAttachmentModule.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, LaunchResponse } from '../types.js' import CrashReporter from '../crash-reporter.js' @@ -55,6 +55,7 @@ export class BrowserstackCLI { SDK_CLI_BIN_PATH: string | null = null logger = BStackLogger options: BrowserstackConfig & BrowserstackOptions | {} + serverConfigKeys: string[] = [] constructor() { this.initialized = false @@ -140,11 +141,13 @@ export class BrowserstackCLI { // observer, so each test/hook event is dispatched (and uploaded) twice. Load once. if (this.modulesLoaded) { this.logger.info('loadModules: modules already loaded in this process; skipping to avoid duplicate observer registration') + this.applyServerConfig() return } this.modulesLoaded = true this.setConfig(startBinResponse) + this.applyServerConfig() // Surface any build errors the binary populated on testhub.errors // BEFORE the config.apis dereference below. On auth failure the @@ -203,6 +206,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)}`) + } + } + /** * Log any build errors the binary reported via testhub.errors. The * field is a JSON-encoded { [errorKey]: { message, type } } map. Called diff --git a/packages/browserstack-service/src/constants.ts b/packages/browserstack-service/src/constants.ts index fedba7b6..bc410c3d 100644 --- a/packages/browserstack-service/src/constants.ts +++ b/packages/browserstack-service/src/constants.ts @@ -241,6 +241,8 @@ export const GIT_META_DATA_TRUNCATED = '...[TRUNCATED]' // CLI related constants export const CLI_STOP_TIMEOUT = 5000 // 5 seconds +export const SERVER_SDK_CONFIG_KEY = 'serverSdkConfig' +export const SERVER_SDK_CONFIG_KEYS_KEY = 'serverSdkConfigKeys' export const BINARY_BUSY_ERROR_CODES = ['ETXTBSY', 'EBUSY'] export const MAX_SPAWN_RETRIES = 3 export const SPAWN_RETRY_DELAY_MS = 1000 diff --git a/packages/browserstack-service/src/launcher.ts b/packages/browserstack-service/src/launcher.ts index d5c135b3..43f6ec27 100644 --- a/packages/browserstack-service/src/launcher.ts +++ b/packages/browserstack-service/src/launcher.ts @@ -26,7 +26,8 @@ import { BROWSERSTACK_OBSERVABILITY, WDIO_NAMING_PREFIX, BROWSERSTACK_TEST_REPORTING, - TEST_REPORTING_PROJECT_NAME + TEST_REPORTING_PROJECT_NAME, + SERVER_SDK_CONFIG_KEY } from './constants.js' import { launchTestSession, @@ -411,6 +412,7 @@ export default class BrowserstackLauncherService implements Services.ServiceInst CLIUtils.setFrameworkDetail(WDIO_NAMING_PREFIX + config.framework, 'WebdriverIO') const binconfig = CLIUtils.getBinConfig(config, capabilities as Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedStandaloneCapabilities[], this._options, this._buildTag) await BrowserstackCLI.getInstance().bootstrap(this._options, config, binconfig) + this._applyServerConfigToRunner(config, capabilities as WebdriverIO.Capabilities[]) BStackLogger.debug(`Is CLI running ${BrowserstackCLI.getInstance().isRunning()}`) PerformanceTester.end(PERFORMANCE_SDK_EVENTS.FRAMEWORK_EVENTS.START) } @@ -1089,6 +1091,37 @@ export default class BrowserstackLauncherService implements Services.ServiceInst } } + /** + * The runner schedules workers after onPrepare from each capability's wdio: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: WebdriverIO.Capabilities[]) { + const cli = BrowserstackCLI.getInstance() + const keys = cli.serverConfigKeys + if (!keys.length || !Array.isArray(capabilities)) { + return + } + 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)) { + capabilities.forEach((capability, index) => CLIUtils.applyServerPlatform(capability as Record, platforms[index])) + } + } + const parallelsPerPlatform = Number((this._options as Record).parallelsPerPlatform) + if (keys.includes('parallelsPerPlatform') && Number.isInteger(parallelsPerPlatform) && parallelsPerPlatform > 0) { + capabilities.forEach((capability) => { + capability['wdio:maxInstances'] = parallelsPerPlatform + }) + config.maxInstances = Math.max(config.maxInstances || 0, capabilities.length * parallelsPerPlatform) + BStackLogger.info(`Server parallelsPerPlatform=${parallelsPerPlatform}: wdio:maxInstances=${parallelsPerPlatform} per capability, maxInstances=${config.maxInstances}`) + } + } + _updateCaps(capabilities?: Capabilities.TestrunnerCapabilities, 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 5aa1e9bb..4bf1fd53 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -2183,7 +2183,7 @@ export function coerceStringBooleans>(obj: T): * @param key - The key to check * @returns true if the key is safe, false otherwise */ -function isSafeKey(key: string): boolean { +export function isSafeKey(key: string): boolean { const dangerousKeys = ['__proto__', 'constructor', 'prototype'] return !dangerousKeys.includes(key) } diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index bbc371c6..9579a667 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -231,6 +231,107 @@ 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' }) + }) + }) + 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 d4415b89..82f5c552 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -94,6 +94,67 @@ describe('BrowserstackCLI bootstrap error surfacing', () => { }) }) + describe('applyServerConfig', () => { + afterEach(() => { + instance.options = {} + instance.serverConfigKeys = [] + instance.modulesLoaded = false + }) + + const configWith = (extra: Record) => JSON.stringify({ apis: {}, ...extra }) + + it('merges exactly the listed keys into the service options', () => { + const options = { parallelsPerPlatform: 1, buildName: 'local', setSessionName: true } + instance.options = options + instance.setConfig({ config: configWith({ + serverSdkConfig: { parallelsPerPlatform: 3, buildName: 'echo', testContextOptions: { skipSessionName: true } }, + serverSdkConfigKeys: ['parallelsPerPlatform', 'testContextOptions'], + }) } as any) + instance.applyServerConfig() + expect(options).toEqual({ parallelsPerPlatform: 3, buildName: 'local', setSessionName: false }) + expect(instance.serverConfigKeys).toEqual(['parallelsPerPlatform', 'testContextOptions']) + }) + + it('changes nothing without a key list, as from an older binary', () => { + const options = { parallelsPerPlatform: 1 } + instance.options = options + for (const extra of [{ serverSdkConfig: { parallelsPerPlatform: 3 } }, { serverSdkConfig: { parallelsPerPlatform: 3 }, serverSdkConfigKeys: 'parallelsPerPlatform' }, {}]) { + instance.setConfig({ config: configWith(extra) } as any) + instance.applyServerConfig() + } + expect(options).toEqual({ parallelsPerPlatform: 1 }) + expect(instance.serverConfigKeys).toEqual([]) + }) + + it('keeps the local options when the merge fails', () => { + const options = { parallelsPerPlatform: 1 } + instance.options = options + instance.config = { serverSdkConfig: { parallelsPerPlatform: 3 }, serverSdkConfigKeys: ['parallelsPerPlatform'] } + vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => { throw new Error('boom') }) + instance.applyServerConfig() + expect(options).toEqual({ parallelsPerPlatform: 1 }) + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('applyServerConfig: keeping the local config')) + }) + + it('runs right after setConfig in loadModules, and again for the options of a repeated load', () => { + const response = { binSessionId: 'b1', config: configWith({ serverSdkConfig: { parallelsPerPlatform: 2 }, serverSdkConfigKeys: ['parallelsPerPlatform'] }) } as any + const mainOptions = { parallelsPerPlatform: 1 } + instance.options = mainOptions + try { + instance.loadModules(response) + } catch { + // modules need a full bin session; only the merge is under test + } + expect(mainOptions.parallelsPerPlatform).toBe(2) + + const workerOptions = { parallelsPerPlatform: 1 } + instance.options = workerOptions + instance.modulesLoaded = true + instance.loadModules(response) + expect(workerOptions.parallelsPerPlatform).toBe(2) + }) + }) + // SDK-6821: apis arrives under the sessionData bucket (flat kept for backward // compat until every SDK migrates). The bucket-only case is the Phase-4 shape — // the one a broken hoist cannot mask via the flat fallback. diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index 43562b43..feab46bc 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -177,6 +177,80 @@ describe('onPrepare', () => { expect(setupPercySpy).toHaveBeenCalledTimes(1) }) + 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', 'wdio: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(() => { + BrowserstackCLI.getInstance().config = {} + BrowserstackCLI.getInstance().serverConfigKeys = [] + BrowserstackCLI.getInstance().options = {} + }) + + it('maps a server parallelsPerPlatform onto wdio: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['wdio:maxInstances'])).toEqual([6, 6]) + expect(config.maxInstances).toBe(12) + }) + + it('lowers wdio:maxInstances to a smaller server parallelsPerPlatform and keeps a larger 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['wdio: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', 'wdio: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['wdio: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['wdio:maxInstances'])).toEqual([5, undefined]) + }) + }) + it('should add the "app" property to a multiremote capability if no "bstack:options"', async () => { const options: BrowserstackConfig = { app: 'bs://' } const service = new BrowserstackLauncher(options as any, caps, config) From 481e05c40441ca18ebeabdaa4f4e242fb0dc07ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:54:40 +0000 Subject: [PATCH 2/8] chore(changeset): auto-generate from PR template (minor) --- .changeset/pr-301.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pr-301.md diff --git a/.changeset/pr-301.md b/.changeset/pr-301.md new file mode 100644 index 00000000..d9cc14af --- /dev/null +++ b/.changeset/pr-301.md @@ -0,0 +1,5 @@ +--- +"@wdio/browserstack-service": minor +--- + +- Server-side configuration overrides from BrowserStack now take effect for WebdriverIO builds, for example `parallelsPerPlatform` (parallel sessions per capability). Your wdio.conf remains the fallback. From 6d191924ee1242a5d9b7956e94889edcdd5e9d75 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 18:47:55 +0530 Subject: [PATCH 3/8] fix(cli): write a server platform key to bstack:options when the capability holds it at both levels getBinConfig flattens bstack:options last, so a key written only to the capability top level left the stale bstack:options copy in effect. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/src/cli/cliUtils.ts | 4 ++-- .../tests/cli/cliUtils.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index e308ee86..d5828079 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -267,8 +267,8 @@ export class CLIUtils { if (!isSafeKey(key)) { continue } - const intoBstackOptions = bstackOptions && key !== 'bstack:options' && !(key in capability) - && (key in bstackOptions || !key.includes(':')) + 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) } diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 9579a667..60919c16 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -330,6 +330,20 @@ describe('CLIUtils', () => { 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' } + }) + }) }) describe('getSdkVersion', () => { From 94038c26767f0d39fc07ee588447c5bbd39e9daa Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 19:25:27 +0530 Subject: [PATCH 4/8] 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) --- .../browserstack-service/src/cli/cliUtils.ts | 5 +++-- packages/browserstack-service/src/cli/index.ts | 5 +++++ .../tests/cli/cliUtils.test.ts | 2 +- .../tests/cli/index.test.ts | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index d5828079..f9974c6d 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -231,7 +231,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)) { @@ -245,7 +246,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/src/cli/index.ts b/packages/browserstack-service/src/cli/index.ts index f215bad6..8e209f0c 100644 --- a/packages/browserstack-service/src/cli/index.ts +++ b/packages/browserstack-service/src/cli/index.ts @@ -220,6 +220,11 @@ export class BrowserstackCLI { return } this.serverConfigKeys = CLIUtils.applyServerSdkConfig(this.options as Record, serverConfig as Record, keys) + // The modules call testContextOptions.sessionNameFormat; the merged JSON can only hold a string there. + const contextOptions = this.config.testContextOptions + if (isObject(contextOptions) && typeof (contextOptions as Record).sessionNameFormat !== 'function') { + delete (contextOptions as Record).sessionNameFormat + } if (this.serverConfigKeys.length) { this.logger.info(`applyServerConfig: server config applied keys=${this.serverConfigKeys.join(',')}`) } diff --git a/packages/browserstack-service/tests/cli/cliUtils.test.ts b/packages/browserstack-service/tests/cli/cliUtils.test.ts index 60919c16..b2d06baa 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -268,7 +268,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 82f5c552..d0bd02c7 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -115,6 +115,24 @@ describe('BrowserstackCLI bootstrap error surfacing', () => { expect(instance.serverConfigKeys).toEqual(['parallelsPerPlatform', 'testContextOptions']) }) + it('drops a server sessionNameFormat from the binary config, which can only be a string', () => { + instance.options = { setSessionName: false } + instance.setConfig({ config: configWith({ + testContextOptions: { skipSessionName: false, sessionNameFormat: 'srv-fmt' }, + serverSdkConfig: { testContextOptions: { skipSessionName: false, sessionNameFormat: 'srv-fmt' } }, + serverSdkConfigKeys: ['testContextOptions'], + }) } as any) + instance.applyServerConfig() + expect(instance.config.testContextOptions).toEqual({ skipSessionName: false }) + expect(instance.options).toEqual({ setSessionName: true }) + }) + + it('leaves the binary config testContextOptions alone without an override', () => { + instance.setConfig({ config: configWith({ testContextOptions: { skipSessionName: false, sessionNameFormat: '' } }) } as any) + instance.applyServerConfig() + expect(instance.config.testContextOptions).toEqual({ skipSessionName: false, sessionNameFormat: '' }) + }) + it('changes nothing without a key list, as from an older binary', () => { const options = { parallelsPerPlatform: 1 } instance.options = options From 018288bd38556adc14e59d67b1a3fb54e1f3468b Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 20:49:59 +0530 Subject: [PATCH 5/8] test(launcher): restore the bootstrap spy after the server-config tests Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browserstack-service/tests/launcher.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/browserstack-service/tests/launcher.test.ts b/packages/browserstack-service/tests/launcher.test.ts index feab46bc..8d0c241f 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -191,6 +191,7 @@ describe('onPrepare', () => { }) afterEach(() => { + vi.mocked(BrowserstackCLI.getInstance().bootstrap).mockRestore() BrowserstackCLI.getInstance().config = {} BrowserstackCLI.getInstance().serverConfigKeys = [] BrowserstackCLI.getInstance().options = {} From a32f94ec6d8e9c9d75876a0b71d27af058a93ae9 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 21:49:52 +0530 Subject: [PATCH 6/8] 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 f9974c6d..49dc89de 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -196,16 +196,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 b2d06baa..12cdcd5f 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -294,6 +294,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 869e19edb37731a2955353ba47828c33ace8de6c Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 22:08:59 +0530 Subject: [PATCH 7/8] 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 49dc89de..9dadef20 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -271,11 +271,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 12cdcd5f..21551e51 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -341,6 +341,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 96a31e2f834481cde0685c1d0b8fadef53ca05d9 Mon Sep 17 00:00:00 2001 From: Kamalpreet Kaur Date: Thu, 22 Oct 2026 22:29:02 +0530 Subject: [PATCH 8/8] 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 9dadef20..2341ca9e 100644 --- a/packages/browserstack-service/src/cli/cliUtils.ts +++ b/packages/browserstack-service/src/cli/cliUtils.ts @@ -279,8 +279,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 21551e51..94a8952c 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -347,6 +347,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 8d0c241f..999ea5aa 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -231,6 +231,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()