import path from 'node:path' import logger from '@wdio/logger' import DotReporter from '@wdio/dot-reporter' import { initializePlugin } from '@wdio/utils' import type { Options, Capabilities, Reporters } from '@wdio/types' const log = logger('@wdio/runner') const mochaAllHooks = ['"before all" hook', '"after all" hook'] /** * BaseReporter * responsible for initializing reporters for every testrun and propagating events * to all these reporters */ export default class BaseReporter { private _reporters: Reporters.ReporterInstance[] = [] private listeners: ((ev: unknown) => void)[] = [] constructor( private _config: Options.Testrunner, private _cid: string, public caps: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities ) { /** * make sure there is at least on default reporter set up (dot reporter is default) */ this._config.reporters = this._config.reporters || [] if (this._config.reporters.length === 0) { this._config.reporters.push([DotReporter, {}]) } } async initReporters () { this._reporters = await Promise.all( this._config.reporters!.map(this._loadReporter.bind(this)) ) } /** * emit events to all registered reporter and wdio launcer * * @param {string} e event name * @param {object} payload event payload */ emit (e: string, payload: { cid?: string specs?: string[] uid?: string file?: string title?: string error?: string sessionId?: string config?: unknown isMultiremote?: boolean instanceOptions?: Options.Testrunner capabilities?: unknown retry?: number, }) { payload.cid = this._cid /** * Send failure message (only once) in case of test or hook failure */ const isTestError = e === 'test:fail' const isHookError = ( e === 'hook:end' && payload.error && mochaAllHooks.some(hook => payload.title?.startsWith(hook)) ) if (isTestError || isHookError) { this.#emitData({ origin: 'reporter', name: 'printFailureMessage', content: payload }) } this._reporters.forEach((reporter) => { try { reporter.emit(e, payload) } catch (err) { const error = err instanceof Error ? err : new Error(`An unknown error occurred: ${err}`) /** * When reporter throws an exception, log the error and continue with the next reporter */ this.#emitData({ origin: 'reporter', name: 'printFailureMessage', content: { cid: this._cid, // Destructing of message and stack is required else nothing is outputted error: { message: error.message, stack: error.stack }, fullTitle: `reporter ${reporter.constructor.name}`, } }) } }) } onMessage (listener: (ev: unknown) => void) { this.listeners.push(listener) } getLogFile (name: string) { // clone the config to avoid changing original properties const options = Object.assign({}, this._config) as Options.Testrunner & { cid: string capabilities: Capabilities.RequestedStandaloneCapabilities | Capabilities.RequestedMultiremoteCapabilities } let filename = `wdio-${this._cid}-${name}-reporter.log` const reporterOptions = this._config.reporters!.find((reporter) => ( Array.isArray(reporter) && ( reporter[0] === name || typeof reporter[0] === 'function' && reporter[0].name === name ) )) if (reporterOptions && Array.isArray(reporterOptions)) { const fileformat = reporterOptions[1].outputFileFormat options.cid = this._cid options.capabilities = this.caps Object.assign(options, reporterOptions[1]) if (fileformat) { if (typeof fileformat !== 'function') { throw new Error('outputFileFormat must be a function') } filename = fileformat(options) } } if (!options.outputDir) { return } return path.join(options.outputDir, filename) } /** * return write stream object based on reporter name */ getWriteStreamObject (reporter: string) { return { write: /* istanbul ignore next */ (content: unknown) => this.#emitData({ origin: 'reporter', name: reporter, content }) } } /** * emit data either through process or listener */ #emitData (payload: unknown) { if (typeof process.send === 'function') { return process.send!(payload) } this.listeners.forEach((fn) => fn(payload)) return true } /** * wait for reporter to finish synchronization, e.g. when sending data asynchronous * to a server (e.g. sumo reporter) */ waitForSync() { const startTime = Date.now() return new Promise((resolve, reject) => { const interval = setInterval(() => { const unsyncedReporter = this._reporters .filter((reporter) => !reporter.isSynchronised) .map((reporter) => reporter.constructor.name) if ((Date.now() - startTime) > this._config.reporterSyncTimeout! && unsyncedReporter.length) { clearInterval(interval) return reject(new Error(`Some reporters are still unsynced: ${unsyncedReporter.join(', ')}`)) } /** * no reporter are in need to sync anymore, continue */ if (!unsyncedReporter.length) { clearInterval(interval) return resolve(true) } log.info(`Wait for ${unsyncedReporter.length} reporter to synchronize`) // wait otherwise }, this._config.reporterSyncInterval) }) } /** * initialize reporters */ private async _loadReporter (reporter: Reporters.ReporterEntry) { let ReporterClass: Reporters.ReporterClass let options: Partial = {} /** * check if reporter has custom options */ if (Array.isArray(reporter)) { options = Object.assign({}, options, reporter[1]) reporter = reporter[0] } /** * check if reporter was passed in from a file, e.g. * * ```js * import MyCustomReporter from '/some/path/MyCustomReporter.js' * export const config = { * //... * reporters: [ * MyCustomReporter, // or * [MyCustomReporter, { custom: 'option' }] * ] * //... * } * ``` */ if (typeof reporter === 'function') { ReporterClass = reporter as Reporters.ReporterClass options.logFile = options.setLogFile ? options.setLogFile(this._cid, ReporterClass.name) : typeof options.logFile === 'string' ? options.logFile : this.getLogFile(ReporterClass.name) options.writeStream = this.getWriteStreamObject(ReporterClass.name) return new ReporterClass(options) } /** * check if reporter is a node package, e.g. wdio-dot reporter * * ```js * export const config = { * //... * reporters: [ * 'dot', // or * ['dot', { custom: 'option' }] * ] * //... * } * ``` */ if (typeof reporter === 'string') { ReporterClass = (await initializePlugin(reporter, 'reporter')).default as Reporters.ReporterClass options.logFile = options.setLogFile ? options.setLogFile(this._cid, reporter) : typeof options.logFile === 'string' ? options.logFile : this.getLogFile(reporter) options.writeStream = this.getWriteStreamObject(reporter) return new ReporterClass(options) } /** * throw error if reporter property was invalid */ throw new Error('Invalid reporters config') } }