UNPKG

@wdio/config

Version:
468 lines (467 loc) 21.6 kB
import path from 'node:path'; import logger from '@wdio/logger'; import { deepmerge, deepmergeCustom } from 'deepmerge-ts'; import RequireLibrary from './RequireLibrary.js'; import FileSystemPathService from './FileSystemPathService.js'; import { makeRelativeToCWD, loadAutoCompilers } 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'; const log = logger('@wdio/config:ConfigParser'); const MERGE_DUPLICATION = ['services', 'reporters']; export default class ConfigParser { _initialConfig; _pathService; _moduleRequireService; #isInitialised = false; #configFilePath; _config; _capabilities = []; constructor(configFilePath, /** * config options parsed in via CLI arguments and applied before * trying to compile config file */ _initialConfig = {}, _pathService = new FileSystemPathService(), _moduleRequireService = new RequireLibrary()) { this._initialConfig = _initialConfig; this._pathService = _pathService; this._moduleRequireService = _moduleRequireService; 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); } /** * the autoCompileOpts.autoCompile CLI argument has to be converted * from type string to type boolean */ if (typeof _initialConfig.autoCompileOpts?.autoCompile === 'string') { const strValue = _initialConfig.autoCompileOpts.autoCompile; _initialConfig.autoCompileOpts.autoCompile = !!strValue && strValue !== 'false'; } this.merge(_initialConfig, false); } /** * initializes the config object */ async initialize(object = {}) { /** * 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 loadAutoCompilers(this._config.autoCompileOpts, this._moduleRequireService); 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].coverage = { ...this._config.runner[1].coverage, enabled: this._initialConfig.coverage }; } } this.#isInitialised = true; } /** * merges config file with default values * @param {string} filename path of file relative to current directory */ async addConfigFile(filename) { 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.config || importedModule.default?.config; if (typeof config !== 'object') { throw new Error(NO_NAMED_CONFIG_EXPORT); } /** * clone the original config */ const fileConfig = Object.assign({}, config); /** * merge capabilities */ const defaultTo = Array.isArray(this._capabilities) ? [] : {}; 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) { log.error(`Failed loading configuration file: ${filePath}:`, e.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 */ merge(object = {}, 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; if (meta && MERGE_DUPLICATION.includes(key)) { const origWithoutObjectEntries = oldValue.filter((value) => typeof value !== 'object'); return Array.from(new Set(deepmerge(newValue, origWithoutObjectEntries))); } return utils.actions.defaultMerge; } }); this._config = customDeepMerge(this._config, object); /** * 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']; } else if (object.specs && object.specs.length > 0) { this._config.specs = object.specs; } if (object['wdio:exclude'] && object['wdio:exclude'].length > 0) { this._config.exclude = object['wdio:exclude']; } else if (object.exclude && object.exclude.length > 0) { this._config.exclude = object.exclude; } /** * 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) ? 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); } /** * 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) { const addHook = (hookName, hook) => { // @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, capExclude) { const isSpecParamPassed = Array.isArray(this._config.spec) && this._config.spec.length > 0; const multiRun = this._config.multiRun; // 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 : []; // only use capability excludes if (CLI) --exclude or config exclude are not defined if (Array.isArray(capExclude) && exclude.length === 0) { 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 = []; 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 = [...new Set(specs)]; // If the --multi-run flag is set, duplicate the specs array N times // Ensure that when --multi-run is used that either --spec or --suite is also used const hasSubsetOfSpecsDefined = isSpecParamPassed || suites.length > 0; if (multiRun && hasSubsetOfSpecsDefined) { specs = Array.from({ length: multiRun }, () => specs).flat(); } else if (multiRun && !hasSubsetOfSpecsDefined) { throw new Error('The --multi-run 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, specs) { const filesToFilter = new Set(); const fileList = ConfigParser.getFilePaths(specs, this._config.rootDir, this._pathService); cliArgFileList.forEach(filteredFile => { filteredFile = removeLineNumbers(filteredFile); // Send single file/file glob to getFilePaths - not supporting hierarchy in spec/exclude // Return value will always be string[] const globMatchedFiles = ConfigParser.getFilePaths(this._pathService.glob(filteredFile, path.dirname(this.#configFilePath)), 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') { if (file.match(filteredFile)) { filesToFilter.add(file); } } else if (Array.isArray(file)) { file.forEach(subFile => { if (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; } /** * return capabilities */ getCapabilities(i) { 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, rootDir, findAndGlob = new FileSystemPathService(), hierarchyDepth) { let files = []; let groupedFiles = []; 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.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, excludeList) { // If 'exclude' is array of paths if (allKeywordsContainPath(excludeList)) { return specs.reduce((returnVal, currSpec) => { if (Array.isArray(currSpec)) { returnVal.push(currSpec.filter(specItem => !excludeList.includes(specItem))); } else if (excludeList.indexOf(currSpec) === -1) { returnVal.push(currSpec); } return returnVal; }, []); } // If 'exclude' is array of keywords return specs.reduce((returnVal, 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; }, []); } shard(specs) { 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) { return excludedSpecList.every(val => val.includes('/') || val.includes('\\')); }