🏡 index : quine-global/webdriverio.git

import url from 'node:url'
import path from 'node:path'
import type { Runner } from 'mocha'
import Mocha from 'mocha'
// @ts-expect-error not exposed from package yet, see https://github.com/mochajs/mocha/issues/4961
import { handleRequires } from 'mocha/lib/cli/run-helpers.js'

import logger from '@wdio/logger'
import { executeHooksWithArgs } from '@wdio/utils'
import type { Services } from '@wdio/types'

import { formatMessage, setupEnv } from './common.js'
import { EVENTS, NOOP } from './constants.js'
import type { MochaOpts as MochaOptsImport, FrameworkMessage, MochaError } from './types.js'
import type { EventEmitter } from 'node:events'

const log = logger('@wdio/mocha-framework')
const FILE_PROTOCOL = 'file://'

type EventTypes = 'hook' | 'test' | 'suite'
type EventTypeProps = '_hookCnt' | '_testCnt' | '_suiteCnt'
interface ParsedConfiguration extends Required<WebdriverIO.Config> {
    rootDir: string
    mochaOpts: MochaOptsImport
}

/**
 * Mocha runner
 */
class MochaAdapter {
    private _mocha?: Mocha
    private _runner?: Runner
    private _specLoadError?: Error

    private _level = 0
    private _hasTests = true
    private _suiteIds: string[] = ['0']
    private _suiteCnt: Map<string, number> = new Map()
    private _hookCnt: Map<string, number> = new Map()
    private _testCnt: Map<string, number> = new Map()
    private _suiteStartDate: number = Date.now()

    constructor(
        private _cid: string,
        private _config: ParsedConfiguration,
        private _specs: string[],
        private _capabilities: WebdriverIO.Capabilities,
        private _reporter: EventEmitter
    ) {
        this._config = Object.assign({
            mochaOpts: {}
        }, _config)
    }

    async init() {
        const { mochaOpts } = this._config
        if (Array.isArray(mochaOpts.require)) {
            const plugins = await handleRequires(
                mochaOpts.require
                    .filter((p) => typeof p === 'string')
                    .map((p) => path.resolve(this._config.rootDir, p))
            )
            Object.assign(mochaOpts, plugins)
        }

        const mocha = this._mocha = new Mocha(mochaOpts)
        // @ts-ignore outdated types
        await mocha.loadFilesAsync({
            esmDecorator: (file: string) => `${file}?invalidateCache=${Math.random()}`
        })
        mocha.reporter(NOOP as unknown as Mocha.Reporter)
        mocha.fullTrace()

        /**
         * as Mocha doesn't support file:// formats yet we have to
         * remove it before adding it to Mocha
         */
        this._specs.forEach((spec) => mocha.addFile(
            spec.startsWith(FILE_PROTOCOL)
                ? url.fileURLToPath(spec)
                : spec
        ))

        const { beforeTest, beforeHook, afterTest, afterHook } = this._config
        mocha.suite.on('pre-require', () =>
            setupEnv(this._cid, this._config.mochaOpts, beforeTest, beforeHook, afterTest, afterHook))
        mocha.suite.on('require', () => mocha.unloadFiles())
        await this._loadFiles(mochaOpts)
        return this
    }

    async _loadFiles (mochaOpts: MochaOptsImport) {
        try {
            // @ts-ignore outdated types
            await this._mocha!.loadFilesAsync({
                esmDecorator: (file: string) => `${file}?invalidateCache=${Math.random()}`
            })

            /**
             * grep
             */
            const mochaRunner = new Mocha.Runner(this._mocha!.suite, { delay: false })
            if (mochaOpts.grep) {
                mochaRunner.grep(this._mocha!.options.grep as RegExp, mochaOpts.invert!)
            }

            this._hasTests = mochaRunner.total > 0
        } catch (err) {
            const error = '' +
                'Unable to load spec files quite likely because they rely on `browser` object that is not fully initialized.\n' +
                '`browser` object has only `capabilities` and some flags like `isMobile`.\n' +
                'Helper files that use other `browser` commands have to be moved to `before` hook.\n' +
                `Spec file(s): ${this._specs.join(',')}\n` +
                `Error: ${(err as Error).stack}`
            this._specLoadError = new Error(error)
            log.warn(error)
        }
    }

    hasTests () {
        return this._hasTests
    }

