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. diff --git a/packages/browserstack-service/src/cli/cliUtils.ts b/packages/browserstack-service/src/cli/cliUtils.ts index b505e967..0a5a48a1 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,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 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..b1806f34 100644 --- a/packages/browserstack-service/tests/cli/cliUtils.test.ts +++ b/packages/browserstack-service/tests/cli/cliUtils.test.ts @@ -229,6 +229,163 @@ 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' } + }) + }) + + 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..0283fcb3 100644 --- a/packages/browserstack-service/tests/cli/index.test.ts +++ b/packages/browserstack-service/tests/cli/index.test.ts @@ -458,6 +458,73 @@ 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('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 + 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..f7c954be 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,82 @@ 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) { + 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.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',