@wdio/config
Version:
A helper utility to parse and validate WebdriverIO options
595 lines (587 loc) • 20.7 kB
JavaScript
// 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
};