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()
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']) {
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
}
}
this._buildIdentifier = capability['browserstack.buildIdentifier']?.toString()
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
this._options.testObservability = this._options.testObservability !== false
if (this._options.testObservability
&&
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) {
await sendStart(this.browserStackConfig)
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)) {
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)
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)
}
if (isAccessibilityAutomationSession(this._accessibilityAutomation) && (process.env.BROWSERSTACK_TURBOSCALE || !shouldAddServiceVersion(this._config, this._options.testObservability))){
const overrideOptions: Partial<Capabilities.ChromeOptions> = 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) => {
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<void>((resolve, reject) => {
this.browserstackLocal?.stop((err: Error) => {
if (err) {
return reject(err)
}
resolve()
})
}),
new Promise((resolve, reject) => {
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<AppUploadResponse> {
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<string, string> = {
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 <string>: should be "app file path" or "app_url" or "custom_id" or "shareable_id".
* <object>: only "path" and "custom_id" should coexist as multiple properties.
*/
async _validateApp (appConfig: AppConfig | string): Promise<App> {
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<string>, path<string>, custom_id<string>, shareable_id<string>}, 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<string>, path<string>, custom_id<string>, shareable_id<string>}.
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 }
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') {
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 || {}
this.browserStackConfig.percyCaptureMode = percyCaptureMode
this.browserStackConfig.percyBuildId = buildId
this.browserStackConfig.isPercyAutoEnabled = percyAutoEnabled
this._options.percy = percy
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
}
}