import fs from 'node:fs' import { readFile } from 'node:fs/promises' import path from 'node:path' import { promisify, format } from 'node:util' import { performance, PerformanceObserver } from 'node:perf_hooks' import os from 'node:os' import { SevereServiceError } from 'webdriverio' import * as BrowserstackLocalLauncher from 'browserstack-local' import { getProductMap } from './testHub/utils.js' import TestOpsConfig from './testOps/testOpsConfig.js' import type { Capabilities, Services, Options } from '@wdio/types' import { startPercy, stopPercy, getBestPlatformForPercySnapshot } from './Percy/PercyHelper.js' import type { BrowserstackConfig, BrowserstackOptions, App, AppConfig, AppUploadResponse, UserConfig } from './types.js' import { BSTACK_SERVICE_VERSION, NOT_ALLOWED_KEYS_IN_CAPS, PERF_MEASUREMENT_ENV, RERUN_ENV, RERUN_TESTS_ENV, BROWSERSTACK_TESTHUB_UUID, VALID_APP_EXTENSION, BROWSERSTACK_PERCY, BROWSERSTACK_OBSERVABILITY } from './constants.js' import { launchTestSession, shouldAddServiceVersion, stopBuildUpstream, getCiInfo, isBStackSession, isUndefined, isAccessibilityAutomationSession, isTrue, getBrowserStackUser, getBrowserStackKey, uploadLogs, ObjectsAreEqual, getBasicAuthHeader, isValidCapsForHealing, getBooleanValueFromString, validateCapsWithNonBstackA11y, mergeChromeOptions } from './util.js' import CrashReporter from './crash-reporter.js' import { BStackLogger } from './bstackLogger.js' import { PercyLogger } from './Percy/PercyLogger.js' import type Percy from './Percy/Percy.js' import BrowserStackConfig from './config.js' import { setupExitHandlers } from './exitHandler.js' import { sendFinish, sendStart } from './instrumentation/funnelInstrumentation.js' import AiHandler from './ai-handler.js' import PerformanceTester from './instrumentation/performance/performance-tester.js' import * as PERFORMANCE_SDK_EVENTS from './instrumentation/performance/constants.js' import accessibilityScripts from './scripts/accessibility-scripts.js' import { _fetch as fetch } from './fetchWrapper.js' type BrowserstackLocal = BrowserstackLocalLauncher.Local & { pid?: number stop(callback: (err?: Error) => void): void } export default class BrowserstackLauncherService implements Services.ServiceInstance { browserstackLocal?: BrowserstackLocal private _buildName?: string private _projectName?: string private _buildTag?: string private _buildIdentifier?: string private _accessibilityAutomation?: boolean | null = null private _percy?: Percy private _percyBestPlatformCaps?: WebdriverIO.Capabilities private readonly browserStackConfig: BrowserStackConfig constructor ( private _options: BrowserstackConfig & BrowserstackOptions, capabilities: Capabilities.TestrunnerCapabilities, private _config: Options.Testrunner ) { BStackLogger.clearLogFile() PercyLogger.clearLogFile() setupExitHandlers() // added to maintain backward compatibility with webdriverIO v5 if (!this._config) { this._config = _options } this.browserStackConfig = BrowserStackConfig.getInstance(_options, _config) if (Array.isArray(capabilities)) { capabilities .flatMap((c) => { if ('alwaysMatch' in c) { return c.alwaysMatch as WebdriverIO.Capabilities } if (Object.values(c).length > 0 && Object.values(c).every(c => typeof c === 'object' && c.capabilities)) { return Object.values(c).map((o) => o.capabilities) as WebdriverIO.Capabilities[] } return c as WebdriverIO.Capabilities }) .forEach((capability: WebdriverIO.Capabilities) => { if (!capability['bstack:options']) { // Skipping adding of service version if session is not of browserstack if (isBStackSession(this._config)) { const extensionCaps = Object.keys(capability).filter((cap) => cap.includes(':')) if (extensionCaps.length) { capability['bstack:options'] = { wdioService: BSTACK_SERVICE_VERSION } if (!isUndefined(capability['browserstack.accessibility'])) { this._accessibilityAutomation ||= isTrue(capability['browserstack.accessibility']) } else if (isTrue(this._options.accessibility)) { capability['bstack:options'].accessibility = true } } else if (shouldAddServiceVersion(this._config, this._options.testObservability)) { capability['browserstack.wdioService'] = BSTACK_SERVICE_VERSION } } // Need this details for sending data to Observability this._buildIdentifier = capability['browserstack.buildIdentifier']?.toString() // @ts-expect-error ToDo: fix invalid cap this._buildName = capability.build?.toString() } else { capability['bstack:options'].wdioService = BSTACK_SERVICE_VERSION this._buildName = capability['bstack:options'].buildName this._projectName = capability['bstack:options'].projectName this._buildTag = capability['bstack:options'].buildTag this._buildIdentifier = capability['bstack:options'].buildIdentifier if (!isUndefined(capability['bstack:options'].accessibility)) { this._accessibilityAutomation ||= isTrue(capability['bstack:options'].accessibility) } else if (isTrue(this._options.accessibility)) { capability['bstack:options'].accessibility = (isTrue(this._options.accessibility)) } } }) } else if (typeof capabilities === 'object') { Object.entries(capabilities as Capabilities.RequestedMultiremoteCapabilities).forEach(([, caps]) => { if (!(caps.capabilities as WebdriverIO.Capabilities)['bstack:options']) { if (isBStackSession(this._config)) { const extensionCaps = Object.keys(caps.capabilities).filter((cap) => cap.includes(':')) if (extensionCaps.length) { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { wdioService: BSTACK_SERVICE_VERSION } if (!isUndefined((caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibility'])) { this._accessibilityAutomation ||= isTrue((caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibility']) } else if (isTrue(this._options.accessibility)) { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { wdioService: BSTACK_SERVICE_VERSION, accessibility: (isTrue(this._options.accessibility)) } } } else if (shouldAddServiceVersion(this._config, this._options.testObservability)) { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.wdioService'] = BSTACK_SERVICE_VERSION } } this._buildIdentifier = (caps.capabilities as WebdriverIO.Capabilities)['browserstack.buildIdentifier'] } else { const bstackOptions = (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] bstackOptions!.wdioService = BSTACK_SERVICE_VERSION this._buildName = bstackOptions!.buildName this._projectName = bstackOptions!.projectName this._buildTag = bstackOptions!.buildTag this._buildIdentifier = bstackOptions!.buildIdentifier if (!isUndefined(bstackOptions!.accessibility)) { this._accessibilityAutomation ||= isTrue(bstackOptions!.accessibility) } else if (isTrue(this._options.accessibility)) { bstackOptions!.accessibility = isTrue(this._options.accessibility) } } }) } this.browserStackConfig.buildIdentifier = this._buildIdentifier this.browserStackConfig.buildName = this._buildName PerformanceTester.startMonitoring('performance-report-launcher.csv') if (!isUndefined(this._options.accessibility)) { this._accessibilityAutomation ||= isTrue(this._options.accessibility) } this._options.accessibility = this._accessibilityAutomation as boolean // by default observability will be true unless specified as false this._options.testObservability = this._options.testObservability !== false if (this._options.testObservability && // update files to run if it's a rerun process.env[RERUN_ENV] && process.env[RERUN_TESTS_ENV] ) { this._config.specs = process.env[RERUN_TESTS_ENV].split(',') } try { CrashReporter.setConfigDetails(this._config, capabilities, this._options) } catch (error: unknown) { BStackLogger.error(`[Crash_Report_Upload] Config processing failed due to ${error}`) } } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_SETUP) async onWorkerStart (cid: string, caps: WebdriverIO.Capabilities) { try { if (this._options.percy && this._percyBestPlatformCaps) { const isThisBestPercyPlatform = ObjectsAreEqual(caps, this._percyBestPlatformCaps) if (isThisBestPercyPlatform) { process.env.BEST_PLATFORM_CID = cid } } } catch (err) { PercyLogger.error(`Error while setting best platform for Percy snapshot at worker start ${err}`) } } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_PRE_TEST) async onPrepare (config: Options.Testrunner, capabilities: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities) { // Send Funnel start request await sendStart(this.browserStackConfig) // Setting up healing for those sessions where we don't add the service version capability as it indicates that the session is not being run on BrowserStack if (!shouldAddServiceVersion(this._config, this._options.testObservability, capabilities as Capabilities.BrowserStackCapabilities)) { try { if ((capabilities as Capabilities.BrowserStackCapabilities).browserName) { capabilities = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities as WebdriverIO.Capabilities, false) } else if ( Array.isArray(capabilities)){ for (let i = 0; i < capabilities.length; i++) { if ((capabilities[i] as Capabilities.BrowserStackCapabilities).browserName) { capabilities[i] = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities[i] as WebdriverIO.Capabilities, false) } } } else if (isValidCapsForHealing(capabilities)) { // setting up healing in case capabilities.xyz.capabilities.browserName where xyz can be anything: capabilities = await AiHandler.setup(this._config, this.browserStackConfig, this._options, capabilities, true) } } catch (err) { if (this._options.selfHeal === true) { BStackLogger.warn(`Error while setting up Browserstack healing Extension ${err}. Disabling healing for this session.`) } } } /** * Upload app to BrowserStack if valid file path to app is given. * Update app value of capability directly if app_url, custom_id, shareable_id is given */ if (!this._options.app) { BStackLogger.debug('app is not defined in browserstack-service config, skipping ...') } else { let app: App = {} const appConfig: AppConfig | string = this._options.app try { app = await this._validateApp(appConfig) } catch (error: unknown){ throw new SevereServiceError((error as Error).message) } if (VALID_APP_EXTENSION.includes(path.extname(app.app!))){ if (fs.existsSync(app.app!)) { const data: AppUploadResponse = await this._uploadApp(app) BStackLogger.info(`app upload completed: ${JSON.stringify(data)}`) app.app = data.app_url } else if (app.customId){ app.app = app.customId } else { throw new SevereServiceError(`[Invalid app path] app path ${app.app} is not correct, Provide correct path to app under test`) } } BStackLogger.info(`Using app: ${app.app}`) this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'app', app.app) } /** * buildIdentifier in service options will take precedence over specified in capabilities */ if (this._options.buildIdentifier) { this._buildIdentifier = this._options.buildIdentifier this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'buildIdentifier', this._buildIdentifier) } /** * evaluate buildIdentifier in case unique execution identifiers are present * e.g., ${BUILD_NUMBER} and ${DATE_TIME} */ this._handleBuildIdentifier(capabilities as Capabilities.TestrunnerCapabilities) // remove accessibilityOptions from the capabilities if present this._updateObjectTypeCaps(capabilities as Capabilities.TestrunnerCapabilities, 'accessibilityOptions') const shouldSetupPercy = this._options.percy || (isUndefined(this._options.percy) && this._options.app) let buildStartResponse = null if (this._options.testObservability || this._accessibilityAutomation || shouldSetupPercy) { BStackLogger.debug('Sending launch start event') buildStartResponse = await launchTestSession(this._options, this._config, { projectName: this._projectName, buildName: this._buildName, buildTag: this._buildTag, bstackServiceVersion: BSTACK_SERVICE_VERSION, buildIdentifier: this._buildIdentifier }, this.browserStackConfig, this._accessibilityAutomation) } //added checks for Accessibility running on non-bstack infra if (isAccessibilityAutomationSession(this._accessibilityAutomation) && (process.env.BROWSERSTACK_TURBOSCALE || !shouldAddServiceVersion(this._config, this._options.testObservability))){ const overrideOptions: Partial = accessibilityScripts.ChromeExtension this._updateObjectTypeCaps(capabilities, 'goog:chromeOptions', overrideOptions) } if (buildStartResponse?.accessibility) { if (this._accessibilityAutomation === null) { this.browserStackConfig.accessibility = buildStartResponse.accessibility.success as boolean this._accessibilityAutomation = buildStartResponse.accessibility.success as boolean this._options.accessibility = buildStartResponse.accessibility.success as boolean if (buildStartResponse.accessibility.success === true) { this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'accessibility', 'true') } } } this.browserStackConfig.accessibility = this._accessibilityAutomation as boolean if (this._accessibilityAutomation && this._options.accessibilityOptions) { const filteredOpts = Object.keys(this._options.accessibilityOptions) .filter(key => !NOT_ALLOWED_KEYS_IN_CAPS.includes(key)) .reduce((opts, key) => { return { ...opts, [key]: this._options.accessibilityOptions?.[key] } }, {}) this._updateObjectTypeCaps(capabilities as Capabilities.TestrunnerCapabilities, 'accessibilityOptions', filteredOpts) } else if (isAccessibilityAutomationSession(this._accessibilityAutomation)) { this._updateObjectTypeCaps(capabilities as Capabilities.TestrunnerCapabilities, 'accessibilityOptions', {}) } if (shouldSetupPercy) { try { const bestPlatformPercyCaps = getBestPlatformForPercySnapshot(capabilities as Capabilities.TestrunnerCapabilities) this._percyBestPlatformCaps = bestPlatformPercyCaps as WebdriverIO.Capabilities process.env[BROWSERSTACK_PERCY] = 'false' await this.setupPercy(this._options, this._config, { projectName: this._projectName }) this._updateBrowserStackPercyConfig() } catch (err) { PercyLogger.error(`Error while setting up Percy ${err}`) } } this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'testhubBuildUuid') this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'buildProductMap') if (!this._options.browserstackLocal) { return BStackLogger.info('browserstackLocal is not enabled - skipping...') } const opts = { key: this._config.key, ...this._options.opts } this.browserstackLocal = new BrowserstackLocalLauncher.Local() this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'local') if (opts.localIdentifier) { this._updateCaps(capabilities as Capabilities.TestrunnerCapabilities, 'localIdentifier', opts.localIdentifier) } /** * measure BrowserStack tunnel boot time */ const obs = new PerformanceObserver((list) => { const entry = list.getEntries()[0] BStackLogger.info(`Browserstack Local successfully started after ${entry.duration}ms`) }) obs.observe({ entryTypes: ['measure'] }) let timer: NodeJS.Timeout performance.mark('tbTunnelStart') PerformanceTester.start(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_START) return Promise.race([ promisify(this.browserstackLocal.start.bind(this.browserstackLocal))(opts), new Promise((resolve, reject) => { /* istanbul ignore next */ timer = setTimeout(function () { reject('Browserstack Local failed to start within 60 seconds!') }, 60000) })] ).then(function (result) { clearTimeout(timer) performance.mark('tbTunnelEnd') PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_START) performance.measure('bootTime', 'tbTunnelStart', 'tbTunnelEnd') return Promise.resolve(result) }, function (err) { PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_START, false, err) clearTimeout(timer) return Promise.reject(err) }) } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.EVENTS.SDK_CLEANUP) async onComplete () { BStackLogger.debug('Inside OnComplete hook..') BStackLogger.debug('Sending stop launch event') await stopBuildUpstream() if (process.env[BROWSERSTACK_OBSERVABILITY] && process.env[BROWSERSTACK_TESTHUB_UUID]) { console.log(`\nVisit https://observability.browserstack.com/builds/${process.env[BROWSERSTACK_TESTHUB_UUID]} to view build report, insights, and many more debugging information all at one place!\n`) } this.browserStackConfig.testObservability.buildStopped = true await PerformanceTester.stopAndGenerate('performance-launcher.html') if (process.env[PERF_MEASUREMENT_ENV]) { PerformanceTester.calculateTimes(['launchTestSession', 'stopBuildUpstream']) if (!process.env.START_TIME) { return } const duration = (new Date()).getTime() - (new Date(process.env.START_TIME)).getTime() BStackLogger.info(`Total duration is ${duration / 1000} s`) } BStackLogger.info(`BrowserStack service run ended for id: ${this.browserStackConfig?.sdkRunID} testhub id: ${TestOpsConfig.getInstance()?.buildHashedId}`) await sendFinish(this.browserStackConfig) try { await this._uploadServiceLogs() } catch (error) { BStackLogger.debug(`Failed to upload BrowserStack WDIO Service logs ${error}`) } BStackLogger.clearLogger() if (this._options.percy) { await this.stopPercy() PercyLogger.clearLogger() } if (!this.browserstackLocal || !this.browserstackLocal.isRunning()) { return } if (this._options.forcedStop) { return process.kill(this.browserstackLocal.pid as number) } let timer: NodeJS.Timeout PerformanceTester.start(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_STOP) return Promise.race([ new Promise((resolve, reject) => { this.browserstackLocal?.stop((err: Error) => { if (err) { return reject(err) } resolve() }) }), new Promise((resolve, reject) => { /* istanbul ignore next */ timer = setTimeout( () => reject(new Error('Browserstack Local failed to stop within 60 seconds!')), 60000 ) })] ).then(function (result) { PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_STOP) clearTimeout(timer) return Promise.resolve(result) }, function (err) { PerformanceTester.end(PERFORMANCE_SDK_EVENTS.AUTOMATE_EVENTS.LOCAL_STOP, false, err) clearTimeout(timer) return Promise.reject(err) }) } async setupPercy(options: BrowserstackConfig & Options.Testrunner, config: Options.Testrunner, bsConfig: UserConfig) { if (this._percy?.isRunning()) { process.env[BROWSERSTACK_PERCY] = 'true' return } try { this._percy = await startPercy(options, config, bsConfig) if (!this._percy || (typeof this._percy === 'object' && Object.keys(this._percy).length === 0)) { throw new Error('Could not start percy, check percy logs for info.') } PercyLogger.info('Percy started successfully') process.env[BROWSERSTACK_PERCY] = 'true' let signal = 0 const handler = async () => { signal++ if (signal === 1) { await this.stopPercy() } } process.on('beforeExit', handler) process.on('SIGINT', handler) process.on('SIGTERM', handler) } catch (err) { PercyLogger.debug(`Error in percy setup ${format(err)}`) process.env[BROWSERSTACK_PERCY] = 'false' } } async stopPercy() { if (!this._percy || !this._percy.isRunning()) { return } try { await stopPercy(this._percy) PercyLogger.info('Percy stopped') } catch (err) { PercyLogger.error('Error occured while stopping percy : ' + err) } } @PerformanceTester.Measure(PERFORMANCE_SDK_EVENTS.APP_AUTOMATE_EVENTS.APP_UPLOAD) async _uploadApp(app:App): Promise { BStackLogger.info(`uploading app ${app.app} ${app.customId? `and custom_id: ${app.customId}` : ''} to browserstack`) const form = new FormData() if (app.app) { const fileName = path.basename(app.app) const fileBlob = new Blob([await readFile(app.app)]) form.append('file', fileBlob, fileName) } if (app.customId) { form.append('custom_id', app.customId) } const headers: Record = { Authorization: getBasicAuthHeader(this._config.user as string, this._config.key as string), } const res = await fetch('https://api-cloud.browserstack.com/app-automate/upload', { method: 'POST', body: form, headers }) if (!res.ok) { throw new SevereServiceError(`app upload failed ${res.body}`) } return await res.json() as AppUploadResponse } /** * @param {String | AppConfig} appConfig : should be "app file path" or "app_url" or "custom_id" or "shareable_id". * : only "path" and "custom_id" should coexist as multiple properties. */ async _validateApp (appConfig: AppConfig | string): Promise { const app: App = {} if (typeof appConfig === 'string'){ app.app = appConfig } else if (typeof appConfig === 'object' && Object.keys(appConfig).length) { if (Object.keys(appConfig).length > 2 || (Object.keys(appConfig).length === 2 && (!appConfig.path || !appConfig.custom_id))) { throw new SevereServiceError(`keys ${Object.keys(appConfig)} can't co-exist as app values, use any one property from {id, path, custom_id, shareable_id}, only "path" and "custom_id" can co-exist.`) } app.app = appConfig.id || appConfig.path || appConfig.custom_id || appConfig.shareable_id app.customId = appConfig.custom_id } else { throw new SevereServiceError('[Invalid format] app should be string or an object') } if (!app.app) { throw new SevereServiceError(`[Invalid app property] supported properties are {id, path, custom_id, shareable_id}. For more details please visit https://www.browserstack.com/docs/app-automate/appium/set-up-tests/specify-app ')`) } return app } async _uploadServiceLogs() { const clientBuildUuid = this._getClientBuildUuid() const response = await uploadLogs(getBrowserStackUser(this._config), getBrowserStackKey(this._config), clientBuildUuid) if (response) { BStackLogger.info(`Upload response: ${JSON.stringify(response, null, 2)}`) BStackLogger.logToFile(`Response - ${format(response)}`, 'debug') } } _updateObjectTypeCaps(capabilities?: Capabilities.TestrunnerCapabilities | WebdriverIO.Capabilities, capType?: string, value?: { [key: string]: unknown }) { try { if (Array.isArray(capabilities)) { capabilities .flatMap((c) => { if ('alwaysMatch' in c) { return c.alwaysMatch as WebdriverIO.Capabilities } if (Object.values(c).length > 0 && Object.values(c).every(c => typeof c === 'object' && c.capabilities)) { return Object.values(c).map((o) => o.capabilities) as WebdriverIO.Capabilities[] } return c as WebdriverIO.Capabilities }) .forEach((capability: WebdriverIO.Capabilities) => { if ( validateCapsWithNonBstackA11y(capability.browserName, capability.browserVersion) && capType === 'goog:chromeOptions' && value ) { const chromeOptions = capability['goog:chromeOptions'] as unknown as Capabilities.ChromeOptions if (chromeOptions){ const finalChromeOptions = mergeChromeOptions(chromeOptions, value) capability['goog:chromeOptions'] = finalChromeOptions } else { capability['goog:chromeOptions'] = value } return } if (!capability['bstack:options']) { const extensionCaps = Object.keys(capability).filter((cap) => cap.includes(':')) if (extensionCaps.length) { if (capType === 'accessibilityOptions' && value) { capability['bstack:options'] = { accessibilityOptions: value } } } else if (capType === 'accessibilityOptions') { if (value) { const accessibilityOpts = { ...value } // @ts-expect-error fix invalid cap if (capability?.accessibility) { accessibilityOpts.authToken = process.env.BSTACK_A11Y_JWT accessibilityOpts.scannerVersion = process.env.BSTACK_A11Y_SCANNER_VERSION } capability['browserstack.accessibilityOptions'] = accessibilityOpts } else { delete capability['browserstack.accessibilityOptions'] } } } else if (capType === 'accessibilityOptions') { if (value) { const accessibilityOpts = { ...value } if (capability['bstack:options'].accessibility) { accessibilityOpts.authToken = process.env.BSTACK_A11Y_JWT accessibilityOpts.scannerVersion = process.env.BSTACK_A11Y_SCANNER_VERSION } capability['bstack:options'].accessibilityOptions = accessibilityOpts } else { delete capability['bstack:options'].accessibilityOptions } } }) } else if (typeof capabilities === 'object') { Object.entries(capabilities as Capabilities.RequestedMultiremoteCapabilities).forEach(([, caps]) => { if ( validateCapsWithNonBstackA11y( (caps.capabilities as WebdriverIO.Capabilities).browserName, (caps.capabilities as WebdriverIO.Capabilities).browserVersion ) && capType === 'goog:chromeOptions' && value ) { const chromeOptions = (caps.capabilities as WebdriverIO.Capabilities)['goog:chromeOptions'] as unknown as Capabilities.ChromeOptions if (chromeOptions) { const finalChromeOptions = mergeChromeOptions(chromeOptions, value); (caps.capabilities as WebdriverIO.Capabilities)['goog:chromeOptions'] = finalChromeOptions } else { (caps.capabilities as WebdriverIO.Capabilities)['goog:chromeOptions'] = value } return } if (!(caps.capabilities as WebdriverIO.Capabilities)['bstack:options']) { const extensionCaps = Object.keys(caps.capabilities).filter((cap) => cap.includes(':')) if (extensionCaps.length) { if (capType === 'accessibilityOptions' && value) { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { accessibilityOptions: value } } } else if (capType === 'accessibilityOptions') { if (value) { const accessibilityOpts = { ...value } if ((caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibility']) { accessibilityOpts.authToken = process.env.BSTACK_A11Y_JWT accessibilityOpts.scannerVersion = process.env.BSTACK_A11Y_SCANNER_VERSION } (caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibilityOptions'] = accessibilityOpts } else { delete (caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibilityOptions'] } } } else if (capType === 'accessibilityOptions') { if (value) { const accessibilityOpts = { ...value } if ((caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.accessibility) { accessibilityOpts.authToken = process.env.BSTACK_A11Y_JWT accessibilityOpts.scannerVersion = process.env.BSTACK_A11Y_SCANNER_VERSION } (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.accessibilityOptions = accessibilityOpts } else { delete (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.accessibilityOptions } } }) } } catch (error) { BStackLogger.debug(`Exception while retrieving capability value. Error - ${error}`) } } _updateCaps(capabilities?: Capabilities.TestrunnerCapabilities, capType?: string, value?: string) { if (Array.isArray(capabilities)) { capabilities .flatMap((c) => { if ('alwaysMatch' in c) { return c.alwaysMatch as WebdriverIO.Capabilities } if (Object.values(c).length > 0 && Object.values(c).every(c => typeof c === 'object' && c.capabilities)) { return Object.values(c).map((o) => o.capabilities) as WebdriverIO.Capabilities[] } return c as WebdriverIO.Capabilities }) .forEach((capability: WebdriverIO.Capabilities) => { if (!capability['bstack:options']) { const extensionCaps = Object.keys(capability).filter((cap) => cap.includes(':')) if (extensionCaps.length) { if (capType === 'local') { capability['bstack:options'] = { local: true } } else if (capType === 'app') { capability['appium:app'] = value } else if (capType === 'buildIdentifier' && value) { capability['bstack:options'] = { buildIdentifier: value } } else if (capType === 'testhubBuildUuid') { capability['bstack:options'] = { testhubBuildUuid: TestOpsConfig.getInstance().buildHashedId } } else if (capType === 'buildProductMap') { capability['bstack:options'] = { buildProductMap: getProductMap(this.browserStackConfig) } } else if (capType === 'accessibility') { capability['bstack:options'] = { accessibility: getBooleanValueFromString(value) } } } else if (capType === 'local'){ capability['browserstack.local'] = true } else if (capType === 'app') { // @ts-expect-error fix invalid cap capability.app = value } else if (capType === 'buildIdentifier') { if (value) { capability['browserstack.buildIdentifier'] = value } else { delete capability['browserstack.buildIdentifier'] } } else if (capType === 'localIdentifier') { capability['browserstack.localIdentifier'] = value } else if (capType === 'testhubBuildUuid') { capability['browserstack.testhubBuildUuid'] = TestOpsConfig.getInstance().buildHashedId } else if (capType === 'buildProductMap') { capability['browserstack.buildProductMap'] = getProductMap(this.browserStackConfig) } else if (capType === 'accessibility') { capability['browserstack.accessibility'] = getBooleanValueFromString(value) } } else if (capType === 'local') { capability['bstack:options'].local = true } else if (capType === 'app') { capability['appium:app'] = value } else if (capType === 'buildIdentifier') { if (value) { capability['bstack:options'].buildIdentifier = value } else { delete capability['bstack:options'].buildIdentifier } } else if (capType === 'localIdentifier') { capability['bstack:options'].localIdentifier = value } else if (capType === 'testhubBuildUuid') { capability['bstack:options'].testhubBuildUuid = TestOpsConfig.getInstance().buildHashedId } else if (capType === 'buildProductMap') { capability['bstack:options'].buildProductMap = getProductMap(this.browserStackConfig) } else if (capType === 'accessibility') { capability['bstack:options'].accessibility = getBooleanValueFromString(value) } }) } else if (typeof capabilities === 'object') { Object.entries(capabilities as Capabilities.RequestedMultiremoteCapabilities).forEach(([, caps]) => { if (!(caps.capabilities as WebdriverIO.Capabilities)['bstack:options']) { const extensionCaps = Object.keys(caps.capabilities).filter((cap) => cap.includes(':')) if (extensionCaps.length) { if (capType === 'local') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { local: true } } else if (capType === 'app') { (caps.capabilities as WebdriverIO.Capabilities)['appium:app'] = value } else if (capType === 'buildIdentifier' && value) { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { buildIdentifier: value } } else if (capType === 'testhubBuildUuid') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { testhubBuildUuid: TestOpsConfig.getInstance().buildHashedId } } else if (capType === 'buildProductMap') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { buildProductMap: getProductMap(this.browserStackConfig) } } else if (capType === 'accessibility') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options'] = { accessibility: getBooleanValueFromString(value) } } } else if (capType === 'local'){ (caps.capabilities as WebdriverIO.Capabilities)['browserstack.local'] = true } else if (capType === 'app') { (caps.capabilities as WebdriverIO.Capabilities)['appium:app'] = value } else if (capType === 'buildIdentifier') { if (value) { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.buildIdentifier'] = value } else { delete (caps.capabilities as WebdriverIO.Capabilities)['browserstack.buildIdentifier'] } } else if (capType === 'localIdentifier') { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.localIdentifier'] = value } else if (capType === 'testhubBuildUuid') { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.testhubBuildUuid'] = TestOpsConfig.getInstance().buildHashedId } else if (capType === 'buildProductMap') { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.buildProductMap'] = getProductMap(this.browserStackConfig) } else if (capType === 'accessibility') { (caps.capabilities as WebdriverIO.Capabilities)['browserstack.accessibility'] = getBooleanValueFromString(value) } } else if (capType === 'local'){ (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.local = true } else if (capType === 'app') { (caps.capabilities as WebdriverIO.Capabilities)['appium:app'] = value } else if (capType === 'buildIdentifier') { if (value) { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.buildIdentifier = value } else { delete (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.buildIdentifier } } else if (capType === 'localIdentifier') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.localIdentifier = value } else if (capType === 'testhubBuildUuid') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.testhubBuildUuid = TestOpsConfig.getInstance().buildHashedId } else if (capType === 'buildProductMap') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.buildProductMap = getProductMap(this.browserStackConfig) } else if (capType === 'accessibility') { (caps.capabilities as WebdriverIO.Capabilities)['bstack:options']!.accessibility = getBooleanValueFromString(value) } }) } else { throw new SevereServiceError('Capabilities should be an object or Array!') } } _handleBuildIdentifier(capabilities?: Capabilities.TestrunnerCapabilities) { if (!this._buildIdentifier) { return } if ((!this._buildName || process.env.BROWSERSTACK_BUILD_NAME) && this._buildIdentifier) { this._updateCaps(capabilities, 'buildIdentifier') BStackLogger.warn('Skipping buildIdentifier as buildName is not passed.') return } if (this._buildIdentifier && this._buildIdentifier.includes('${DATE_TIME}')){ const formattedDate = new Intl.DateTimeFormat('en-GB', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) .format(new Date()) .replace(/ |, /g, '-') this._buildIdentifier = this._buildIdentifier.replace('${DATE_TIME}', formattedDate) this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier) } if (!this._buildIdentifier.includes('${BUILD_NUMBER}')) { return } const ciInfo = getCiInfo() if (ciInfo !== null && ciInfo.build_number) { this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', 'CI '+ ciInfo.build_number) this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier) } else { const localBuildNumber = this._getLocalBuildNumber() if (localBuildNumber) { this._buildIdentifier = this._buildIdentifier.replace('${BUILD_NUMBER}', localBuildNumber) this._updateCaps(capabilities, 'buildIdentifier', this._buildIdentifier) } } } _updateBrowserStackPercyConfig() { const { percyAutoEnabled = false, percyCaptureMode, buildId, percy } = this._percy || {} // Setting to browserStackConfig for populating data in funnel instrumentaion this.browserStackConfig.percyCaptureMode = percyCaptureMode this.browserStackConfig.percyBuildId = buildId this.browserStackConfig.isPercyAutoEnabled = percyAutoEnabled // To handle stop percy build this._options.percy = percy // To pass data to workers process.env.BROWSERSTACK_PERCY = String(percy) process.env.BROWSERSTACK_PERCY_CAPTURE_MODE = percyCaptureMode } /** * @return {string} if buildName doesn't exist in json file, it will return 1 * else returns corresponding value in json file (e.g. { "wdio-build": { "identifier" : 2 } } => 2 in this case) */ _getLocalBuildNumber() { const browserstackFolderPath = path.join(os.homedir(), '.browserstack') try { if (!fs.existsSync(browserstackFolderPath)){ fs.mkdirSync(browserstackFolderPath) } const filePath = path.join(browserstackFolderPath, '.build-name-cache.json') if (!fs.existsSync(filePath)) { fs.appendFileSync(filePath, JSON.stringify({})) } const buildCacheFileData = fs.readFileSync(filePath) const parsedBuildCacheFileData = JSON.parse(buildCacheFileData.toString()) if (this._buildName && this._buildName in parsedBuildCacheFileData) { const prevIdentifier = parseInt((parsedBuildCacheFileData[this._buildName].identifier)) const newIdentifier = prevIdentifier + 1 this._updateLocalBuildCache(filePath, this._buildName, newIdentifier) return newIdentifier.toString() } const newIdentifier = 1 this._updateLocalBuildCache(filePath, this._buildName, 1) return newIdentifier.toString() } catch { return null } } _updateLocalBuildCache(filePath?:string, buildName?:string, buildIdentifier?:number) { if (!buildName || !filePath) { return } const jsonContent = JSON.parse(fs.readFileSync(filePath).toString()) jsonContent[buildName] = { 'identifier': buildIdentifier } fs.writeFileSync(filePath, JSON.stringify(jsonContent)) } _getClientBuildUuid() { if (process.env[BROWSERSTACK_TESTHUB_UUID]) { return process.env[BROWSERSTACK_TESTHUB_UUID] } const uuid = this.browserStackConfig?.sdkRunID BStackLogger.logToFile(`If facing any issues, please contact BrowserStack support with the Build Run Id - ${uuid}`, 'info') return uuid } }