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, '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 = {}, 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') { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this._config.runner[1] as any).coverage = { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(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(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) => { // @ts-ignore Expression produces a union type that is too complex to represent const existingHooks = this._config[hookName] if (!existingHooks) { // @ts-ignore Expression produces a union type that is too complex to represent this._config[hookName] = hook.bind(service) } else if (typeof existingHooks === 'function') { // @ts-ignore Expression produces a union type that is too complex to represent this._config[hookName] = [existingHooks, hook.bind(service)] } else { // @ts-ignore Expression produces a union type that is too complex to represent 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 // when CLI --spec is explicitly specified, this._config.specs contains the filtered // specs matching the passed pattern else the specs defined inside the config are returned 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)] } // only use capability specs if (CLI) --spec is not defined if (!isSpecParamPassed && Array.isArray(capSpecs)) { specs = ConfigParser.getFilePaths(capSpecs, this._config.rootDir, this._pathService) } // handle case where user passes --suite via CLI 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!') } // Allow --suite and --spec to both be defined on the command line specs = isSpecParamPassed ? [...specs, ...suiteSpecs] : suiteSpecs } // Remove any duplicate tests from the final specs array specs = filterDublicationArrayItems(specs) // If the --repeat flag is set, duplicate the specs array N times // Ensure that when --repeat is used that either --spec or --suite is also used 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, 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() const fileList = ConfigParser.getFilePaths(specs, this._config.rootDir, this._pathService) cliArgFileList.forEach(filteredFile => { filteredFile = removeLineNumbers(filteredFile) // Send wildcard or single file glob to getFilePaths // Return value will always be string[] or string [][] const globMatchedFiles = 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 can be a string[] or a string[][] fileList.forEach(file => { if (typeof file === 'string') { // TODO: filteredFile is not a regex and thus this is a false positive if (isValidRegex(filteredFile) && file.match(filteredFile)) { filesToFilter.add(file) } } else if (Array.isArray(file)) { file.forEach(subFile => { // TODO: filteredFile is not a regex and thus this is a false positive 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 } /** * 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] } // patterns must be an array of strings and/or string arrays 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 pattern is an array, then call getFilePaths again // But only call one level deep, can't have multiple levels of hierarchy if (Array.isArray(pattern) && !hierarchyDepth) { // Will always only get a string array back groupedFiles = 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 are already absolute, no need to glob them files.push(pattern) } else { pattern = pattern.toString().replace(/\\/g, '/') let filenames = findAndGlob.glob(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 'exclude' is array of paths 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) } // If 'exclude' is array of keywords 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 } }