import exitHook from 'async-exit-hook' import { createRequire } from 'node:module' import logger from '@wdio/logger' import { validateConfig } from '@wdio/config' import { ConfigParser } from '@wdio/config/node' import { initializePlugin, initializeLauncherService, sleep, enableFileLogging } from '@wdio/utils' import { setupDriver, setupBrowser } from '@wdio/utils/node' import type { Capabilities, Services } from '@wdio/types' import CLInterface from './interface.js' import { runLauncherHook, runOnCompleteHook, runServiceHook, nodeVersion, type HookError } from './utils.js' import { TESTRUNNER_DEFAULTS, WORKER_GROUPLOGS_MESSAGES } from './constants.js' import type { RunCommandArguments } from './types.js' const log = logger('@wdio/cli:launcher') const require = createRequire(import.meta.url) interface Schedule { cid: number caps: WebdriverIO.Capabilities specs: WorkerSpecs[] availableInstances: number runningInstances: number } interface WorkerSpecs { files: string[] retries: number rid?: string } export interface EndMessage { cid: string, // is actually rid exitCode: number, specs: string[], retries: number } const TS_FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] class Launcher { #isInitialized: boolean = false public configParser: ConfigParser public isMultiremote = false public isParallelMultiremote = false public runner?: Services.RunnerInstance public interface?: CLInterface private _exitCode = 0 private _hasTriggeredExitRoutine = false private _schedule: Schedule[] = [] private _rid: number[] = [] private _runnerStarted = 0 private _runnerFailed = 0 private _launcher?: Services.ServiceInstance[] private _resolve?: Function constructor( private _configFilePath: string, private _args: Partial = {}, private _isWatchMode = false ) { this.configParser = new ConfigParser(this._configFilePath, this._args) } /** * run sequence * @return {Promise} that only gets resolved with either an exitCode or an error */ async run(): Promise { await this.initialize() const config = 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 as { capabilities: WebdriverIO.Capabilities }).capabilities)) this.isMultiremote = this.isParallelMultiremote || !Array.isArray(capabilities) validateConfig(TESTRUNNER_DEFAULTS, { ...config, capabilities }) await enableFileLogging(config.outputDir) logger.setLogLevelsConfig(config.logLevels, config.logLevel) /** * For Parallel-Multiremote, only get the specs and excludes from the first object */ const totalWorkerCnt = Array.isArray(capabilities) ? capabilities .map((c) => { if (this.isParallelMultiremote) { const keys = Object.keys(c as Capabilities.RequestedMultiremoteCapabilities) const caps = (c as Capabilities.RequestedMultiremoteCapabilities)[keys[0]].capabilities as WebdriverIO.Capabilities return this.configParser.getSpecs(caps['wdio:specs'], caps['wdio:exclude']).length } const standaloneCaps = c as Capabilities.RequestedStandaloneCapabilities 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 CLInterface(config, totalWorkerCnt, this._isWatchMode) config.runnerEnv!.FORCE_COLOR = Number(this.interface.hasAnsiSupport).toString() const [runnerName, runnerOptions] = Array.isArray(config.runner) ? config.runner : [config.runner, {} as WebdriverIO.BrowserRunnerOptions] const Runner = (await initializePlugin(runnerName, 'runner') as Services.RunnerPlugin).default this.runner = new Runner(runnerOptions, config) /** * catches ctrl+c event */ exitHook(this._exitHandler.bind(this)) let exitCode = 0 let error: HookError | undefined = undefined const caps = this.configParser.getCapabilities() as Capabilities.TestrunnerCapabilities try { const { ignoredWorkerServices, launcherServices } = await initializeLauncherService(config, caps) this._launcher = launcherServices this._args.ignoredWorkerServices = ignoredWorkerServices /** * run pre test tasks for runner plugins * (e.g. deploy Lambda function to AWS) */ await this.runner.initialize() /** * run onPrepare hook */ log.info('Run onPrepare hook') await runLauncherHook(config.onPrepare, config, caps) await runServiceHook(this._launcher, 'onPrepare', config, caps) /** * pre-configure necessary driver for worker threads */ await Promise.all([ setupDriver(config, caps), setupBrowser(config, caps) ]) exitCode = await this._runMode(config, caps) await logger.waitForBuffer() this.interface.finalise() } catch (err) { error = err as HookError } finally { if (!this._hasTriggeredExitRoutine) { this._hasTriggeredExitRoutine = true const passesCodeCoverage = await this.runner.shutdown() if (!passesCodeCoverage) { exitCode = exitCode || 1 } } exitCode = await this.#runOnCompleteHook(config, caps, exitCode) } if (error) { this.interface.logHookError(error) throw error } return exitCode } /** * initialize launcher by loading `tsx` if needed */ async initialize () { /** * only initialize once */ if (this.#isInitialized) { return } /** * add tsx to process NODE_OPTIONS so it will be passed along the worker process */ const tsxPath = require.resolve('tsx') if (!process.env.NODE_OPTIONS || !process.env.NODE_OPTIONS.includes(tsxPath)) { /** * The `--import` flag is only available in Node 20.6.0 / 18.19.0 and later. * This switching can be removed once the minimum supported version of Node exceeds 20.6.0 / 18.19.0 * see https://nodejs.org/api/module.html#customization-hooks and https://tsx.is/dev-api/node-cli#module-mode-only */ 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}` } /** * load tsx in the main process if config file is a .ts file to allow config parser to load it */ if (TS_FILE_EXTENSIONS.some((ext) => this._configFilePath.endsWith(ext))) { await import(tsxPath) } this.#isInitialized = true /** * initialize config parser */ 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 ( config: Required, caps: Capabilities.TestrunnerCapabilities, exitCode: number ): Promise { log.info('Run onComplete hook') const onCompleteResults = await runOnCompleteHook(config.onComplete!, config, caps, exitCode, this.interface!.result) if (this._launcher) { await runServiceHook(this._launcher, 'onComplete', exitCode, config, caps) } // if any of the onComplete hooks failed, update the exit code return onCompleteResults.includes(1) ? 1 : exitCode } /** * run without triggering onPrepare/onComplete hooks */ private _runMode(config: Required, caps?: Capabilities.TestrunnerCapabilities): Promise { /** * fail if */ 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((resolve) => { log.error('Missing capabilities, exiting with failure') return resolve(1) }) } /** * avoid retries in watch mode */ const specFileRetries = this._isWatchMode ? 0 : config.specFileRetries /** * schedule test runs */ let cid = 0 if (this.isMultiremote && !this.isParallelMultiremote) { /** * Multiremote mode */ this._schedule.push({ cid: cid++, caps: caps as Capabilities.RequestedMultiremoteCapabilities, specs: this._formatSpecs(caps as Capabilities.RequestedMultiremoteCapabilities, specFileRetries), availableInstances: config.maxInstances || 1, runningInstances: 0 }) } else { /** * Regular mode & Parallel Multiremote */ for (const capabilities of caps as Capabilities.RequestedStandaloneCapabilities[]) { /** * when using browser runner we only allow one session per browser */ const availableInstances = this.isParallelMultiremote ? config.maxInstances || 1 : config.runner === 'browser' ? 1 : (capabilities as WebdriverIO.Capabilities)['wdio:maxInstances'] || config.maxInstancesPerCapability this._schedule.push({ cid: cid++, caps: capabilities as WebdriverIO.Capabilities, specs: this._formatSpecs(capabilities, specFileRetries), availableInstances, runningInstances: 0 }) } } return new Promise((resolve) => { this._resolve = resolve /** * fail if no specs were found or specified */ if (Object.values(this._schedule).reduce((specCnt, schedule) => specCnt + schedule.specs.length, 0) === 0) { const { total, current } = config.shard if (total > 1) { log.info(`No specs to execute in shard ${current}/${total}, exiting!`) return resolve(0) } log.error('No specs found to run, exiting with failure') return resolve(1) } /** * return immediately if no spec was run */ if (this._runSpecs()) { resolve(0) } }) } /** * Format the specs into an array of objects with files and retries */ private _formatSpecs(capabilities: (Capabilities.RequestedMultiremoteCapabilities | Capabilities.RequestedStandaloneCapabilities), specFileRetries: number) { let caps: WebdriverIO.Capabilities if ('alwaysMatch' in capabilities) { caps = capabilities.alwaysMatch as WebdriverIO.Capabilities } else if (typeof Object.keys(capabilities)[0] === 'object' && 'capabilities' in (capabilities as Capabilities.RequestedMultiremoteCapabilities)[Object.keys(capabilities)[0]]) { caps = {} as WebdriverIO.Capabilities } else { caps = capabilities as WebdriverIO.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: string | string[]) => { if (typeof file === 'string') { return { files: [file], retries: specFileRetries } } else if (Array.isArray(file)) { return { files: file, retries: specFileRetries } } log.warn('Unexpected entry in specs that is neither string nor array: ', file) // Returning an empty structure to avoid undefined return { files: [], retries: specFileRetries } }) } /** * run multiple single remote tests * @return {Boolean} true if all specs have been run and all instances have finished */ private _runSpecs(): boolean { /** * stop spawning new processes when CTRL+C was triggered */ if (this._hasTriggeredExitRoutine) { return true } const config = this.configParser.getConfig() while (this._getNumberOfRunningInstances() < config.maxInstances) { const schedulableCaps = this._schedule /** * bail if number of errors exceeds allowed */ .filter((session) => { const filter = typeof config.bail !== 'number' || config.bail < 1 || config.bail > this._runnerFailed /** * clear number of specs when filter is false */ if (!filter) { this._schedule.forEach((t) => { t.specs = [] }) return false } /** * make sure complete number of running instances is not higher than general maxInstances number */ if (this._getNumberOfRunningInstances() >= config.maxInstances) { return false } /** * make sure the capability has available capacities and still has caps to run */ return session.availableInstances > 0 && session.specs.length > 0 }) /** * make sure we are running caps with less running instances first */ .sort((a, b) => a.runningInstances - b.runningInstances) /** * continue if no capability were schedulable */ if (schedulableCaps.length === 0) { break } const specs = schedulableCaps[0].specs.shift() as NonNullable this._startInstance( specs.files, schedulableCaps[0].caps as Capabilities.ResolvedTestrunnerCapabilities, 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 */ private _getNumberOfRunningInstances(): number { 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 */ private _getNumberOfSpecsLeft(): number { 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 */ private async _startInstance( specs: string[], caps: Capabilities.ResolvedTestrunnerCapabilities, cid: number, rid: string | undefined, retries: number ) { if (!this.runner || !this.interface) { throw new Error('Internal Error: no runner initialized, call run() first') } const config = this.configParser.getConfig() // wait before retrying the spec file if (typeof config.specFileRetriesDelay === 'number' && config.specFileRetries > 0 && config.specFileRetries !== retries) { await sleep(config.specFileRetriesDelay * 1000) } // Retried tests receive the cid of the failing test as rid // so they can run with the same cid of the failing test. const runnerId = rid || this._getRunnerId(cid) const processNumber = this._runnerStarted + 1 // process.debugPort defaults to 5858 and is set even when process // is not being debugged. const debugArgs = [] let debugType let debugHost = '' const debugPort = process.debugPort for (const arg of process.execArgv) { const debugArgs = arg.match('--(debug|inspect)(?:-brk)?(?:=(.*):)?') if (debugArgs) { const [, type, host] = debugArgs if (type) { debugType = type } if (host) { debugHost = `${host}:` } } } if (debugType) { debugArgs.push(`--${debugType}=${debugHost}${(debugPort + processNumber)}`) } // if you would like to add --debug-brk, use a different port, etc... const capExecArgs = [...(config.execArgv || [])] // The default value for child.fork execArgs is process.execArgs, // so continue to use this unless another value is specified in config. const defaultArgs = (capExecArgs.length) ? process.execArgv : [] // If an arg appears multiple times the last occurrence is used const execArgv = [...defaultArgs, ...debugArgs, ...capExecArgs] // bump up worker count this._runnerStarted++ // Prepare to pass different capability objects for each worker const workerCaps = structuredClone(caps) // run worker hook to allow modify runtime and capabilities of a specific worker log.info('Run onWorkerStart hook') await runLauncherHook(config.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)) // prefer launcher settings in capabilities over general launcher 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: config.user, key: config.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)) } private _workerHookError(error: HookError) { 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 ...) */ private _getRunnerId(cid: number): string { 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 */ private async _endHandler({ cid: rid, exitCode, specs, retries }: EndMessage): Promise { const passed = this._isWatchModeHalted() || exitCode === 0 if (!passed && retries > 0) { // Default is true, so test for false explicitly 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 } /** * avoid emitting job:end if watch mode has been stopped by user */ if (!this._isWatchModeHalted() && this.interface) { this.interface.emit('job:end', { cid: rid, passed, retries }) } /** * Update schedule now this process has ended * get cid (capability id) from rid (runner id) */ const cid = parseInt(rid, 10) this._schedule[cid].availableInstances++ this._schedule[cid].runningInstances-- log.info('Run onWorkerEnd hook') const config = this.configParser.getConfig() await runLauncherHook(config.onWorkerEnd, rid, exitCode, specs, retries) .catch((error) => this._workerHookError(error)) await runServiceHook(this._launcher!, 'onWorkerEnd', rid, exitCode, specs, retries) .catch((error) => this._workerHookError(error)) /** * do nothing if * - there are specs to be executed * - we are running watch mode */ const shouldRunSpecs = this._runSpecs() const inWatchMode = this._isWatchMode && !this._hasTriggeredExitRoutine if (!shouldRunSpecs || inWatchMode) { /** * print reporter results when in watch mode */ 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 */ private _exitHandler(callback?: (value: boolean) => void): void | Promise { 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} */ private _isWatchModeHalted(): boolean { return this._isWatchMode && this._hasTriggeredExitRoutine } } export default Launcher