/* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from 'node:events' import logger from '@wdio/logger' import { initializeWorkerService, initializePlugin, executeHooksWithArgs } from '@wdio/utils' import { ConfigParser } from '@wdio/config/node' import { _setGlobal } from '@wdio/globals' import { expect, setOptions, getConfig, matchers, SnapshotService, SoftAssertionService } from 'expect-webdriverio' import { attach } from 'webdriverio' import type { Selector } from 'webdriverio' import type { Options, Capabilities } from '@wdio/types' import BrowserFramework from './browser.js' import BaseReporter from './reporter.js' import { initializeInstance, getInstancesData } from './utils.js' import type { BeforeArgs, AfterArgs, BeforeSessionArgs, AfterSessionArgs, RunParams, TestFramework, SessionStartedMessage, SessionEndedMessage, SnapshotResultMessage } from './types.js' const log = logger('@wdio/runner') export default class Runner extends EventEmitter { private _browser?: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser private _configParser?: ConfigParser private _sigintWasCalled = false private _isMultiremote = false private _specFileRetryAttempts = 0 private _reporter?: BaseReporter private _framework?: TestFramework private _config?: WebdriverIO.Config private _cid?: string private _specs?: string[] private _caps?: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities private _sessionInitError?: Error /** * run test suite * @param {string} cid worker id (e.g. `0-0`) * @param {Object} args config arguments passed into worker process * @param {string[]} specs list of spec files to run * @param {Object} caps capabilities to run session with * @param {string} configFile path to config file to get config from * @param {number} retries number of retries remaining * @return {Promise} resolves in number of failures for testrun */ async run({ cid, args, specs, caps, configFile, retries }: RunParams) { this._configParser = new ConfigParser(configFile, args) this._cid = cid this._specs = specs /** * add config file */ try { await this._configParser.initialize(args) } catch (err: any) { log.error(`Failed to read config file: ${err.stack}`) return this._shutdown(1, retries, true) } this._config = this._configParser.getConfig() this._specFileRetryAttempts = (this._config.specFileRetries || 0) - (retries || 0) logger.setLogLevelsConfig(this._config.logLevels, this._config.logLevel) if (this._config.maskingPatterns) { logger.setMaskingPatterns(this._config.maskingPatterns) } const capabilities = this._configParser.getCapabilities() const isMultiremote = this._isMultiremote = !Array.isArray(capabilities) || (Object.values(caps).length > 0 && Object.values(caps).every(c => typeof c === 'object' && c.capabilities)) /** * add built-in services */ const softAssertionService = new SoftAssertionService({ autoAssertOnTestEnd: this._config.autoAssertOnTestEnd || true }, this._caps, this._config) const snapshotService = SnapshotService.initiate({ updateState: this._config.updateSnapshots, resolveSnapshotPath: this._config.resolveSnapshotPath }) this._configParser.addService(softAssertionService) this._configParser.addService(snapshotService) this._caps = this._isMultiremote /** * Filter driver instances based on 'wdio:exclude' capability and allow * user to exclude them if not needed for given spec file */ ? Object.entries(caps).reduce((filteredCaps, [browserName, browserCaps]) => { const ex = browserCaps.capabilities['wdio:exclude'] if (ex) { const sp = this._configParser?.getSpecs(specs, ex) if (sp && sp.length === 0) { return filteredCaps } } filteredCaps[browserName] = browserCaps return filteredCaps }, {} as Capabilities.RequestedMultiremoteCapabilities) : caps /** * create `browser` stub only if `specFiltering` feature is enabled */ let browser = await this._startSession({ ...this._config, // @ts-ignore used in `/packages/webdriverio/src/protocol-stub.ts` _automationProtocol: this._config.automationProtocol, automationProtocol: './protocol-stub.js' }, this._caps) /** * run `beforeSession` command before framework and browser are initiated */ ;(await initializeWorkerService( this._config as WebdriverIO.Config, this._caps as WebdriverIO.Capabilities, args.ignoredWorkerServices )).map(this._configParser.addService.bind(this._configParser)) const beforeSessionParams: BeforeSessionArgs = [this._config, this._caps, this._specs, this._cid] await executeHooksWithArgs('beforeSession', this._config.beforeSession, beforeSessionParams) this._reporter = new BaseReporter(this._config, this._cid, { ...this._caps }) await this._reporter.initReporters() /** * initialize framework */ this._framework = await this.#initFramework(cid, this._config, this._caps, this._reporter, specs) process.send!({ name: 'testFrameworkInit', content: { cid, caps: this._caps, specs, hasTests: this._framework.hasTests() } }) if (!this._framework.hasTests()) { return this._shutdown(0, retries, true) } browser = await this._initSession(this._config, this._caps) /** * return if session initialization failed */ if (!browser) { const afterArgs: AfterArgs = [1, this._caps, this._specs] await executeHooksWithArgs('after', this._config.after as Function, afterArgs) return this._shutdown(1, retries, true) } this._reporter.caps = browser.capabilities const beforeArgs: BeforeArgs = [this._caps, this._specs, browser] await executeHooksWithArgs('before', this._config.before, beforeArgs) /** * kill session of SIGINT signal showed up while trying to * get a session ID */ if (this._sigintWasCalled) { log.info('SIGINT signal detected while starting session, shutting down...') await this.endSession() return this._shutdown(0, retries, true) } /** * initialization successful, send start message */ const multiRemoteBrowser = browser as unknown as WebdriverIO.MultiRemoteBrowser this._reporter.emit('runner:start', { cid, specs, config: browser.options, isMultiremote, instanceOptions: isMultiremote ? multiRemoteBrowser.instances.reduce((prev: any, browserName: string) => { prev[multiRemoteBrowser.getInstance(browserName).sessionId] = multiRemoteBrowser.getInstance(browserName).options as Options.WebdriverIO return prev }, {} as Record) : { [browser.sessionId]: browser.options }, sessionId: browser.sessionId, capabilities: isMultiremote ? multiRemoteBrowser.instances.reduce((caps: any, browserName: string) => { caps[browserName] = multiRemoteBrowser.getInstance(browserName).capabilities caps[browserName].sessionId = multiRemoteBrowser.getInstance(browserName).sessionId return caps }, {} as Capabilities.RequestedMultiremoteCapabilities) : { ...browser.capabilities, sessionId: browser.sessionId }, retry: this._specFileRetryAttempts } as Options.RunnerStart) /** * report sessionId and target connection information to worker */ const { protocol, hostname, port, path, queryParams, automationProtocol, headers } = browser.options const { isW3C, sessionId } = browser const instances = getInstancesData(browser, isMultiremote) process.send!({ origin: 'worker', name: 'sessionStarted', content: { automationProtocol, sessionId, isW3C, protocol, hostname, port, path, queryParams, isMultiremote, instances, capabilities: browser.capabilities, injectGlobals: this._config.injectGlobals, headers } }) /** * kick off tests in framework */ let failures = 0 try { failures = await this._framework.run() } catch (err: any) { log.error(err) this.emit('error', err) failures = 1 } /** * in watch mode we don't close the session and leave current page opened */ if (!args.watch) { await this.endSession() } /** * send snapshot result upstream */ process.send!({ origin: 'worker', name: 'snapshot', content: snapshotService.results }) return this._shutdown(failures, retries) } async #initFramework ( cid: string, config: WebdriverIO.Config, capabilities: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities, reporter: BaseReporter, specs: string[] ): Promise { const runner = Array.isArray(config.runner) ? config.runner[0] : config.runner /** * initialize framework adapter when running remote browser tests */ if (runner === 'local') { const framework = (await initializePlugin(config.framework as string, 'framework')).default as unknown as TestFramework const frameworkInstance = await framework.init(cid, config, specs, capabilities, reporter) if (frameworkInstance.setupExpect) { await frameworkInstance.setupExpect(expect, matchers, getConfig) } return frameworkInstance } /** * for embedded browser tests the `@wdio/browser-runner` already has the environment * setup so we can just run through the tests */ if (runner === 'browser') { return BrowserFramework.init(cid, config, specs, capabilities, reporter) } throw new Error(`Unknown runner "${runner}"`) } /** * init protocol session * @param {object} config configuration of sessions * @param {Object} caps desired capabilities of session * @param {Object} browserStub stubbed `browser` object with only capabilities, config and env flags * @return {Promise} resolves with browser object or null if session couldn't get established */ private async _initSession ( config: WebdriverIO.Config, caps: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities ) { const browser = await this._startSession(config, caps) as WebdriverIO.Browser // return null if session couldn't get established if (!browser) { return } /** * register global helper method to fetch elements */ _setGlobal('$', (selector: Selector) => browser.$(selector), config.injectGlobals) _setGlobal('$$', (selector: Selector) => browser.$$(selector), config.injectGlobals) /** * register command event */ browser.on('command', (command: any) => this._reporter?.emit( 'client:beforeCommand', Object.assign(command, { sessionId: browser.sessionId }) )) /** * register result event */ browser.on('result', (result: any) => this._reporter?.emit( 'client:afterCommand', Object.assign(result, { sessionId: browser.sessionId }) )) return browser } /** * start protocol session * @param {object} config configuration of sessions * @param {Object} caps desired capabilities of session * @return {Promise} resolves with browser object or null if session couldn't get established */ private async _startSession ( config: WebdriverIO.Config, caps: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities ) { try { /** * get all custom or overwritten commands users tried to register before the * test started, e.g. after all imports */ const customStubCommands: [string, (...args: any[]) => any, boolean][] = (this._browser as any | undefined)?.customCommands || [] const overwrittenCommands: [any, (...args: any[]) => any, boolean][] = (this._browser as any | undefined)?.overwrittenCommands || [] this._browser = await initializeInstance(config, caps, this._isMultiremote) _setGlobal('browser', this._browser, config.injectGlobals) _setGlobal('driver', this._browser, config.injectGlobals) /** * for Jasmine we extend the Jasmine matchers instead of injecting the assertion * library ourselves */ if (config.framework !== 'jasmine') { _setGlobal('expect', expect, config.injectGlobals) } /** * re-assign previously registered custom commands to the actual instance */ for (const params of customStubCommands) { this._browser.addCommand(...params) } for (const params of overwrittenCommands) { this._browser.overwriteCommand(...params) } /** * import and set options for `expect-webdriverio` assertion lib once * the browser was initiated */ setOptions({ wait: config.waitforTimeout, // ms to wait for expectation to succeed interval: config.waitforInterval, // interval between attempts beforeAssertion: async (params) => { await Promise.all([ this._reporter?.emit('client:beforeAssertion', { ...params, sessionId: (this._browser as WebdriverIO.Browser)?.sessionId }), executeHooksWithArgs('beforeAssertion', config.beforeAssertion, [params]) ]) }, afterAssertion: async (params) => { await Promise.all([ this._reporter?.emit('client:afterAssertion', { ...params, sessionId: (this._browser as WebdriverIO.Browser)?.sessionId }), executeHooksWithArgs('afterAssertion', config.afterAssertion, [params]) ]) } }) /** * attach browser to `multiremotebrowser` so user have better typing support */ if (this._isMultiremote) { _setGlobal('multiremotebrowser', this._browser, config.injectGlobals) } } catch (error: any) { log.error(error) this._sessionInitError = error return } return this._browser } /** * kill worker session */ private async _shutdown ( failures: number, retries: number, initiationFailed = false ) { /** * In case of initialization failed, the sessionId is undefined and the runner:start is not triggered. * So, to be able to perform the runner:end into the reporters, we need to launch the runner:start just before the runner:end. */ if (this._reporter && initiationFailed) { this._reporter.emit('runner:start', { cid: this._cid, specs: this._specs, config: this._config, isMultiremote: this._isMultiremote, instanceOptions: {}, capabilities: this._caps, retry: this._specFileRetryAttempts }) } this._reporter!.emit('runner:end', { failures, cid: this._cid, retries, ...(this._sessionInitError instanceof Error ? { error: this._sessionInitError.message } : {}) } as Options.RunnerEnd) try { await this._reporter!.waitForSync() } catch (err: any) { log.error(err) } this.emit('exit', failures === 0 ? 0 : 1) return failures } /** * end WebDriver session, a config object can be applied if object has changed * within a hook by the user */ async endSession(payload?: any) { /** * make sure instance(s) exist and have `sessionId` */ const multiremoteBrowser = this._browser as WebdriverIO.MultiRemoteBrowser const browser = this._browser as WebdriverIO.Browser const hasSessionId = Boolean(this._browser) && (this._isMultiremote /** * every multiremote instance should exist and should have `sessionId` */ ? !multiremoteBrowser.instances.some((browserName: string) => ( multiremoteBrowser.getInstance(browserName) && !multiremoteBrowser.getInstance(browserName).sessionId) ) /** * browser object should have `sessionId` in regular mode */ : browser.sessionId ) /** * in watch mode we create a new child process to kill the * session, see packages/wdio-local-runner/src/index.ts, * therefore we need to attach to the session to kill it */ if (!hasSessionId && payload?.args.config.sessionId) { this._browser = await attach({ ...payload.args.config, capabilities: payload?.args.capabilities }) } else if (!hasSessionId) { /** * don't do anything if test framework returns after SIGINT * if endSession is called without payload we expect a session id */ return } /** * store updated capabilities for afterSession hook */ const capabilities = (this._browser?.capabilities as WebdriverIO.Capabilities) || ({} as Capabilities.RequestedMultiremoteCapabilities) if (this._isMultiremote) { const multiremoteBrowser = this._browser as WebdriverIO.MultiRemoteBrowser multiremoteBrowser.instances.forEach((browserName: string) => { (capabilities as Capabilities.RequestedMultiremoteCapabilities)[browserName] = multiremoteBrowser.getInstance(browserName).capabilities as any }) } await this._browser?.deleteSession() process.send!({ origin: 'worker', name: 'sessionEnded', cid: this._cid }) /** * delete session(s) */ if (this._isMultiremote) { multiremoteBrowser.instances.forEach((browserName: string) => { // @ts-ignore sessionId is usually required delete multiremoteBrowser.getInstance(browserName).sessionId }) } else if (browser) { browser.sessionId = undefined as unknown as string } const afterSessionArgs: AfterSessionArgs = [this._config!, capabilities, this._specs as string[]] await executeHooksWithArgs('afterSession', this._config!.afterSession!, afterSessionArgs) } } export { default as BaseReporter } from './reporter.js' export * from './types.js'