import path from 'node:path'
import logger from '@wdio/logger'
import { deepmerge, deepmergeCustom } from 'deepmerge-ts'
import type { Capabilities, Options, Reporters, Services } from '@wdio/types'
import FileSystemPathService from './FileSystemPathService.js'
import { makeRelativeToCWD } from './utils.js'
import { removeLineNumbers, isCucumberFeatureWithLineNumber, validObjectOrArray } from '../utils.js'
import { SUPPORTED_HOOKS, SUPPORTED_FILE_EXTENSIONS, DEFAULT_CONFIGS, NO_NAMED_CONFIG_EXPORT } from '../constants.js'
import type { PathService } from '../types.js'
const log = logger('@wdio/config:ConfigParser')
const MERGE_DUPLICATION = ['services', 'reporters', 'capabilities'] as const
type KeyWithMergeDuplication = (typeof MERGE_DUPLICATION)[number]
type Spec = string | string[]
type ESMImport = { config?: TestrunnerOptionsWithParameters }
type DefaultImport = { default?: { config?: TestrunnerOptionsWithParameters } }
type ImportedConfigModule = ESMImport | DefaultImport
interface TestrunnerOptionsWithParameters extends Options.Testrunner {
watch?: boolean
coverage?: boolean
spec?: string[]
suite?: string[]
repeat?: number
capabilities?: Capabilities.TestrunnerCapabilities
rootDir: string
tsConfigPath?: string
}
interface MergeConfig extends Omit<Partial<TestrunnerOptionsWithParameters>, 'specs' | 'exclude'> {
specs?: Spec[]
'wdio:specs'?: Spec[]
exclude?: string[]
'wdio:exclude'?: string[]
group?: boolean
}
export default class ConfigParser {
#isInitialised = false
#configFilePath: string
private _config: TestrunnerOptionsWithParameters
private _capabilities?: Capabilities.TestrunnerCapabilities = []
constructor(
configFilePath: string,
* config options parsed in via CLI arguments and applied before
* trying to compile config file
*/
private _initialConfig: Partial<TestrunnerOptionsWithParameters> = {},
private _pathService: PathService = new FileSystemPathService()
) {
this.#configFilePath = configFilePath
this._config = Object.assign(
{ rootDir: path.dirname(configFilePath) },
DEFAULT_CONFIGS()
)
* specs applied as CLI arguments should be relative from CWD
* rather than relative to the config file
*/
if (_initialConfig.spec) {
_initialConfig.spec = makeRelativeToCWD(_initialConfig.spec) as string[]
}
this.merge(_initialConfig, false)
}
* initializes the config object
*/
async initialize(object: MergeConfig = {}) {
* only run auto compile functionality once but allow the config parse to be initialized
* multiple times, e.g. when used with the packages/wdio-cli/src/watcher.ts
*/
if (!this.#isInitialised) {
await this.addConfigFile(this.#configFilePath)
}
this.merge({ ...object })
* enable/disable coverage reporting
*/
if (Object.keys(this._initialConfig || {}).includes('coverage')) {
if (this._config.runner === 'browser') {
this._config.runner = ['browser', {
coverage: { enabled: this._initialConfig.coverage }
}]
} else if (Array.isArray(this._config.runner) && this._config.runner[0] === 'browser') {
(this._config.runner[1] as any).coverage = {
...(this._config.runner[1] as any).coverage,
enabled: this._initialConfig.coverage
}
}
}
this.#isInitialised = true
}
* merges config file with default values
* @param {string} filename path of file relative to current directory
*/
private async addConfigFile(filename: string) {
if (typeof filename !== 'string') {
throw new Error('addConfigFile requires filepath')
}
* resolve config file path always relative to working directory
*/
const filePath = this._pathService.ensureAbsolutePath(filename, process.cwd())
try {
* Check if direct exports got assigned as default exports and if so
* be more flexible and pick allow for these as well.
*/
const importedModule = await this._pathService.loadFile<ImportedConfigModule>(filePath)
const config = (importedModule as ESMImport).config || (importedModule as DefaultImport).default?.config
if (typeof config !== 'object') {
throw new Error(NO_NAMED_CONFIG_EXPORT)
}
const configFileCapabilities = config.capabilities
if (!configFileCapabilities) {
throw new Error(`No \`capabilities\` property found in WebdriverIO.Config defined in file: ${filePath}`)
}
* clone the original config
*/
const fileConfig = Object.assign({}, config)
* merge capabilities
*/
const defaultTo = (Array.isArray(this._capabilities) ? [] : {}) as Capabilities.TestrunnerCapabilities
this._capabilities = deepmerge(this._capabilities, fileConfig.capabilities || defaultTo)
delete fileConfig.capabilities
* Add hooks from the file config and remove them from file config object to avoid
* complications when using merge function
*/
this.addService(fileConfig)
for (const hookName of SUPPORTED_HOOKS) {
delete fileConfig[hookName]
}
this._config = deepmerge(this._config, fileConfig)
* remove `watch` from config as far as it can be only passed as command line argument
*/
delete this._config.watch
} catch (e: unknown) {
log.error(`Failed loading configuration file: ${filePath}:`, (e as Error).message)
throw e
}
}
* merge external object with config object
* @param {Object} object desired object to merge into the config object
* @param {boolean} [addPathToSpecs=true] this flag determines whether it is necessary to find paths to specs if the --spec parameter was passed in CLI
*/
private merge(object: MergeConfig = {}, addPathToSpecs = true) {
const spec = Array.isArray(object.spec) ? object.spec : []
const exclude = Array.isArray(object.exclude) ? object.exclude : []
* Add deepmergeCustom to remove array('services', 'reporters', 'capabilities') duplication in the config object
*/
const customDeepMerge = deepmergeCustom({
mergeArrays: ([oldValue, newValue], utils, meta) => {
const key = meta?.key as KeyWithMergeDuplication
if (meta && MERGE_DUPLICATION.includes(key)) {
const origWithoutObjectEntries = oldValue.filter((value: [Services.ServiceClass, WebdriverIO.ServiceOption] | [Reporters.ReporterClass, WebdriverIO.ReporterOption]) => typeof value !== 'object')
return Array.from(new Set(deepmerge(newValue, origWithoutObjectEntries)))
}
return utils.actions.defaultMerge
}
})
this._config = customDeepMerge(this._config, object) as TestrunnerOptionsWithParameters
* overwrite config specs that got piped into the wdio command,
* also adhering to the wdio-prefixes from a capability
*/
if (object['wdio:specs'] && object['wdio:specs'].length > 0) {
this._config.specs = object['wdio:specs'] as Spec[]
} else if (object.specs && object.specs.length > 0) {
this._config.specs = object.specs as string[]
}
if (object['wdio:exclude'] && object['wdio:exclude'].length > 0) {
this._config.exclude = object['wdio:exclude'] as string[]
} else if (object.exclude && object.exclude.length > 0) {
this._config.exclude = object.exclude as string[]
}
* cleanup duplicated "suite" if the same value was provided
*/
if (object.suite && object.suite.length > 0) {
this._config.suite = this._config.suite?.filter((suite, idx, suites) => suites.indexOf(suite) === idx)
}
* overwrite capabilities
*/
this._capabilities = validObjectOrArray(this._config.capabilities as object) ? this._config.capabilities : this._capabilities
* save original specs if Cucumber's feature line number is provided
*/
if (this._config.spec && isCucumberFeatureWithLineNumber(this._config.spec)) {
* `this._config.spec` is string instead of Array in watch mode
*/
this._config.cucumberFeaturesWithLineNumbers = Array.isArray(this._config.spec) ? [...new Set(this._config.spec)] : [this._config.spec]
}
* run single spec file only, regardless of multiple-spec specification
*/
if (addPathToSpecs && spec.length > 0) {
this._config.specs = this.setFilePathToFilterOptions(spec, this._config.specs!, object.group)
}
* At this step function allKeywordsContainPath() allows us to make sure
* that all arguments, passed to '--exclude' param, are paths to specs.
* So they can be processed in setFilePathToFilterOptions()
* Otherwise, the application crashes with an error.
* Therefore, if --exclude contains not paths, but keywords, e.g. 'dialog', 'test.component' etc.,
* then filtering of excluded specs occurs in the filterSpecs() method
*/
if (exclude.length > 0 && allKeywordsContainPath(exclude)) {
this._config.exclude = this.setFilePathToFilterOptions(exclude, this._config.exclude!)
} else if (exclude.length > 0) {
this._config.exclude = exclude
}
}
* Add hooks from an existing service to the runner config.
* @param {object} service - an object that contains hook methods.
*/
addService(service: Services.Hooks) {
const addHook = (hookName: string, hook: Function) => {
const existingHooks = this._config[hookName]
if (!existingHooks) {
this._config[hookName] = hook.bind(service)
} else if (typeof existingHooks === 'function') {
this._config[hookName] = [existingHooks, hook.bind(service)]
} else {
this._config[hookName] = [...existingHooks, hook.bind(service)]
}
}
for (const hookName of SUPPORTED_HOOKS) {
const hooksToBeAdded = service[hookName]
if (!hooksToBeAdded) {
continue
}
if (typeof hooksToBeAdded === 'function') {
addHook(hookName, hooksToBeAdded)
} else if (Array.isArray(hooksToBeAdded)) {
for (const hookToAdd of hooksToBeAdded) {
if (typeof hookToAdd === 'function') {
addHook(hookName, hookToAdd)
}
}
}
}
}
* determine what specs to run based on the spec(s), suite(s), exclude
* attributes from CLI, config and capabilities
*/
getSpecs(capSpecs?: Spec[], capExclude?: Spec[]) {
const isSpecParamPassed = Array.isArray(this._config.spec) && this._config.spec.length > 0
const repeat = this._config.repeat
let specs = ConfigParser.getFilePaths(this._config.specs!, this._config.rootDir, this._pathService)
let exclude = allKeywordsContainPath(this._config.exclude!)
? ConfigParser.getFilePaths(this._config.exclude!, this._config.rootDir, this._pathService)
: this._config.exclude || []
const suites = Array.isArray(this._config.suite) ? this._config.suite : []
if (Array.isArray(capExclude)) {
exclude = [...exclude, ...ConfigParser.getFilePaths(capExclude, this._config.rootDir, this._pathService)]
}
if (!isSpecParamPassed && Array.isArray(capSpecs)) {
specs = ConfigParser.getFilePaths(capSpecs, this._config.rootDir, this._pathService)
}
if (suites.length > 0) {
let suiteSpecs: Spec[] = []
for (const suiteName of suites) {
const suite = this._config.suites?.[suiteName]
if (!suite) {
log.warn(`No suite was found with name "${suiteName}"`)
}
if (Array.isArray(suite)) {
suiteSpecs = suiteSpecs.concat(ConfigParser.getFilePaths(suite, this._config.rootDir, this._pathService))
}
}
if (suiteSpecs.length === 0) {
throw new Error(`The suite(s) "${suites.join('", "')}" you specified don't exist ` +
'in your config file or doesn\'t contain any files!')
}
specs = isSpecParamPassed ? [...specs, ...suiteSpecs] : suiteSpecs
}
specs = filterDublicationArrayItems(specs)
const hasSubsetOfSpecsDefined = isSpecParamPassed || suites.length > 0
if (repeat && hasSubsetOfSpecsDefined) {
specs = Array.from({ length: repeat }, () => specs).flat()
} else if (repeat && !hasSubsetOfSpecsDefined) {
throw new Error('The --repeat flag requires that either the --spec or --suite flag is also set')
}
return this.shard(
this.filterSpecs(specs, <string[]>exclude)
)
}
* sets config attribute with file paths from filtering
* options from cli argument
*
* @param {string[]} cliArgFileList list of files in a string form
* @param {Object} config config object that stores the spec and exclude attributes
* cli argument
* @return {String[]} List of files that should be included or excluded
*/
setFilePathToFilterOptions(cliArgFileList: string[], specs: Spec[], group?: boolean) {
const filesToFilter = new Set<string>()
const fileList = ConfigParser.getFilePaths(specs, this._config.rootDir, this._pathService)
cliArgFileList.forEach(filteredFile => {
filteredFile = removeLineNumbers(filteredFile)
const globMatchedFiles = <string[]>ConfigParser.getFilePaths(
group ? [[filteredFile]] : [filteredFile],
this._config.rootDir,
this._pathService
)
if (this._pathService.isFile(filteredFile)) {
filesToFilter.add(
this._pathService.ensureAbsolutePath(
filteredFile,
path.dirname(this.#configFilePath)
)
)
} else if (globMatchedFiles.length) {
globMatchedFiles.forEach(file => filesToFilter.add(file))
} else {
fileList.forEach(file => {
if (typeof file === 'string') {
if (isValidRegex(filteredFile) && file.match(filteredFile)) {
filesToFilter.add(file)
}
} else if (Array.isArray(file)) {
file.forEach(subFile => {
if (isValidRegex(filteredFile) && subFile.match(filteredFile)) {
filesToFilter.add(subFile)
}
})
} else {
log.warn('Unexpected entry in specs that is neither string nor array: ', file)
}
})
}
})
if (filesToFilter.size === 0) {
throw new Error(`spec file(s) ${cliArgFileList.join(', ')} not found`)
}
return [...filesToFilter]
}
* return configs
*/
getConfig() {
if (!this.#isInitialised) {
throw new Error('ConfigParser was not initialized, call "await config.initialize()" first!')
}
return this._config as Required<WebdriverIO.Config>
}
* return capabilities
*/
getCapabilities(i?: number): Capabilities.TestrunnerCapabilities | Capabilities.RequestedStandaloneCapabilities {
if (!this.#isInitialised) {
throw new Error('ConfigParser was not initialized, call "await config.initialize()" first!')
}
if (typeof i === 'number' && Array.isArray(this._capabilities) && this._capabilities[i]) {
return this._capabilities[i]
}
return this._capabilities!
}
* returns a flattened list of globbed files
*
* @param {String[] | String[][]} patterns list of files to glob
* @param {Boolean} omitWarnings to indicate omission of warnings
* @param {FileSystemPathService} findAndGlob system path service for expanding globbed file names
* @param {number} hierarchyDepth depth to prevent recursive calling beyond a depth of 1
* @return {String[] | String[][]} list of files
*/
static getFilePaths(patterns: Spec[], rootDir: string, findAndGlob: PathService = new FileSystemPathService(), hierarchyDepth?: number) {
let files: Spec[] = []
let groupedFiles: string[] = []
if (typeof patterns === 'string') {
patterns = [patterns]
}
if (!Array.isArray(patterns)) {
throw new Error('specs or exclude property should be an array of strings, specs may also be an array of string arrays')
}
patterns = patterns.map(pattern => {
if (Array.isArray(pattern)) {
return pattern.map(subPattern => removeLineNumbers(subPattern))
}
return removeLineNumbers(pattern)
})
for (let pattern of patterns) {
if (Array.isArray(pattern) && !hierarchyDepth) {
groupedFiles = <string[]>ConfigParser.getFilePaths(pattern, rootDir, findAndGlob, 1)
files.push(groupedFiles)
} else if (Array.isArray(pattern) && hierarchyDepth) {
log.error('Unexpected depth of hierarchical arrays')
} else if ((pattern as string).startsWith('file://')) {
files.push(pattern)
} else {
pattern = pattern.toString().replace(/\\/g, '/')
let filenames = findAndGlob.glob(<string>pattern, rootDir)
filenames = filenames.filter(
(filename) => SUPPORTED_FILE_EXTENSIONS.find(
(ext) => filename.endsWith(ext)))
filenames = filenames.map(filename => findAndGlob.ensureAbsolutePath(filename, rootDir))
if (filenames.length === 0) {
log.warn('pattern', pattern, 'did not match any file')
}
files = [...files, ...new Set(filenames)]
}
}
return files
}
* returns specs files with the excludes filtered
*
* @param {String[] | String[][]} spec files - list of spec files
* @param {string[]} excludeList files - list of exclude files
* @return {String[] | String[][]} list of spec files with excludes removed
*/
filterSpecs(specs: Spec[], excludeList: string[]) {
if (allKeywordsContainPath(excludeList)) {
const filteredSpec = specs.reduce((returnVal: Spec[], currSpec) => {
if (Array.isArray(currSpec)) {
returnVal.push(currSpec.filter(specItem => !excludeList.includes(specItem)))
} else if (excludeList.indexOf(currSpec) === -1) {
returnVal.push(currSpec)
}
return returnVal
}, [])
return filterEmptyArrayItems(filteredSpec)
}
const filteredSpec = specs.reduce((returnVal: Spec[], currSpec) => {
if (Array.isArray(currSpec)) {
returnVal.push(currSpec.filter(specItem => !excludeList.some(excludeVal => specItem.includes(excludeVal))))
}
const isSpecExcluded = excludeList.some(excludedVal => currSpec.includes(excludedVal))
if (!isSpecExcluded) {
returnVal.push(currSpec)
}
return returnVal
}, [])
return filterEmptyArrayItems(filteredSpec)
}
shard(specs: Spec[]) {
if (!this._config.shard || this._config.shard.total === 1) {
return specs
}
const { total, current } = this._config.shard
const totalSpecs = specs.length
const specsPerShard = Math.max(Math.round(totalSpecs / total), 1)
const end = current === total ? undefined : specsPerShard * current
return specs.slice(current * specsPerShard - specsPerShard, end)
}
}
function allKeywordsContainPath(excludedSpecList: string[]) {
return excludedSpecList.every(val => val.includes('/') || val.includes('\\') || val.includes('*'))
}
function filterEmptyArrayItems(specList: Spec[]) {
return specList.filter(item=>(Array.isArray(item) && item.length) || !Array.isArray(item))
}
function filterDublicationArrayItems(specList: Spec[]) {
return [...new Set(specList.map(item=> Array.isArray(item) ? [...new Set(item)] : item))]
}
function isValidRegex(expression: string) {
try {
new RegExp(expression)
return true
} catch {
return false
}
}