    async run () {
        const mocha = this._mocha!

        let runtimeError
        const result = await new Promise((resolve) => {
            try {
                this._runner = mocha.run(resolve)
            } catch (err) {
                runtimeError = err as Error
                return resolve(1)
            }

            Object.keys(EVENTS).forEach((e: keyof typeof EVENTS) =>
                this._runner!.on(e, this.emit.bind(this, EVENTS[e])))

            this._runner.suite.beforeAll(this.wrapHook('beforeSuite'))
            this._runner.suite.afterAll(this.wrapHook('afterSuite'))
        })
        await executeHooksWithArgs('after', this._config.after as Function, [runtimeError || this._specLoadError || result, this._capabilities, this._specs])

        /**
         * in case the spec has a runtime error throw after the wdio hook
         */
        if (runtimeError || this._specLoadError) {
            throw runtimeError || this._specLoadError
        }

        return result
    }

    /**
     * Hooks which are added as true Mocha hooks need to call done() to notify async
     */
    wrapHook (hookName: keyof Services.HookFunctions) {
        return () => executeHooksWithArgs(
            hookName,
            this._config[hookName] as Function,
            [this.prepareMessage(hookName)]
        ).catch((e) => {
            log.error(`Error in ${hookName} hook: ${e.stack.slice(7)}`)
        })
    }

    prepareMessage (hookName: keyof Services.HookFunctions) {
        const params: FrameworkMessage = { type: hookName }

        switch (hookName) {
        case 'beforeSuite':
            this._suiteStartDate = Date.now()
            params.payload = this._runner?.suite.suites[0]
            break
        case 'afterSuite':
            params.payload = this._runner?.suite.suites[0]
            if (params.payload) {
                (params.payload as { duration: number }).duration = (
                    (params.payload as { duration: number }).duration ||
                    (Date.now() - this._suiteStartDate)
                )
            }
            break
        case 'beforeTest':
        case 'afterTest':
            params.payload = this._runner?.test
            break
        }

        return formatMessage(params)
    }

    emit (event: string, payload: Record<string, unknown>, err?: MochaError) {
        /**
         * For some reason, Mocha fires a second 'suite:end' event for the root suite,
         * with no matching 'suite:start', so this can be ignored.
         */
        if (payload.root) {
            return
        }

        const message = formatMessage({ type: event, payload, err })

        message.cid = this._cid
        message.specs = this._specs
        message.uid = this.getUID(message)

        this._reporter.emit(message.type, message)
    }

    getSyncEventIdStart (type: EventTypes) {
        const prop = `_${type}Cnt` as EventTypeProps
        const suiteId = this._suiteIds[this._suiteIds.length - 1]
        const cnt = this[prop].has(suiteId)
            ? this[prop].get(suiteId) || 0
            : 0
        this[prop].set(suiteId, cnt + 1)
        return `${type}-${suiteId}-${cnt}`
    }

    getSyncEventIdEnd (type: EventTypes) {
        const prop = `_${type}Cnt` as EventTypeProps
        const suiteId = this._suiteIds[this._suiteIds.length - 1]
        const cnt = this[prop].get(suiteId)! - 1
        return `${type}-${suiteId}-${cnt}`
    }

    getUID (message: FrameworkMessage) {
        if (message.type === 'suite:start') {
            const suiteCnt = this._suiteCnt.has(this._level.toString())
                ? this._suiteCnt.get(this._level.toString())
                : 0
            const suiteId = `suite-${this._level}-${suiteCnt}`

            if (this._suiteCnt.has(this._level.toString())) {
                this._suiteCnt.set(this._level.toString(), this._suiteCnt.get(this._level.toString())! + 1)
            } else {
                this._suiteCnt.set(this._level.toString(), 1)
            }

            // const suiteId = this.getSyncEventIdStart('suite')
            this._suiteIds.push(`${this._level}${suiteCnt}`)
            this._level++
            return suiteId
        }
        if (message.type === 'suite:end') {
            this._level--
            const suiteCnt = this._suiteCnt.get(this._level.toString())! - 1
            const suiteId = `suite-${this._level}-${suiteCnt}`
            this._suiteIds.pop()
            return suiteId
        }
        if (message.type === 'hook:start') {
            return this.getSyncEventIdStart('hook')
        }
        if (message.type === 'hook:end') {
            return this.getSyncEventIdEnd('hook')
        }
        if (['test:start', 'test:pending'].includes(message.type)) {
            return this.getSyncEventIdStart('test')
        }
        if (['test:end', 'test:pass', 'test:fail', 'test:retry'].includes(message.type)) {
            return this.getSyncEventIdEnd('test')
        }

        throw new Error(`Unknown message type : ${message.type}`)
    }
}

const adapterFactory: { init?: Function } = {}

adapterFactory.init = async function (...args: unknown[]) {
    // @ts-ignore just passing through args
    const adapter = new MochaAdapter(...args)
    const instance = await adapter.init()
    return instance
}

export default adapterFactory
export { MochaAdapter, adapterFactory }
export * from './types.js'

declare global {
    namespace WebdriverIO {
        interface MochaOpts extends MochaOptsImport {}
    }
}