UNPKG

@wdio/config

Version:
595 lines (587 loc) 20.7 kB
// src/node/ConfigParser.ts import path3 from "node:path"; import logger from "@wdio/logger"; import { deepmerge, deepmergeCustom } from "deepmerge-ts"; // src/node/FileSystemPathService.ts import fs from "node:fs"; import url from "node:url"; import path from "node:path"; import { sync as globSync } from "glob"; // src/node/RequireLibrary.ts var RequireLibrary = class { import(module) { return import(module); } }; // src/node/FileSystemPathService.ts function lowercaseWinDriveLetter(p) { return p.replace(/^[A-Za-z]:\\/, (match) => match.toLowerCase()); } var FileSystemPathService = class { #moduleRequireService = new RequireLibrary(); loadFile(path4) { if (!path4) { throw new Error("A path is required"); } return this.#moduleRequireService.import(path4); } isFile(filepath) { return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile(); } /** * find test files based on a glob pattern * @param pattern file pattern to glob * @param rootDir directory of wdio config file * @returns files matching the glob pattern */ glob(pattern, rootDir) { const globResult = globSync(pattern, { cwd: rootDir, matchBase: true }) || []; const fileName = pattern.startsWith(path.sep) ? pattern : path.resolve(rootDir, pattern); if (!pattern.includes("*") && !globResult.includes(pattern) && !globResult.map(lowercaseWinDriveLetter).includes(lowercaseWinDriveLetter(fileName)) && fs.existsSync(fileName)) { globResult.push(fileName); } return globResult.sort(); } ensureAbsolutePath(filepath, rootDir) { if (filepath.startsWith("file://")) { return filepath; } const p = path.isAbsolute(filepath) ? path.normalize(filepath) : path.resolve(rootDir, filepath); return url.pathToFileURL(p).href; } }; // src/node/utils.ts import url2 from "node:url"; import path2 from "node:path"; function makeRelativeToCWD(files = []) { const returnFiles = []; for (const file of files) { if (Array.isArray(file)) { returnFiles.push(makeRelativeToCWD(file)); continue; } returnFiles.push( file.startsWith("file:///") ? url2.fileURLToPath(file) : file.includes("/") && !file.includes("*") ? path2.resolve(process.cwd(), file) : file ); } return returnFiles; } // src/constants.ts var DEFAULT_TIMEOUT = 1e4; var DEFAULT_CONFIGS = () => ({ specs: [], suites: {}, exclude: [], capabilities: [], outputDir: void 0, logLevel: "info", logLevels: {}, groupLogsByTestSpec: false, excludeDriverLogs: [], bail: 0, waitforInterval: 100, waitforTimeout: 5e3, framework: "mocha", reporters: [], services: [], maxInstances: 100, maxInstancesPerCapability: 100, injectGlobals: true, filesToWatch: [], connectionRetryTimeout: 12e4, connectionRetryCount: 3, execArgv: [], runnerEnv: {}, runner: "local", shard: { current: 1, total: 1 }, specFileRetries: 0, specFileRetriesDelay: 0, specFileRetriesDeferred: false, reporterSyncInterval: 100, reporterSyncTimeout: 5e3, cucumberFeaturesWithLineNumbers: [], /** * framework defaults */ mochaOpts: { timeout: DEFAULT_TIMEOUT }, jasmineOpts: { defaultTimeoutInterval: DEFAULT_TIMEOUT }, cucumberOpts: { timeout: DEFAULT_TIMEOUT }, /** * hooks */ onPrepare: [], onWorkerStart: [], onWorkerEnd: [], before: [], beforeSession: [], beforeSuite: [], beforeHook: [], beforeTest: [], beforeCommand: [], afterCommand: [], afterTest: [], afterHook: [], afterSuite: [], afterSession: [], after: [], onComplete: [], onReload: [], beforeAssertion: [], afterAssertion: [], /** * cucumber specific hooks */ beforeFeature: [], beforeScenario: [], beforeStep: [], afterStep: [], afterScenario: [], afterFeature: [] }); var SUPPORTED_HOOKS = [ "before", "beforeSession", "beforeSuite", "beforeHook", "beforeTest", "beforeCommand", "afterCommand", "afterTest", "afterHook", "afterSuite", "afterSession", "after", "beforeAssertion", "afterAssertion", // @ts-ignore not defined in core hooks but added with cucumber "beforeFeature", "beforeScenario", "beforeStep", "afterStep", "afterScenario", "afterFeature", "onReload", "onPrepare", "onWorkerStart", "onWorkerEnd", "onComplete" ]; var SUPPORTED_FILE_EXTENSIONS = [ ".js", ".jsx", ".mjs", ".mts", ".es6", ".ts", ".tsx", ".feature", ".coffee", ".cjs" ]; var NO_NAMED_CONFIG_EXPORT = 'No named export object called "config" found. Make sure you export the config object via `export.config = { ... }` when using CommonJS or `export const config = { ... }` when using ESM. Read more on this on https://webdriver.io/docs/configurationfile !'; // src/utils.ts var validObjectOrArray = (object) => Array.isArray(object) && object.length > 0 || typeof object === "object" && Object.keys(object).length > 0; function removeLineNumbers(filePath) { const matcher = filePath.match(/:\d+(:\d+$|$)/); if (matcher) { filePath = filePath.substring(0, matcher.index); } return filePath; } function isCucumberFeatureWithLineNumber(spec) { const specs = Array.isArray(spec) ? spec : [spec]; return specs.some((s) => /:\d+(:\d+$|$)/.test(s)); } // src/node/ConfigParser.ts var log = logger("@wdio/config:ConfigParser"); var MERGE_DUPLICATION = ["services", "reporters", "capabilities"]; var ConfigParser = class _ConfigParser { constructor(configFilePath, _initialConfig = {}, _pathService = new FileSystemPathService()) { this._initialConfig = _initialConfig; this._pathService = _pathService; this.#configFilePath = configFilePath; this._config = Object.assign( { rootDir: path3.dirname(configFilePath) }, DEFAULT_CONFIGS() ); if (_initialConfig.spec) { _initialConfig.spec = makeRelativeToCWD(_initialConfig.spec); } this.merge(_initialConfig, false); } #isInitialised = false; #configFilePath; _config; _capabilities = []; /** * initializes the config object */ async initialize(object = {}) { if (!this.#isInitialised) { await this.addConfigFile(this.#configFilePath); } this.merge({ ...object }); 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 = { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...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"); } const filePath = this._pathService.ensureAbsolutePath(filename, process.cwd()); try { 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); } const configFileCapabilities = config.capabilities; if (!configFileCapabilities) { throw new Error(`No \`capabilities\` property found in WebdriverIO.Config defined in file: ${filePath}`); } const fileConfig = Object.assign({}, config); const defaultTo = Array.isArray(this._capabilities) ? [] : {}; this._capabilities = deepmerge(this._capabilities, fileConfig.capabilities || defaultTo); delete fileConfig.capabilities; this.addService(fileConfig); for (const hookName of SUPPORTED_HOOKS) { delete fileConfig[hookName]; } this._config = deepmerge(this._config, fileConfig); 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 : []; 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); 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; } if (object.suite && object.suite.length > 0) { this._config.suite = this._config.suite?.filter((suite, idx, suites) => suites.indexOf(suite) === idx); } this._capabilities = validObjectOrArray(this._config.capabilities) ? this._config.capabilities : this._capabilities; if (this._config.spec && isCucumberFeatureWithLineNumber(this._config.spec)) { this._config.cucumberFeaturesWithLineNumbers = Array.isArray(this._config.spec) ? [...new Set(this._config.spec)] : [this._config.spec]; } if (addPathToSpecs && spec.length > 0) { this._config.specs = this.setFilePathToFilterOptions(spec, this._config.specs, object.group); } 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) => { 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, capExclude) { 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.length === 0) { 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 = []; 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, 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, group) { const filesToFilter = /* @__PURE__ */ new Set(); const fileList = _ConfigParser.getFilePaths(specs, this._config.rootDir, this._pathService); cliArgFileList.forEach((filteredFile) => { filteredFile = removeLineNumbers(filteredFile); const globMatchedFiles = _ConfigParser.getFilePaths( group ? [[filteredFile]] : [filteredFile], this._config.rootDir, this._pathService ); if (this._pathService.isFile(filteredFile)) { filesToFilter.add( this._pathService.ensureAbsolutePath( filteredFile, path3.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; } /** * 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]; } 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 = _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.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 (allKeywordsContainPath(excludeList)) { const filteredSpec2 = 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; }, []); return filterEmptyArrayItems(filteredSpec2); } const filteredSpec = 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; }, []); return filterEmptyArrayItems(filteredSpec); } 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 ? void 0 : specsPerShard * current; return specs.slice(current * specsPerShard - specsPerShard, end); } }; function allKeywordsContainPath(excludedSpecList) { return excludedSpecList.every((val) => val.includes("/") || val.includes("\\") || val.includes("*")); } function filterEmptyArrayItems(specList) { return specList.filter((item) => Array.isArray(item) && item.length || !Array.isArray(item)); } function filterDublicationArrayItems(specList) { return [...new Set(specList.map((item) => Array.isArray(item) ? [...new Set(item)] : item))]; } function isValidRegex(expression) { try { new RegExp(expression); return true; } catch { return false; } } export { ConfigParser, FileSystemPathService };