@wdio/cli
Version:
WebdriverIO testrunner command line interface
1,496 lines (1,480 loc) • 56.8 kB
JavaScript
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// src/index.ts
import "dotenv/config";
// src/launcher.ts
import exitHook from "async-exit-hook";
import { resolve } from "import-meta-resolve";
import logger3 from "@wdio/logger";
import { validateConfig } from "@wdio/config";
import { ConfigParser as ConfigParser2 } from "@wdio/config/node";
import { initializePlugin, initializeLauncherService, sleep, enableFileLogging } from "@wdio/utils";
import { setupDriver, setupBrowser } from "@wdio/utils/node";
// src/interface.ts
import { EventEmitter } from "node:events";
import chalk, { supportsColor } from "chalk";
import logger2 from "@wdio/logger";
import { SnapshotManager } from "@vitest/snapshot/manager";
// src/utils.ts
import { promisify } from "node:util";
import ejs from "ejs";
import pickBy from "lodash.pickby";
import logger from "@wdio/logger";
import { SevereServiceError } from "webdriverio";
import { ConfigParser } from "@wdio/config/node";
import { CAPABILITY_KEYS } from "@wdio/protocols";
// src/constants.ts
import module from "node:module";
import { HOOK_DEFINITION } from "@wdio/utils";
var require2 = module.createRequire(import.meta.url);
var pkgJSON = require2("../package.json");
var pkg = pkgJSON;
var CLI_EPILOGUE = `Documentation: https://webdriver.io
@wdio/cli (v${pkg.version})`;
var SUPPORTED_COMMANDS = ["run", "install", "config", "repl"];
var ANDROID_CONFIG = {
platformName: "Android",
automationName: "UiAutomator2",
deviceName: "Test"
};
var IOS_CONFIG = {
platformName: "iOS",
automationName: "XCUITest",
deviceName: "iPhone Simulator"
};
var SUPPORTED_SNAPSHOTSTATE_OPTIONS = ["all", "new", "none"];
var TESTRUNNER_DEFAULTS = {
/**
* Define specs for test execution. You can either specify a glob
* pattern to match multiple files at once or wrap a glob or set of
* paths into an array to run them within a single worker process.
*/
specs: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "specs" option needs to be a list of strings');
}
}
},
/**
* exclude specs from test execution
*/
exclude: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "exclude" option needs to be a list of strings');
}
}
},
/**
* key/value definition of suites (named by key) and a list of specs as value
* to specify a specific set of tests to execute
*/
suites: {
type: "object"
},
/**
* Project root directory path.
*/
rootDir: {
type: "string"
},
/**
* If you only want to run your tests until a specific amount of tests have failed use
* bail (default is 0 - don't bail, run all tests).
*/
bail: {
type: "number",
default: 0
},
/**
* supported test framework by wdio testrunner
*/
framework: {
type: "string"
},
/**
* capabilities of WebDriver sessions
*/
capabilities: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
if (typeof param === "object") {
return true;
}
throw new Error('the "capabilities" options needs to be an object or a list of objects');
}
for (const option of param) {
if (typeof option === "object") {
continue;
}
throw new Error("expected every item of a list of capabilities to be of type object");
}
return true;
},
required: true
},
/**
* list of reporters to use, a reporter can be either a string or an object with
* reporter options, e.g.:
* [
* 'dot',
* {
* name: 'spec',
* outputDir: __dirname + '/reports'
* }
* ]
*/
reporters: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "reporters" options needs to be a list of strings');
}
const isValidReporter = (option) => typeof option === "string" || typeof option === "function";
for (const option of param) {
if (isValidReporter(option)) {
continue;
}
if (Array.isArray(option) && typeof option[1] === "object" && isValidReporter(option[0])) {
continue;
}
throw new Error(
'a reporter should be either a string in the format "wdio-<reportername>-reporter" or a function/class. Please see the docs for more information on custom reporters (https://webdriver.io/docs/customreporter)'
);
}
return true;
}
},
/**
* set of WDIO services to use
*/
services: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "services" options needs to be a list of strings and/or arrays');
}
for (const option of param) {
if (!Array.isArray(option)) {
if (typeof option === "string") {
continue;
}
throw new Error('the "services" options needs to be a list of strings and/or arrays');
}
}
return true;
},
default: []
},
/**
* Node arguments to specify when launching child processes
*/
execArgv: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "execArgv" options needs to be a list of strings');
}
},
default: []
},
/**
* amount of instances to be allowed to run in total
*/
maxInstances: {
type: "number"
},
/**
* amount of instances to be allowed to run per capability
*/
maxInstancesPerCapability: {
type: "number"
},
/**
* whether or not testrunner should inject `browser`, `$` and `$$` as
* global environment variables
*/
injectGlobals: {
type: "boolean"
},
/**
* Set to true if you want to update your snapshots.
*/
updateSnapshots: {
type: "string",
default: SUPPORTED_SNAPSHOTSTATE_OPTIONS[1],
validate: (param) => {
if (param && !SUPPORTED_SNAPSHOTSTATE_OPTIONS.includes(param)) {
throw new Error(`the "updateSnapshots" options needs to be one of "${SUPPORTED_SNAPSHOTSTATE_OPTIONS.join('", "')}"`);
}
}
},
/**
* Overrides default snapshot path. For example, to store snapshots next to test files.
*/
resolveSnapshotPath: {
type: "function",
validate: (param) => {
if (param && typeof param !== "function") {
throw new Error('the "resolveSnapshotPath" options needs to be a function');
}
}
},
/**
* The number of times to retry the entire specfile when it fails as a whole
*/
specFileRetries: {
type: "number",
default: 0
},
/**
* Delay in seconds between the spec file retry attempts
*/
specFileRetriesDelay: {
type: "number",
default: 0
},
/**
* Whether or not retried spec files should be retried immediately or deferred to the end of the queue
*/
specFileRetriesDeferred: {
type: "boolean",
default: true
},
/**
* whether or not print the log output grouped by test files
*/
groupLogsByTestSpec: {
type: "boolean",
default: false
},
/**
* list of strings to watch of `wdio` command is called with `--watch` flag
*/
filesToWatch: {
type: "object",
validate: (param) => {
if (!Array.isArray(param)) {
throw new Error('the "filesToWatch" option needs to be a list of strings');
}
}
},
shard: {
type: "object",
validate: (param) => {
if (typeof param !== "object") {
throw new Error('the "shard" options needs to be an object');
}
const p = param;
if (typeof p.current !== "number" || typeof p.total !== "number") {
throw new Error('the "shard" option needs to have "current" and "total" properties with number values');
}
if (p.current < 0 || p.current > p.total) {
throw new Error('the "shard.current" value has to be between 0 and "shard.total"');
}
}
},
/**
* hooks
*/
onPrepare: HOOK_DEFINITION,
onWorkerStart: HOOK_DEFINITION,
onWorkerEnd: HOOK_DEFINITION,
before: HOOK_DEFINITION,
beforeSession: HOOK_DEFINITION,
beforeSuite: HOOK_DEFINITION,
beforeHook: HOOK_DEFINITION,
beforeTest: HOOK_DEFINITION,
afterTest: HOOK_DEFINITION,
afterHook: HOOK_DEFINITION,
afterSuite: HOOK_DEFINITION,
afterSession: HOOK_DEFINITION,
after: HOOK_DEFINITION,
onComplete: HOOK_DEFINITION,
onReload: HOOK_DEFINITION,
beforeAssertion: HOOK_DEFINITION,
afterAssertion: HOOK_DEFINITION
};
var WORKER_GROUPLOGS_MESSAGES = {
normalExit: (cid) => `
***** List of steps of WorkerID=[${cid}] *****`,
exitWithError: (cid) => `
***** List of steps of WorkerID=[${cid}] that preceded the error above *****`
};
// src/utils.ts
var log = logger("@wdio/cli:utils");
var renderFile = promisify(ejs.renderFile);
var HookError = class extends SevereServiceError {
origin;
constructor(message, origin) {
super(message);
this.origin = origin;
}
};
async function runServiceHook(launcher, hookName, ...args) {
const start = Date.now();
return Promise.all(launcher.map(async (service) => {
try {
if (typeof service[hookName] === "function") {
await service[hookName](...args);
}
} catch (err) {
const message = `A service failed in the '${hookName}' hook
${err.stack}
`;
if (err instanceof SevereServiceError || err.name === "SevereServiceError") {
return { status: "rejected", reason: message, origin: hookName };
}
log.error(`${message}Continue...`);
}
})).then((results) => {
if (launcher.length) {
log.debug(`Finished to run "${hookName}" hook in ${Date.now() - start}ms`);
}
const rejectedHooks = results.filter((p) => p && p.status === "rejected");
if (rejectedHooks.length) {
return Promise.reject(new HookError(`
${rejectedHooks.map((p) => p && p.reason).join()}
Stopping runner...`, hookName));
}
});
}
async function runLauncherHook(hook, ...args) {
if (typeof hook === "function") {
hook = [hook];
}
const catchFn = (e) => {
log.error(`Error in hook: ${e.stack}`);
if (e instanceof SevereServiceError) {
throw new HookError(e.message, hook[0].name);
}
};
return Promise.all(hook.map((hook2) => {
try {
return hook2(...args);
} catch (err) {
return catchFn(err);
}
})).catch(catchFn);
}
async function runOnCompleteHook(onCompleteHook, config3, capabilities, exitCode, results) {
if (typeof onCompleteHook === "function") {
onCompleteHook = [onCompleteHook];
}
return Promise.all(onCompleteHook.map(async (hook) => {
try {
await hook(exitCode, config3, capabilities, results);
return 0;
} catch (err) {
log.error(`Error in onCompleteHook: ${err.stack}`);
if (err instanceof SevereServiceError) {
throw new HookError(err.message, "onComplete");
}
return 1;
}
}));
}
function getRunnerName(caps = {}) {
let runner = caps.browserName || caps.platformName || caps["appium:platformName"] || caps["appium:appPackage"] || caps["appium:appWaitActivity"] || caps["appium:app"];
if (!runner) {
runner = Object.values(caps).length === 0 || Object.values(caps).some((cap) => !cap.capabilities) ? "undefined" : "MultiRemote";
}
return runner;
}
async function getCapabilities(arg) {
const optionalCapabilites = {
platformVersion: arg.platformVersion,
udid: arg.udid,
...arg.deviceName && { deviceName: arg.deviceName }
};
if (/.*\.(apk|app|ipa)$/.test(arg.option)) {
return {
capabilities: {
app: arg.option,
...arg.option.endsWith("apk") ? ANDROID_CONFIG : IOS_CONFIG,
...optionalCapabilites
}
};
} else if (/android/.test(arg.option)) {
return { capabilities: { browserName: "Chrome", ...ANDROID_CONFIG, ...optionalCapabilites } };
} else if (/ios/.test(arg.option)) {
return { capabilities: { browserName: "Safari", ...IOS_CONFIG, ...optionalCapabilites } };
} else if (/(js|ts)$/.test(arg.option)) {
const config3 = new ConfigParser(arg.option);
try {
await config3.initialize();
} catch (e) {
throw Error(e.code === "MODULE_NOT_FOUND" ? `Config File not found: ${arg.option}` : `Could not parse ${arg.option}, failed with error: ${e.message}`);
}
if (typeof arg.capabilities === "undefined") {
throw Error("Please provide index/named property of capability to use from the capabilities array/object in wdio config file");
}
let requiredCaps = config3.getCapabilities();
requiredCaps = // multi capabilities
requiredCaps[parseInt(arg.capabilities, 10)] || // multiremote
requiredCaps[arg.capabilities];
const requiredW3CCaps = pickBy(requiredCaps, (_, key) => CAPABILITY_KEYS.includes(key) || key.includes(":"));
if (!Object.keys(requiredW3CCaps).length) {
throw Error(`No capability found in given config file with the provided capability indexed/named property: ${arg.capabilities}. Please check the capability in your wdio config file.`);
}
return { capabilities: { ...requiredW3CCaps } };
}
return { capabilities: { browserName: arg.option } };
}
var cucumberTypes = {
paths: "array",
backtrace: "boolean",
dryRun: "boolean",
forceExit: "boolean",
failFast: "boolean",
format: "array",
formatOptions: "object",
import: "array",
language: "string",
name: "array",
order: "string",
publish: "boolean",
requireModule: "array",
retry: "number",
retryTagFilter: "string",
strict: "boolean",
tags: "string",
worldParameters: "object",
timeout: "number",
scenarioLevelReporter: "boolean",
tagsInTitle: "boolean",
ignoreUndefinedDefinitions: "boolean",
failAmbiguousDefinitions: "boolean",
tagExpression: "string",
profiles: "array",
file: "string"
};
var mochaTypes = {
require: "array",
compilers: "array",
allowUncaught: "boolean",
asyncOnly: "boolean",
bail: "boolean",
checkLeaks: "boolean",
delay: "boolean",
fgrep: "string",
forbidOnly: "boolean",
forbidPending: "boolean",
fullTrace: "boolean",
global: "array",
grep: "string",
invert: "boolean",
retries: "number",
timeout: "number",
ui: "string"
};
var jasmineTypes = {
defaultTimeoutInterval: "number",
helpers: "array",
requires: "array",
random: "boolean",
seed: "string",
failFast: "boolean",
failSpecWithNoExpectations: "boolean",
oneFailurePerSpec: "boolean",
grep: "string",
invertGrep: "boolean",
cleanStack: "boolean",
stopOnSpecFailure: "boolean",
stopSpecOnExpectationFailure: "boolean",
requireModule: "array"
};
function coerceOpts(types, opts) {
for (const key in opts) {
if (types[key] === "boolean" && typeof opts[key] === "string") {
opts[key] = opts[key] === "true";
} else if (types[key] === "number") {
opts[key] = Number(opts[key]);
} else if (types[key] === "array") {
opts[key] = Array.isArray(opts[key]) ? opts[key] : [opts[key]];
} else if (types[key] === "object" && typeof opts[key] === "string") {
opts[key] = JSON.parse(opts[key]);
}
}
return opts;
}
function coerceOptsFor(framework) {
if (framework === "cucumber") {
return coerceOpts.bind(null, cucumberTypes);
} else if (framework === "mocha") {
return coerceOpts.bind(null, mochaTypes);
} else if (framework === "jasmine") {
return coerceOpts.bind(null, jasmineTypes);
}
throw new Error(`Unsupported framework "${framework}"`);
}
var NodeVersion = /* @__PURE__ */ ((NodeVersion2) => {
NodeVersion2[NodeVersion2["major"] = 0] = "major";
NodeVersion2[NodeVersion2["minor"] = 1] = "minor";
NodeVersion2[NodeVersion2["patch"] = 2] = "patch";
return NodeVersion2;
})(NodeVersion || {});
function nodeVersion(type) {
return process.versions.node.split(".").map(Number)[NodeVersion[type]];
}
// src/interface.ts
var log2 = logger2("@wdio/cli");
var EVENT_FILTER = ["sessionStarted", "sessionEnded", "finishedCommand", "ready", "workerResponse", "workerEvent"];
var WDIOCLInterface = class extends EventEmitter {
constructor(_config, totalWorkerCnt, _isWatchMode = false) {
super();
this._config = _config;
this.totalWorkerCnt = totalWorkerCnt;
this._isWatchMode = _isWatchMode;
this.hasAnsiSupport = supportsColor && supportsColor.hasBasic;
this.totalWorkerCnt = totalWorkerCnt;
this._isWatchMode = _isWatchMode;
this._specFileRetries = _config.specFileRetries || 0;
this._specFileRetriesDelay = _config.specFileRetriesDelay || 0;
this.on("job:start", this.addJob.bind(this));
this.on("job:end", this.clearJob.bind(this));
this.setup();
this.onStart();
}
#snapshotManager = new SnapshotManager({
updateSnapshot: "new"
// ignored in this context
});
hasAnsiSupport;
result = {
finished: 0,
passed: 0,
retries: 0,
failed: 0
};
_jobs = /* @__PURE__ */ new Map();
_specFileRetries;
_specFileRetriesDelay;
_skippedSpecs = 0;
_inDebugMode = false;
_start = /* @__PURE__ */ new Date();
_messages = {
reporter: {},
debugger: {}
};
#hasShard() {
return this._config.shard && this._config.shard.total !== 1;
}
setup() {
this._jobs = /* @__PURE__ */ new Map();
this._start = /* @__PURE__ */ new Date();
this.result = {
finished: 0,
passed: 0,
retries: 0,
failed: 0
};
this._messages = {
reporter: {},
debugger: {}
};
}
onStart() {
const shardNote = this.#hasShard() ? ` (Shard ${this._config.shard.current} of ${this._config.shard.total})` : "";
this.log(chalk.bold(`
Execution of ${chalk.blue(this.totalWorkerCnt)} workers${shardNote} started at`), this._start.toISOString());
if (this._inDebugMode) {
this.log(chalk.bgYellow(chalk.black("DEBUG mode enabled!")));
}
if (this._isWatchMode) {
this.log(chalk.bgYellow(chalk.black("WATCH mode enabled!")));
}
this.log("");
}
onSpecRunning(rid) {
this.onJobComplete(rid, this._jobs.get(rid), 0, chalk.bold(chalk.cyan("RUNNING")));
}
onSpecRetry(rid, job, retries = 0) {
const delayMsg = this._specFileRetriesDelay > 0 ? ` after ${this._specFileRetriesDelay}s` : "";
this.onJobComplete(rid, job, retries, chalk.bold(chalk.yellow("RETRYING") + delayMsg));
}
onSpecPass(rid, job, retries = 0) {
this.onJobComplete(rid, job, retries, chalk.bold(chalk.green("PASSED")));
}
onSpecFailure(rid, job, retries = 0) {
this.onJobComplete(rid, job, retries, chalk.bold(chalk.red("FAILED")));
}
onSpecSkip(rid, job) {
this.onJobComplete(rid, job, 0, "SKIPPED", log2.info);
}
onJobComplete(cid, job, retries = 0, message = "", _logger = this.log) {
const details = [`[${cid}]`, message];
if (job) {
details.push("in", getRunnerName(job.caps), this.getFilenames(job.specs));
}
if (retries > 0) {
details.push(`(${retries} retries)`);
}
return _logger(...details);
}
onTestError(payload) {
const error = {
type: payload.error?.type || "Error",
message: payload.error?.message || (typeof payload.error === "string" ? payload.error : "Unknown error."),
stack: payload.error?.stack
};
return this.log(`[${payload.cid}]`, `${chalk.red(error.type)} in "${payload.fullTitle}"
${chalk.red(error.stack || error.message)}`);
}
getFilenames(specs = []) {
if (specs.length > 0) {
return "- " + specs.join(", ").replace(new RegExp(`${process.cwd()}`, "g"), "");
}
return "";
}
/**
* add job to interface
*/
addJob({ cid, caps, specs, hasTests }) {
this._jobs.set(cid, { caps, specs, hasTests });
if (hasTests) {
this.onSpecRunning(cid);
} else {
this._skippedSpecs++;
}
}
/**
* clear job from interface
*/
clearJob({ cid, passed, retries }) {
const job = this._jobs.get(cid);
this._jobs.delete(cid);
const retryAttempts = this._specFileRetries - retries;
const retry = !passed && retries > 0;
if (!retry) {
this.result.finished++;
}
if (job && job.hasTests === false) {
return this.onSpecSkip(cid, job);
}
if (passed) {
this.result.passed++;
this.onSpecPass(cid, job, retryAttempts);
} else if (retry) {
this.totalWorkerCnt++;
this.result.retries++;
this.onSpecRetry(cid, job, retryAttempts);
} else {
this.result.failed++;
this.onSpecFailure(cid, job, retryAttempts);
}
}
/**
* for testing purposes call console log in a static method
*/
log(...args) {
console.log(...args);
return args;
}
logHookError(error) {
if (error instanceof HookError) {
return this.log(`${chalk.red(error.name)} in "${error.origin}"
${chalk.red(error.stack || error.message)}`);
}
return this.log(`${chalk.red(error.name)}: ${chalk.red(error.stack || error.message)}`);
}
/**
* event handler that is triggered when runner sends up events
*/
onMessage(event) {
if (event.name === "reporterRealTime") {
this.log(event.content);
return;
}
if (event.origin === "debugger" && event.name === "start") {
this.log(chalk.yellow(event.params.introMessage));
this._inDebugMode = true;
return this._inDebugMode;
}
if (event.origin === "debugger" && event.name === "stop") {
this._inDebugMode = false;
return this._inDebugMode;
}
if (event.name === "testFrameworkInit") {
return this.emit("job:start", event.content);
}
if (event.name === "snapshot") {
const snapshotResults = event.content;
return snapshotResults.forEach((snapshotResult) => {
this.#snapshotManager.add(snapshotResult);
});
}
if (event.name === "error") {
return this.log(
`[${event.cid}]`,
chalk.white(chalk.bgRed(chalk.bold(" Error: "))),
event.content ? event.content.message || event.content.stack || event.content : ""
);
}
if (event.origin !== "reporter" && event.origin !== "debugger") {
if (EVENT_FILTER.includes(event.name)) {
return;
}
return this.log(event.cid, event.origin, event.name, event.content);
}
if (event.name === "printFailureMessage") {
return this.onTestError(event.content);
}
if (!this._messages[event.origin][event.name]) {
this._messages[event.origin][event.name] = [];
}
this._messages[event.origin][event.name].push(event.content);
}
sigintTrigger() {
if (this._inDebugMode) {
return false;
}
const isRunning = this._jobs.size !== 0 || this._isWatchMode;
const shutdownMessage = isRunning ? "Ending WebDriver sessions gracefully ...\n(press ctrl+c again to hard kill the runner)" : "Ended WebDriver sessions gracefully after a SIGINT signal was received!";
return this.log("\n\n" + shutdownMessage);
}
printReporters() {
const reporter = this._messages.reporter;
this._messages.reporter = {};
for (const [reporterName, messages] of Object.entries(reporter)) {
this.log("\n", chalk.bold(chalk.magenta(`"${reporterName}" Reporter:`)));
this.log(messages.join(""));
}
}
printSummary() {
const totalJobs = this.totalWorkerCnt - this.result.retries;
const elapsed = new Date(Date.now() - this._start.getTime()).toUTCString().match(/(\d\d:\d\d:\d\d)/)[0];
const retries = this.result.retries ? chalk.yellow(this.result.retries, "retries") + ", " : "";
const failed = this.result.failed ? chalk.red(this.result.failed, "failed") + ", " : "";
const skipped = this._skippedSpecs > 0 ? chalk.gray(this._skippedSpecs, "skipped") + ", " : "";
const percentCompleted = totalJobs ? Math.round(this.result.finished / totalJobs * 100) : 0;
const snapshotSummary = this.#snapshotManager.summary;
const snapshotNotes = [];
if (snapshotSummary.added > 0) {
snapshotNotes.push(chalk.green(`${snapshotSummary.added} snapshot(s) added.`));
}
if (snapshotSummary.updated > 0) {
snapshotNotes.push(chalk.yellow(`${snapshotSummary.updated} snapshot(s) updated.`));
}
if (snapshotSummary.unmatched > 0) {
snapshotNotes.push(chalk.red(`${snapshotSummary.unmatched} snapshot(s) unmatched.`));
}
if (snapshotSummary.unchecked > 0) {
snapshotNotes.push(chalk.gray(`${snapshotSummary.unchecked} snapshot(s) unchecked.`));
}
if (snapshotNotes.length > 0) {
this.log("\nSnapshot Summary:");
snapshotNotes.forEach((note) => this.log(note));
}
return this.log(
"\nSpec Files: ",
chalk.green(this.result.passed, "passed") + ", " + retries + failed + skipped + totalJobs,
"total",
`(${percentCompleted}% completed)`,
"in",
elapsed,
this.#hasShard() ? `
Shard: ${this._config.shard.current} / ${this._config.shard.total}` : "",
"\n"
);
}
finalise() {
this.printReporters();
this.printSummary();
}
};
// src/launcher.ts
var log3 = logger3("@wdio/cli:launcher");
var TS_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
var Launcher = class {
constructor(_configFilePath, _args = {}, _isWatchMode = false) {
this._configFilePath = _configFilePath;
this._args = _args;
this._isWatchMode = _isWatchMode;
this.configParser = new ConfigParser2(this._configFilePath, this._args);
}
#isInitialized = false;
configParser;
isMultiremote = false;
isParallelMultiremote = false;
runner;
interface;
_exitCode = 0;
_hasTriggeredExitRoutine = false;
_schedule = [];
_rid = [];
_runnerStarted = 0;
_runnerFailed = 0;
_launcher;
_resolve;
/**
* run sequence
* @return {Promise} that only gets resolved with either an exitCode or an error
*/
async run() {
await this.initialize();
const config3 = this.configParser.getConfig();
const capabilities = this.configParser.getCapabilities();
this.isParallelMultiremote = Array.isArray(capabilities) && capabilities.every((cap) => Object.values(cap).length > 0 && Object.values(cap).every((c) => typeof c === "object" && c.capabilities));
this.isMultiremote = this.isParallelMultiremote || !Array.isArray(capabilities);
validateConfig(TESTRUNNER_DEFAULTS, { ...config3, capabilities });
await enableFileLogging(config3.outputDir);
logger3.setLogLevelsConfig(config3.logLevels, config3.logLevel);
const totalWorkerCnt = Array.isArray(capabilities) ? capabilities.map((c) => {
if (this.isParallelMultiremote) {
const keys = Object.keys(c);
const caps2 = c[keys[0]].capabilities;
return this.configParser.getSpecs(caps2["wdio:specs"], caps2["wdio:exclude"]).length;
}
const standaloneCaps = c;
const cap = "alwaysMatch" in standaloneCaps ? standaloneCaps.alwaysMatch : standaloneCaps;
return this.configParser.getSpecs(cap["wdio:specs"], cap["wdio:exclude"]).length;
}).reduce((a, b) => a + b, 0) : 1;
this.interface = new WDIOCLInterface(config3, totalWorkerCnt, this._isWatchMode);
config3.runnerEnv.FORCE_COLOR = Number(this.interface.hasAnsiSupport).toString();
const [runnerName, runnerOptions] = Array.isArray(config3.runner) ? config3.runner : [config3.runner, {}];
const Runner = (await initializePlugin(runnerName, "runner")).default;
this.runner = new Runner(runnerOptions, config3);
exitHook(this._exitHandler.bind(this));
let exitCode = 0;
let error = void 0;
const caps = this.configParser.getCapabilities();
try {
const { ignoredWorkerServices, launcherServices } = await initializeLauncherService(config3, caps);
this._launcher = launcherServices;
this._args.ignoredWorkerServices = ignoredWorkerServices;
await this.runner.initialize();
log3.info("Run onPrepare hook");
await runLauncherHook(config3.onPrepare, config3, caps);
await runServiceHook(this._launcher, "onPrepare", config3, caps);
await Promise.all([
setupDriver(config3, caps),
setupBrowser(config3, caps)
]);
exitCode = await this._runMode(config3, caps);
await logger3.waitForBuffer();
this.interface.finalise();
} catch (err) {
error = err;
} finally {
if (!this._hasTriggeredExitRoutine) {
this._hasTriggeredExitRoutine = true;
const passesCodeCoverage = await this.runner.shutdown();
if (!passesCodeCoverage) {
exitCode = exitCode || 1;
}
}
exitCode = await this.#runOnCompleteHook(config3, caps, exitCode);
}
if (error) {
this.interface.logHookError(error);
throw error;
}
return exitCode;
}
/**
* initialize launcher by loading `tsx` if needed
*/
async initialize() {
if (this.#isInitialized) {
return;
}
const tsxPath = resolve("tsx", import.meta.url);
if (!process.env.NODE_OPTIONS || !process.env.NODE_OPTIONS.includes(tsxPath)) {
const moduleLoaderFlag = nodeVersion("major") >= 21 || nodeVersion("major") === 20 && nodeVersion("minor") >= 6 || nodeVersion("major") === 18 && nodeVersion("minor") >= 19 ? "--import" : "--loader";
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS || ""} ${moduleLoaderFlag} ${tsxPath}`;
}
if (TS_FILE_EXTENSIONS.some((ext) => this._configFilePath.endsWith(ext))) {
await import(tsxPath);
}
this.#isInitialized = true;
await this.configParser.initialize(this._args);
}
/**
* run onComplete hook
* Even if it fails we still want to see result and end logger stream.
* Also ensure that user hooks are run before service hooks so that e.g.
* a user can use plugin service, e.g. shared store service is still
* available running hooks in this order
*/
async #runOnCompleteHook(config3, caps, exitCode) {
log3.info("Run onComplete hook");
const onCompleteResults = await runOnCompleteHook(config3.onComplete, config3, caps, exitCode, this.interface.result);
if (this._launcher) {
await runServiceHook(this._launcher, "onComplete", exitCode, config3, caps);
}
return onCompleteResults.includes(1) ? 1 : exitCode;
}
/**
* run without triggering onPrepare/onComplete hooks
*/
_runMode(config3, caps) {
if (
/**
* no caps were provided
*/
!caps || /**
* capability array is empty
*/
Array.isArray(caps) && caps.length === 0 || /**
* user wants to use multiremote but capability object is empty
*/
!Array.isArray(caps) && Object.keys(caps).length === 0
) {
return new Promise((resolve2) => {
log3.error("Missing capabilities, exiting with failure");
return resolve2(1);
});
}
const specFileRetries = this._isWatchMode ? 0 : config3.specFileRetries;
let cid = 0;
if (this.isMultiremote && !this.isParallelMultiremote) {
this._schedule.push({
cid: cid++,
caps,
specs: this._formatSpecs(caps, specFileRetries),
availableInstances: config3.maxInstances || 1,
runningInstances: 0
});
} else {
for (const capabilities of caps) {
const availableInstances = this.isParallelMultiremote ? config3.maxInstances || 1 : config3.runner === "browser" ? 1 : capabilities["wdio:maxInstances"] || config3.maxInstancesPerCapability;
this._schedule.push({
cid: cid++,
caps: capabilities,
specs: this._formatSpecs(capabilities, specFileRetries),
availableInstances,
runningInstances: 0
});
}
}
return new Promise((resolve2) => {
this._resolve = resolve2;
if (Object.values(this._schedule).reduce((specCnt, schedule) => specCnt + schedule.specs.length, 0) === 0) {
const { total, current } = config3.shard;
if (total > 1) {
log3.info(`No specs to execute in shard ${current}/${total}, exiting!`);
return resolve2(0);
}
log3.error("No specs found to run, exiting with failure");
return resolve2(1);
}
if (this._runSpecs()) {
resolve2(0);
}
});
}
/**
* Format the specs into an array of objects with files and retries
*/
_formatSpecs(capabilities, specFileRetries) {
let caps;
if ("alwaysMatch" in capabilities) {
caps = capabilities.alwaysMatch;
} else if (typeof Object.keys(capabilities)[0] === "object" && "capabilities" in capabilities[Object.keys(capabilities)[0]]) {
caps = {};
} else {
caps = capabilities;
}
const specs = (
// @ts-expect-error deprecated
caps.specs || caps["wdio:specs"]
);
const excludes = (
// @ts-expect-error deprecated
caps.exclude || caps["wdio:exclude"]
);
const files = this.configParser.getSpecs(specs, excludes);
return files.map((file) => {
if (typeof file === "string") {
return { files: [file], retries: specFileRetries };
} else if (Array.isArray(file)) {
return { files: file, retries: specFileRetries };
}
log3.warn("Unexpected entry in specs that is neither string nor array: ", file);
return { files: [], retries: specFileRetries };
});
}
/**
* run multiple single remote tests
* @return {Boolean} true if all specs have been run and all instances have finished
*/
_runSpecs() {
if (this._hasTriggeredExitRoutine) {
return true;
}
const config3 = this.configParser.getConfig();
while (this._getNumberOfRunningInstances() < config3.maxInstances) {
const schedulableCaps = this._schedule.filter((session) => {
const filter = typeof config3.bail !== "number" || config3.bail < 1 || config3.bail > this._runnerFailed;
if (!filter) {
this._schedule.forEach((t) => {
t.specs = [];
});
return false;
}
if (this._getNumberOfRunningInstances() >= config3.maxInstances) {
return false;
}
return session.availableInstances > 0 && session.specs.length > 0;
}).sort((a, b) => a.runningInstances - b.runningInstances);
if (schedulableCaps.length === 0) {
break;
}
const specs = schedulableCaps[0].specs.shift();
this._startInstance(
specs.files,
schedulableCaps[0].caps,
schedulableCaps[0].cid,
specs.rid,
specs.retries
);
schedulableCaps[0].availableInstances--;
schedulableCaps[0].runningInstances++;
}
return this._getNumberOfRunningInstances() === 0 && this._getNumberOfSpecsLeft() === 0;
}
/**
* gets number of all running instances
* @return {number} number of running instances
*/
_getNumberOfRunningInstances() {
return this._schedule.map((a) => a.runningInstances).reduce((a, b) => a + b);
}
/**
* get number of total specs left to complete whole suites
* @return {number} specs left to complete suite
*/
_getNumberOfSpecsLeft() {
return this._schedule.map((a) => a.specs.length).reduce((a, b) => a + b);
}
/**
* Start instance in a child process.
* @param {Array} specs Specs to run
* @param {number} cid Capabilities ID
* @param {string} rid Runner ID override
* @param {number} retries Number of retries remaining
*/
async _startInstance(specs, caps, cid, rid, retries) {
if (!this.runner || !this.interface) {
throw new Error("Internal Error: no runner initialized, call run() first");
}
const config3 = this.configParser.getConfig();
if (typeof config3.specFileRetriesDelay === "number" && config3.specFileRetries > 0 && config3.specFileRetries !== retries) {
await sleep(config3.specFileRetriesDelay * 1e3);
}
const runnerId = rid || this._getRunnerId(cid);
const processNumber = this._runnerStarted + 1;
const debugArgs = [];
let debugType;
let debugHost = "";
const debugPort = process.debugPort;
for (const arg of process.execArgv) {
const debugArgs2 = arg.match("--(debug|inspect)(?:-brk)?(?:=(.*):)?");
if (debugArgs2) {
const [, type, host] = debugArgs2;
if (type) {
debugType = type;
}
if (host) {
debugHost = `${host}:`;
}
}
}
if (debugType) {
debugArgs.push(`--${debugType}=${debugHost}${debugPort + processNumber}`);
}
const capExecArgs = [...config3.execArgv || []];
const defaultArgs = capExecArgs.length ? process.execArgv : [];
const execArgv = [...defaultArgs, ...debugArgs, ...capExecArgs];
this._runnerStarted++;
const workerCaps = structuredClone(caps);
log3.info("Run onWorkerStart hook");
await runLauncherHook(config3.onWorkerStart, runnerId, workerCaps, specs, this._args, execArgv).catch((error) => this._workerHookError(error));
await runServiceHook(this._launcher, "onWorkerStart", runnerId, workerCaps, specs, this._args, execArgv).catch((error) => this._workerHookError(error));
const worker = await this.runner.run({
cid: runnerId,
command: "run",
configFile: this._configFilePath,
args: {
...this._args,
/**
* Pass on user and key values to ensure they are available in the worker process when using
* environment variables that were locally exported but not part of the environment.
*/
user: config3.user,
key: config3.key
},
caps: workerCaps,
specs,
execArgv,
retries
});
worker.on("message", this.interface.onMessage.bind(this.interface));
worker.on("error", this.interface.onMessage.bind(this.interface));
worker.on("exit", (code) => {
if (!this.configParser.getConfig().groupLogsByTestSpec) {
return;
}
if (code.exitCode === 0) {
console.log(WORKER_GROUPLOGS_MESSAGES.normalExit(code.cid));
} else {
console.log(WORKER_GROUPLOGS_MESSAGES.exitWithError(code.cid));
}
worker.logsAggregator.forEach((logLine) => {
console.log(logLine.replace(new RegExp("\\n$"), ""));
});
});
worker.on("exit", this._endHandler.bind(this));
}
_workerHookError(error) {
if (!this.interface) {
throw new Error("Internal Error: no interface initialized, call run() first");
}
this.interface.logHookError(error);
if (this._resolve) {
this._resolve(1);
}
}
/**
* generates a runner id
* @param {number} cid capability id (unique identifier for a capability)
* @return {String} runner id (combination of cid and test id e.g. 0a, 0b, 1a, 1b ...)
*/
_getRunnerId(cid) {
if (!this._rid[cid]) {
this._rid[cid] = 0;
}
return `${cid}-${this._rid[cid]++}`;
}
/**
* Close test runner process once all child processes have exited
* @param {number} cid Capabilities ID
* @param {number} exitCode exit code of child process
* @param {Array} specs Specs that were run
* @param {number} retries Number or retries remaining
*/
async _endHandler({ cid: rid, exitCode, specs, retries }) {
const passed = this._isWatchModeHalted() || exitCode === 0;
if (!passed && retries > 0) {
const requeue = this.configParser.getConfig().specFileRetriesDeferred ? "push" : "unshift";
this._schedule[parseInt(rid, 10)].specs[requeue]({ files: specs, retries: retries - 1, rid });
} else {
this._exitCode = this._isWatchModeHalted() ? 0 : this._exitCode || exitCode;
this._runnerFailed += !passed ? 1 : 0;
}
if (!this._isWatchModeHalted() && this.interface) {
this.interface.emit("job:end", { cid: rid, passed, retries });
}
const cid = parseInt(rid, 10);
this._schedule[cid].availableInstances++;
this._schedule[cid].runningInstances--;
log3.info("Run onWorkerEnd hook");
const config3 = this.configParser.getConfig();
await runLauncherHook(config3.onWorkerEnd, rid, exitCode, specs, retries).catch((error) => this._workerHookError(error));
await runServiceHook(this._launcher, "onWorkerEnd", rid, exitCode, specs, retries).catch((error) => this._workerHookError(error));
const shouldRunSpecs = this._runSpecs();
const inWatchMode = this._isWatchMode && !this._hasTriggeredExitRoutine;
if (!shouldRunSpecs || inWatchMode) {
if (inWatchMode) {
this.interface?.finalise();
}
return;
}
if (this._resolve) {
this._resolve(passed ? this._exitCode : 1);
}
}
/**
* We need exitHandler to catch SIGINT / SIGTERM events.
* Make sure all started selenium sessions get closed properly and prevent
* having dead driver processes. To do so let the runner end its Selenium
* session first before killing
*/
_exitHandler(callback) {
if (!callback || !this.runner || !this.interface) {
return;
}
if (this._hasTriggeredExitRoutine) {
return callback(true);
}
this._hasTriggeredExitRoutine = true;
this.interface.sigintTrigger();
return this.runner.shutdown().then(callback);
}
/**
* returns true if user stopped watch mode, ex with ctrl+c
* @returns {boolean}
*/
_isWatchModeHalted() {
return this._isWatchMode && this._hasTriggeredExitRoutine;
}
};
var launcher_default = Launcher;
// src/run.ts
import path3 from "node:path";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
// src/commands/index.ts
import { config as config2 } from "create-wdio/config/cli";
import { install } from "create-wdio/install/cli";
// src/commands/repl.ts
var repl_exports = {};
__export(repl_exports, {
builder: () => builder2,
cmdArgs: () => cmdArgs2,
command: () => command2,
desc: () => desc2,
handler: () => handler2
});
import pickBy3 from "lodash.pickby";
import { remote } from "webdriverio";
// src/commands/run.ts
var run_exports = {};
__export(run_exports, {
builder: () => builder,
cmdArgs: () => cmdArgs,
command: () => command,
desc: () => desc,
handler: () => handler,
launch: () => launch,
launchWithStdin: () => launchWithStdin
});
import fs from "node:fs/promises";
import path2 from "node:path";
// src/watcher.ts
import url from "node:url";
import path from "node:path";
import chokidar from "chokidar";
import pickBy2 from "lodash.pickby";
import flattenDeep from "lodash.flattendeep";
import union from "lodash.union";
import logger4 from "@wdio/logger";
import { FileSystemPathService } from "@wdio/config/node";
var log4 = logger4("@wdio/cli:watch");
var Watcher = class {
constructor(_configFile, _args) {
this._configFile = _configFile;
this._args = _args;
log4.info("Starting launcher in watch mode");
this._launcher = new launcher_default(this._configFile, this._args, true);
}
_launcher;
_specs = [];
async watch() {
await this._launcher.initialize();
const specs = this._launcher.configParser.getSpecs();
const capSpecs = this._launcher.isMultiremote ? [] : union(flattenDeep(
this._launcher.configParser.getCapabilities().map((cap) => "alwaysMatch" in cap ? cap.alwaysMatch["wdio:specs"] : cap["wdio:specs"] || [])
));
this._specs = [...specs, ...capSpecs];
const flattenedSpecs = flattenDeep(this._specs).map((fileUrl) => url.fileURLToPath(fileUrl));
chokidar.watch(flattenedSpecs, { ignoreInitial: true }).on("add", this.getFileListener()).on("change", this.getFileListener());
const { filesToWatch } = this._launcher.configParser.getConfig();
if (filesToWatch.length) {
const pathService = new FileSystemPathService();
const rootDir = path.dirname(path.resolve(process.cwd(), this._configFile));
const globbedFilesToWatch = filesToWatch.map((file) => pathService.ensureAbsolutePath(file, rootDir));
chokidar.watch(globbedFilesToWatch, { ignoreInitial: true }).on("add", this.getFileListener(false)).on("change", this.getFileListener(false));
}
await this._launcher.run();
const workers = this.getWorkers();
Object.values(workers).forEach((worker) => worker.on("exit", () => {
if (Object.values(workers).find((w) => w.isBusy)) {
return;
}
this._launcher.interface?.finalise();
}));
}
/**
* return file listener callback that calls `run` method
* @param {Boolean} [passOnFile=true] if true pass on file change as parameter
* @return {Function} chokidar event callback
*/
getFileListener(passOnFile = true) {
return (spec) => {
const runSpecs = [];
let singleSpecFound = false;
for (let index = 0, length = this._specs.length; index < length; index += 1) {
const value = this._specs[index];
if (Array.isArray(value) && value.indexOf(spec) > -1) {
runSpecs.push(value);
} else if (!singleSpecFound && spec === value) {
singleSpecFound = true;
runSpecs.push(value);
}
}
if (runSpecs.length === 0) {
runSpecs.push(url.pathToFileURL(spec).href);
}
const { spec: _, ...args } = this._args;
return runSpecs.map((spec2) => {
return this.run({
...args,
...passOnFile ? { spec: [spec2] } : {}
});
});
};
}
/**
* helper method to get workers from worker pool of wdio runner
* @param predicate filter by property value (see lodash.pickBy)
* @param includeBusyWorker don't filter out busy worker (default: false)
* @return Object with workers, e.g. {'0-0': { ... }}
*/
getWorkers(predicate, includeBusyWorker) {
if (!this._launcher.runner) {
throw new Error("Internal Error: no runner initialized, call run() first");
}
let workers = this._launcher.runner.workerPool;
if (typeof predicate === "function") {
workers = pickBy2(workers, predicate);
}
if (!includeBusyWorker) {
workers = pickBy2(workers, (worker) => !worker.isBusy);
}
return workers;
}
/**
* run workers with params
* @param params parameters to run the worker with
*/
run(params = {}) {
const workers = this.getWorkers(
params.spec ? (worker) => Boolean(worker.specs.find((s) => params.spec?.includes(s))) : void 0
);
if (Object.keys(workers).length === 0 || !this._launcher.interface) {
return;
}
this._launcher.interface.totalWorkerCnt = Object.entries(workers).length;
this.cleanUp();
for (const [, worker] of Object.entries(workers)) {
const { cid, capabilities, specs, sessionId } = worker;
const { hostname, path: path4, port, protocol, automationProtocol } = worker.config;
const args = Object.assign({ sessionId, baseUrl: worker.config.baseUrl, hostname, path: path4, port, protocol, automationProtocol }, params);
worker.postMessage("run", args);
this._launcher.interface.emit("job:start", { cid, caps: capabilities, specs });
}
}
cleanUp() {
this._launcher.interface?.setup();
}
};
// src/commands/run.ts
import { config } from "create-wdio/config/cli";
var command = "run <configPath>";
var desc = "Run your WDIO configuration file to initialize your tests. (default)";
var cmdArgs = {
watch: {
desc: "Run WebdriverIO in watch mode",
type: "boolean"
},
hostname: {
alias: "h",
desc: "automation driver host address",
type: "string"
},
port: {
alias: "p",
desc: "automation driver port",
type: "number"
},
path: {
type: "string",
desc: 'path to WebDriver endpoints (default "/")'
},
user: {
alias: "u",
desc: "username if using a cloud service as automation backend",
type: "string"
},
key: {
alias: "k",
desc: "corresponding access key to the user",
type: "string"
},
logLevel: {
alias: "l",
desc: "level of logging verbosity",
choices: ["trace", "debug", "info", "warn", "error", "silent"]
},
bail: {
desc: "stop test runner after specific amount of tests have failed",
type: "number"
},
baseUrl: {
desc: "shorten url command calls by setting a base url",
type: "string"
},
waitforTimeout: {
alias: "w",
desc: "timeout for all waitForXXX commands",
type: "number"
},
updateSnapshots: {
alias: "s",
desc: "update DOM, image or test snapshots",
type: "string",
coerce: (value) => {
if (value === "") {
return "all";
}
return value;
}
},
framework: {
alias: "f",
desc: "defines the framework (Mocha, Jasmine or Cucumber) to run the specs",
type: "string"
},
reporters: {
alias: "r",
desc: "reporters to print out the results on stdout",
type: "array"
},
suite: {
desc: "overwrites the specs attribute and runs the defined suite",
type: "array"
},
spec: {
desc: "run only a certain spec file or wildcard - overrides specs piped from stdin",
type: "array"
},
exclude: {
desc: "exclude certain spec file or wildcard from the test run - overrides exclude piped from stdin",
type: "array"
},
"repeat": {
desc: "Repeat specific specs and/or suites N times",
type: "number"
},
mochaOpts: {
desc: "Mocha options",
coerce: coerceOptsFor("mocha")
},
jasmineOpts: {
desc: "Jasmine options",
coerce: coerceOptsFor("jasmine")
},
cucumberOpts: {
desc: "Cucumber options",
coerce: coerceOptsFor("cucumber")
},
coverage: {
desc: "Enable coverage for browser runner"
},
shard: {
desc: "Shard tests and execute only the selected shard. Specify in the one-based form like `--shard x/y`, where x is the current and y the total shard.",
coerce: (shard) => {
const [current, total] = shard.split("/").map(Number);
if (Number.isNaN(current) || Number.isNaN(total)) {
throw new Error("Shard parameter must be in the form `x/y`, where x and y are positive integers.");
}
return {