import pickBy from 'lodash.pickby'
import logger from '@wdio/logger'
import { SevereServiceError } from 'webdriverio'
import { ConfigParser } from '@wdio/config/node'
import { CAPABILITY_KEYS } from '@wdio/protocols'
import type { Capabilities, Services } from '@wdio/types'
import {
ANDROID_CONFIG,
IOS_CONFIG,
} from './constants.js'
import type {
OnCompleteResult,
ReplCommandArguments,
} from './types.js'
const log = logger('@wdio/cli:utils')
export class HookError extends SevereServiceError {
public origin: string
constructor(message: string, origin: string) {
super(message)
this.origin = origin
}
}
* run service launch sequences
*/
export async function runServiceHook(
launcher: Services.ServiceInstance[],
hookName: keyof Services.HookFunctions,
...args: unknown[]
) {
const start = Date.now()
return Promise.all(launcher.map(async (service: Services.ServiceInstance) => {
try {
if (typeof service[hookName] === 'function') {
await (service[hookName] as Function)(...args)
}
} catch (err) {
const message = `A service failed in the '${hookName}' hook\n${(err as Error).stack}\n\n`
if (err instanceof SevereServiceError || (err as Error).name === 'SevereServiceError') {
return { status: 'rejected', reason: message, origin: hookName }
}
log.error(`${message}Continue...`)
}
})).then(results => {
if (launcher.length) {
log.debug(`Finished to run "${hookName}" hook in ${Date.now() - start}ms`)
}
const rejectedHooks = results.filter(p => p && p.status === 'rejected')
if (rejectedHooks.length) {
return Promise.reject(new HookError(`\n${rejectedHooks.map(p => p && p.reason).join()}\n\nStopping runner...`, hookName))
}
})
}
* Run hook in service launcher
* @param {Array|Function} hook - can be array of functions or single function
* @param {object} config
* @param {object} capabilities
*/
export async function runLauncherHook(hook: Function | Function[], ...args: unknown[]) {
if (typeof hook === 'function') {
hook = [hook]
}
const catchFn = (e: Error) => {
log.error(`Error in hook: ${e.stack}`)
if (e instanceof SevereServiceError) {
throw new HookError(e.message, (hook as Function[])[0].name)
}
}
return Promise.all(hook.map((hook) => {
try {
return hook(...args)
} catch (err) {
return catchFn(err as Error)
}
})).catch(catchFn)
}
* Run onCompleteHook in Launcher
* @param {Array|Function} onCompleteHook - can be array of functions or single function
* @param {*} config
* @param {*} capabilities
* @param {*} exitCode
* @param {*} results
*/
export async function runOnCompleteHook(
onCompleteHook: Function | Function[],
config: WebdriverIO.Config,
capabilities: Capabilities.TestrunnerCapabilities,
exitCode: number,
results: OnCompleteResult
) {
if (typeof onCompleteHook === 'function') {
onCompleteHook = [onCompleteHook]
}
return Promise.all(onCompleteHook.map(async (hook) => {
try {
await hook(exitCode, config, capabilities, results)
return 0
} catch (err) {
log.error(`Error in onCompleteHook: ${(err as Error).stack}`)
if (err instanceof SevereServiceError) {
throw new HookError(err.message, 'onComplete')
}
return 1
}
}))
}
* get runner identification by caps
*/
export function getRunnerName(caps: WebdriverIO.Capabilities = {}) {
let runner =
caps.browserName ||
caps.platformName ||
caps['appium:platformName'] ||
caps['appium:appPackage'] ||
caps['appium:appWaitActivity'] ||
caps['appium:app']
if (!runner) {
runner = Object.values(caps).length === 0 || Object.values(caps).some(cap => !cap.capabilities) ? 'undefined' : 'MultiRemote'
}
return runner
}
export function findInConfig(config: string, type: string) {
let regexStr = `[\\/\\/]*[\\s]*${type}s: [\\s]*\\[([\\s]*['|"]\\w*['|"],*)*[\\s]*\\]`
if (type === 'framework') {
regexStr = `[\\/\\/]*[\\s]*${type}: ([\\s]*['|"]\\w*['|"])`
}
const regex = new RegExp(regexStr, 'gmi')
return config.match(regex)
}
export async function getCapabilities(arg: ReplCommandArguments) {
const optionalCapabilites = {
platformVersion: arg.platformVersion,
udid: arg.udid,
...(arg.deviceName && { deviceName: arg.deviceName })
}
* Parsing of option property and constructing desiredCapabilities
* for Appium session. Could be application(1) or browser(2-3) session.
*/
if (/.*\.(apk|app|ipa)$/.test(arg.option)) {
return {
capabilities: {
app: arg.option,
...(arg.option.endsWith('apk') ? ANDROID_CONFIG : IOS_CONFIG),
...optionalCapabilites,
}
}
} else if (/android/.test(arg.option)) {
return { capabilities: { browserName: 'Chrome', ...ANDROID_CONFIG, ...optionalCapabilites } }
} else if (/ios/.test(arg.option)) {
return { capabilities: { browserName: 'Safari', ...IOS_CONFIG, ...optionalCapabilites } }
} else if (/(js|ts)$/.test(arg.option)) {
const config = new ConfigParser(arg.option)
try {
await config.initialize()
} catch (e) {
throw Error((e as { code: string }).code === 'MODULE_NOT_FOUND' ? `Config File not found: ${arg.option}` :
`Could not parse ${arg.option}, failed with error: ${(e as Error).message}`)
}
if (typeof arg.capabilities === 'undefined') {
throw Error('Please provide index/named property of capability to use from the capabilities array/object in wdio config file')
}
let requiredCaps = config.getCapabilities()
requiredCaps = (
(requiredCaps as (Capabilities.RequestedStandaloneCapabilities)[])[parseInt(arg.capabilities, 10)] ||
(requiredCaps as Capabilities.RequestedMultiremoteCapabilities)[arg.capabilities]
)
const requiredW3CCaps = pickBy(requiredCaps, (_: never, key: string) => CAPABILITY_KEYS.includes(key) || key.includes(':'))
if (!Object.keys(requiredW3CCaps).length) {
throw Error(`No capability found in given config file with the provided capability indexed/named property: ${arg.capabilities}. Please check the capability in your wdio config file.`)
}
return { capabilities: { ...(requiredW3CCaps as Capabilities.W3CCapabilities) } }
}
return { capabilities: { browserName: arg.option } }
}
const cucumberTypes: Record<string, string> = {
paths: 'array',
backtrace: 'boolean',
dryRun: 'boolean',
forceExit: 'boolean',
failFast: 'boolean',
format: 'array',
formatOptions: 'object',
import: 'array',
language: 'string',
name: 'array',
order: 'string',
publish: 'boolean',
requireModule: 'array',
retry: 'number',
retryTagFilter: 'string',
strict: 'boolean',
tags: 'string',
worldParameters: 'object',
timeout: 'number',
scenarioLevelReporter: 'boolean',
tagsInTitle: 'boolean',
ignoreUndefinedDefinitions: 'boolean',
failAmbiguousDefinitions: 'boolean',
tagExpression: 'string',
profiles: 'array',
file: 'string'
}
const mochaTypes: Record<string, string> = {
require: 'array',
compilers: 'array',
allowUncaught: 'boolean',
asyncOnly: 'boolean',
bail: 'boolean',
checkLeaks: 'boolean',
delay: 'boolean',
fgrep: 'string',
forbidOnly: 'boolean',
forbidPending: 'boolean',
fullTrace: 'boolean',
global: 'array',
grep: 'string',
invert: 'boolean',
retries: 'number',
timeout: 'number',
ui: 'string'
}
const jasmineTypes: Record<string, string> = {
defaultTimeoutInterval: 'number',
helpers: 'array',
requires: 'array',
random: 'boolean',
seed: 'string',
failFast: 'boolean',
failSpecWithNoExpectations: 'boolean',
oneFailurePerSpec: 'boolean',
grep: 'string',
invertGrep: 'boolean',
cleanStack: 'boolean',
stopOnSpecFailure: 'boolean',
stopSpecOnExpectationFailure: 'boolean',
requireModule: 'array',
}
type CLIParams = { [x: string]: boolean | string | number | (string | boolean | number)[] }
export function coerceOpts (
types: Record<string, string>,
opts: CLIParams
) {
for (const key in opts) {
if (types[key] === 'boolean' && typeof opts[key] === 'string') {
opts[key] = opts[key] === 'true'
} else if (types[key] === 'number') {
opts[key] = Number(opts[key])
} else if (types[key] === 'array') {
opts[key] = Array.isArray(opts[key]) ? opts[key] : [opts[key]]
} else if (types[key] === 'object' && typeof opts[key] === 'string') {
opts[key] = JSON.parse(opts[key])
}
}
return opts
}
export function coerceOptsFor(framework: 'cucumber' | 'mocha' | 'jasmine') {
if (framework === 'cucumber') {
return coerceOpts.bind(null, cucumberTypes)
} else if (framework === 'mocha') {
return coerceOpts.bind(null, mochaTypes)
} else if (framework === 'jasmine') {
return coerceOpts.bind(null, jasmineTypes)
}
throw new Error(`Unsupported framework "${framework}"`)
}
enum NodeVersion {
'major' = 0,
'minor' = 1,
'patch' = 2
}
export function nodeVersion(type: keyof typeof NodeVersion): number {
return process.versions.node.split('.').map(Number)[NodeVersion[type]]
}