UNPKG

@wdio/cli

Version:
1,496 lines (1,480 loc) 56.8 kB
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 {