Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-302.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 v8 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 @@ -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'
Expand Down Expand Up @@ -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<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 bstackServiceVersion
}
Expand Down
28 changes: 26 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 } 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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<string, unknown>, serverConfig as Record<string, unknown>, 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
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 @@ -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'
Expand Down
36 changes: 35 additions & 1 deletion packages/browserstack-service/src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<string, unknown>[]
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)) {
caps.forEach((capability, index) => CLIUtils.applyServerPlatform(capability, platforms[index]))
}
}
const parallelsPerPlatform = Number((this._options as unknown as Record<string, unknown>).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
Expand Down
4 changes: 4 additions & 0 deletions packages/browserstack-service/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading