Skip to content
5 changes: 5 additions & 0 deletions .changeset/pr-301.md
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 110 additions & 0 deletions packages/browserstack-service/src/cli/cliUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>, serverConfig: Record<string, unknown>, keys: unknown[]): string[] {
const server = JSON.parse(JSON.stringify(serverConfig)) as Record<string, unknown>
const updates: Record<string, unknown> = {}
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<string, unknown>, source: Record<string, unknown>) {
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<string, unknown> = { ...(target as Record<string, unknown>) }
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
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<string, unknown> {
if (!isObject(testContextOptions)) {
return {}
}
const contextOptions = testContextOptions as Record<string, unknown>
const options: Record<string, unknown> = {}
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<string, unknown>, platform: unknown) {
if (!isObject(platform)) {
return
}
const entry = JSON.parse(JSON.stringify(platform)) as Record<string, unknown>
for (const [key, value] of Object.entries(entry)) {
if (!isSafeKey(key)) {
continue
}
const bstackOptions = isObject(capability['bstack:options']) ? capability['bstack:options'] as Record<string, unknown> : 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
}
Expand Down
34 changes: 32 additions & 2 deletions packages/browserstack-service/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>, serverConfig as Record<string, unknown>, 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<string, unknown>).sessionNameFormat !== 'function') {
delete (contextOptions as Record<string, unknown>).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
Expand Down
2 changes: 2 additions & 0 deletions packages/browserstack-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<string, unknown> | undefined)?.platforms
if (Array.isArray(platforms)) {
capabilities.forEach((capability, index) => CLIUtils.applyServerPlatform(capability as Record<string, unknown>, platforms[index]))
}
}
const parallelsPerPlatform = Number((this._options as Record<string, unknown>).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
Expand Down
2 changes: 1 addition & 1 deletion packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2183,7 +2183,7 @@ export function coerceStringBooleans<T extends Record<string, unknown>>(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)
}
Expand Down
Loading
Loading