import fs from 'node:fs' import path from 'node:path' import type { WriteStream } from 'node:fs' import { EventEmitter } from 'node:events' import type { Reporters, Options } from '@wdio/types' import { getErrorsFromEvent, transformCommandScript } from './utils.js' import type { Suite } from './stats/suite.js' import SuiteStats from './stats/suite.js' import type { Hook } from './stats/hook.js' import HookStats from './stats/hook.js' import TestStats, { type Test } from './stats/test.js' import RunnerStats from './stats/runner.js' import type { AfterCommandArgs, BeforeCommandArgs, CommandArgs, Tag, Argument } from './types.js' type CustomWriteStream = { write: (content: unknown) => boolean } export default class WDIOReporter extends EventEmitter { outputStream: WriteStream | CustomWriteStream failures = 0 suites: Record = {} hooks: Record = {} tests: Record ={} currentSuites: SuiteStats[] = [] counts = { suites: 0, tests: 0, hooks: 0, passes: 0, skipping: 0, failures: 0, pending: 0 } retries = 0 runnerStat?: RunnerStats isContentPresent = false specs: string[] = [] currentSpec?: string constructor(public options: Partial) { super() // ensure the report directory exists if (this.options.outputDir) { try { fs.mkdirSync(this.options.outputDir, { recursive: true }) } catch (err) { throw new Error(`Couldn't create output directory at "${this.options.outputDir}": ${(err as Error).stack}`) } } this.outputStream = (this.options.stdout || !this.options.logFile) && this.options.writeStream ? this.options.writeStream as CustomWriteStream : fs.createWriteStream(this.options.logFile!) let currentTest: TestStats const rootSuite = new SuiteStats({ title: '(root)', fullTitle: '(root)', file: '' }) this.currentSuites.push(rootSuite) this.on('client:beforeCommand', this.onBeforeCommand.bind(this)) this.on('client:afterCommand', this.onAfterCommand.bind(this)) this.on('client:beforeAssertion', this.onBeforeAssertion.bind(this)) this.on('client:afterAssertion', this.onAfterAssertion.bind(this)) this.on('runner:start', (runner: Options.RunnerStart) => { rootSuite.cid = runner.cid this.specs.push(...runner.specs) this.runnerStat = new RunnerStats(runner) this.onRunnerStart(this.runnerStat) }) this.on('suite:start', (params: Suite) => { /** * the jasmine framework doesn't give us information about the file * therefore we need to propagate these information into params */ if (!params.file) { params.file = !params.parent ? this.specs.shift() || 'unknown spec file' : this.currentSpec! this.currentSpec = params.file } const suite = new SuiteStats(params) const currentSuite = this.currentSuites[this.currentSuites.length - 1] currentSuite.suites.push(suite) this.currentSuites.push(suite) this.suites[suite.uid] = suite this.onSuiteStart(suite) }) /** * This event is currently only supported by Cucumber and is emitted instead of a new * 'suite:start' event when a suite is retried. This is useful for reporters that want to * display the number of times a suite was retried. */ this.on('suite:retry', (suite: Suite) => { const suiteStat = this.suites[suite.uid!] suiteStat.retry() this.onSuiteRetry(suiteStat) }) this.on('hook:start', (hook: Hook) => { const hookStats = new HookStats(hook) const currentSuite = this.currentSuites[this.currentSuites.length - 1] currentSuite.hooks.push(hookStats) currentSuite.hooksAndTests.push(hookStats) this.hooks[hook.uid!] = hookStats this.onHookStart(hookStats) }) this.on('hook:end', (hook: Hook) => { const hookStats = this.hooks[hook.uid!] hookStats.complete(getErrorsFromEvent(hook)) this.counts.hooks++ this.onHookEnd(hookStats) }) this.on('test:start', (test: Test) => { test.retries = this.retries currentTest = new TestStats(test) const currentSuite = this.currentSuites[this.currentSuites.length - 1] currentSuite.tests.push(currentTest) currentSuite.hooksAndTests.push(currentTest) this.tests[test.uid] = currentTest this.onTestStart(currentTest) }) this.on('test:pass', (test: Test) => { const testStat = this.tests[test.uid] testStat.pass() this.counts.passes++ this.counts.tests++ this.onTestPass(testStat) }) this.on('test:skip', (test: Test) => { const testStat = this.tests[test.uid] currentTest.skip(test.pendingReason!) this.counts.skipping++ this.counts.tests++ this.onTestSkip(testStat) }) this.on('test:fail', (test: Test) => { const testStat = this.tests[test.uid] testStat.fail(getErrorsFromEvent(test)) this.counts.failures++ this.counts.tests++ this.onTestFail(testStat) }) this.on('test:retry', (test: Test) => { const testStat = this.tests[test.uid] testStat.fail(getErrorsFromEvent(test)) this.onTestRetry(testStat) this.retries++ }) this.on('test:pending', (test: Test) => { test.retries = this.retries const currentSuite = this.currentSuites[this.currentSuites.length - 1] currentTest = new TestStats(test) /** * In Mocha: tests that are skipped don't have a start event but a test end. * In Jasmine: tests have a start event, therefore we need to replace the * test instance with the pending test here */ if (test.uid in this.tests && this.tests[test.uid].state !== 'pending') { currentTest.uid = test.uid in this.tests ? 'skipped-' + this.counts.skipping : currentTest.uid } const suiteTests = currentSuite.tests if (!suiteTests.length || currentTest.uid !== suiteTests[suiteTests.length - 1].uid) { currentSuite.tests.push(currentTest) currentSuite.hooksAndTests.push(currentTest) } else { suiteTests[suiteTests.length - 1] = currentTest currentSuite.hooksAndTests[currentSuite.hooksAndTests.length - 1] = currentTest } this.tests[currentTest.uid] = currentTest if (test.state === 'pending') { currentTest.state = 'pending' this.counts.pending++ this.counts.tests++ this.onTestPending(currentTest) } else { currentTest.skip(test.pendingReason!) this.counts.skipping++ this.counts.tests++ this.onTestSkip(currentTest) } }) this.on('test:end', (test: Test) => { const testStat = this.tests[test.uid] this.retries = 0 this.onTestEnd(testStat) }) this.on('suite:end', (suite: Suite) => { const suiteStat = this.suites[suite.uid!] suiteStat.complete() this.currentSuites.pop() this.onSuiteEnd(suiteStat) }) this.on('runner:end', (runner: Options.RunnerEnd) => { rootSuite.complete() if (this.runnerStat) { this.runnerStat.failures = runner.failures this.runnerStat.retries = runner.retries this.runnerStat.error = runner.error this.runnerStat.complete() this.onRunnerEnd(this.runnerStat) } const logFile = (this.options as Reporters.Options).logFile if (!this.isContentPresent && logFile && fs.existsSync(logFile)) { fs.unlinkSync(logFile) } }) /** * browser client event handlers */ this.on('client:beforeCommand', (payload) => { if (!currentTest) { return } if (payload.body?.script) { payload.body = { ...payload.body, script: transformCommandScript(payload.body.script) } } currentTest.output.push(Object.assign(payload, { type: 'command' })) }) this.on('client:afterCommand', (payload) => { if (!currentTest) { return } if (payload.body?.script) { payload.body = { ...payload.body, script: transformCommandScript(payload.body.script) } } currentTest.output.push(Object.assign(payload, { type: 'result' })) }) } /** * allows reporter to stale process shutdown process until required sync work * is done (e.g. when having to send data to some server or any other async work) */ get isSynchronised() { return true } /** * function to write to reporters output stream */ write(content: unknown) { if (content) { this.isContentPresent = true } this.outputStream.write(content) } onRunnerStart(_runnerStats: RunnerStats) { } onBeforeCommand(_commandArgs: BeforeCommandArgs) { } onAfterCommand(_commandArgs: AfterCommandArgs) { } onBeforeAssertion(_assertionArgs: unknown) { } onAfterAssertion(_assertionArgs: unknown) { } onSuiteStart(_suiteStats: SuiteStats) { } onHookStart(_hookStat: HookStats) { } onHookEnd(_hookStats: HookStats) { } onTestStart(_testStats: TestStats) { } onTestPass(_testStats: TestStats) { } onTestFail(_testStats: TestStats) { } onTestRetry(_testStats: TestStats) { } onTestSkip(_testStats: TestStats) { } onTestPending(_testStats: TestStats) { } onTestEnd(_testStats: TestStats) { } onSuiteRetry(_suiteStats: SuiteStats) { } onSuiteEnd(_suiteStats: SuiteStats) { } onRunnerEnd(_runnerStats: RunnerStats) { } } /** * Returns the browser name from the capabilities * @param caps WebdriverIO Capabilities * @returns {string} Browser name */ function getBrowserName(caps: WebdriverIO.Capabilities) { // @ts-expect-error outdated JSONWP capabilities const app = ((caps['appium:app'] || caps.app) || '').replace('sauce-storage:', '') const appName = ( caps['appium:bundleId'] || caps['appium:appPackage'] || caps['appium:appActivity'] || (path.isAbsolute(app) ? path.basename(app) : app) ) // @ts-expect-error outdated JSONWP capabilities return caps.browserName || caps.browser || appName } export { SuiteStats, Tag, HookStats, TestStats, RunnerStats, BeforeCommandArgs, AfterCommandArgs, CommandArgs, Argument, Test, getBrowserName }