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. diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index 5b1454aa..2341ca9e 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,114 @@ 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') { + this.assignSafeKeys(updates, this.getOptionsFromTestContextOptions(server[key])) + } else if (key !== 'platforms') { + updates[key] = this.mergeServerConfigValue(options[key], server[key]) + } + applied.push(key) + } + 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. + */ + 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. 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)) { + 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']) { + 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 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(':'))) + if (intoBstackOptions) { + capability['bstack:options'] = { ...bstackOptions, [key]: this.mergeServerConfigValue(bstackOptions[key], value) } + } else { + capability[key] = this.mergeServerConfigValue(capability[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..8e209f0c 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,33 @@ 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) + // 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(',')}`) + } + } 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..94a8952c 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -231,6 +231,154 @@ 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: '' }) + }) + + 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('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 = { + 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('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 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'] } } + 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', + 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', () => { 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..d0bd02c7 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -94,6 +94,85 @@ 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('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 + 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..999ea5aa 100644 --- a/packages/browserstack-service/tests/launcher.test.ts +++ b/packages/browserstack-service/tests/launcher.test.ts @@ -177,6 +177,82 @@ 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(() => { + vi.mocked(BrowserstackCLI.getInstance().bootstrap).mockRestore() + 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) { + BrowserstackCLI.getInstance().serverConfigKeys = [] + 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)