webdriverio/wdio-mocha-framework

View on GitHub
lib/adapter.js

Summary

Maintainability
C
1 day
Test Coverage
import path from 'path'
import Mocha from 'mocha'

import { runInFiberContext, wrapCommands, executeHooksWithArgs } from 'wdio-sync'

const INTERFACES = {
    bdd: ['it', 'before', 'beforeEach', 'after', 'afterEach'],
    tdd: ['test', 'suiteSetup', 'setup', 'suiteTeardown', 'teardown'],
    qunit: ['test', 'before', 'beforeEach', 'after', 'afterEach']
}

const EVENTS = {
    'suite': 'suite:start',
    'suite end': 'suite:end',
    'test': 'test:start',
    'test end': 'test:end',
    'hook': 'hook:start',
    'hook end': 'hook:end',
    'pass': 'test:pass',
    'fail': 'test:fail',
    'pending': 'test:pending'
}

const NOOP = function () {}
const SETTLE_TIMEOUT = 5000

/**
 * Extracts the mocha UI type following this convention:
 *  - If the mochaOpts.ui provided doesn't contain a '-' then the full name
 *      is taken as ui type (i.e. 'bdd','tdd','qunit')
 *  - If it contains a '-' then it asumes we are providing a custom ui for
 *      mocha. Then it extracts the text after the last '-' (ignoring .js if
 *      provided) as the interface type. (i.e. strong-bdd in
 *      https://github.com/strongloop/strong-mocha-interfaces)
 */
const MOCHA_UI_TYPE_EXTRACTOR = /^(?:.*-)?([^-.]+)(?:.js)?$/
const DEFAULT_INTERFACE_TYPE = 'bdd'

/**
 * Mocha runner
 */
class MochaAdapter {
    constructor (cid, config, specs, capabilities) {
        /**
         * rename requires option to stay backwards compatible
         * ToDo remove with next major release
         */
        if (config.mochaOpts && config.mochaOpts.requires) {
            if (Array.isArray(config.mochaOpts.require)) {
                config.mochaOpts.require.push(config.mochaOpts.requires)
            } else {
                config.mochaOpts.require = config.mochaOpts.requires
            }
        }

        this.cid = cid
        this.capabilities = capabilities
        this.specs = specs
        this.config = Object.assign({
            mochaOpts: {}
        }, config)
        this.runner = {}

        this.sentMessages = 0 // number of messages sent to the parent
        this.receivedMessages = 0 // number of messages received by the parent
        this.messageCounter = 0
        this.messageUIDs = {
            suite: {},
            hook: {},
            test: {}
        }
    }

    options (options, context) {
        let {require = [], compilers = []} = options

        if (typeof require === 'string') {
            require = [require]
        }

        this.requireExternalModules([...compilers, ...require], context)
    }

    async run () {
        let {mochaOpts} = this.config

        const match = MOCHA_UI_TYPE_EXTRACTOR.exec(mochaOpts.ui)
        const type = (match && INTERFACES[match[1]] && match[1]) || DEFAULT_INTERFACE_TYPE

        const mocha = new Mocha(mochaOpts)
        mocha.loadFiles()
        mocha.reporter(NOOP)
        mocha.fullTrace()
        this.specs.forEach((spec) => mocha.addFile(spec))

        wrapCommands(global.browser, this.config.beforeCommand, this.config.afterCommand)

        mocha.suite.on('pre-require', (context, file, mocha) => {
            this.options(mochaOpts, {
                context, file, mocha, options: mochaOpts
            })

            INTERFACES[type].forEach((fnName) => {
                let testCommand = INTERFACES[type][0]

                runInFiberContext(
                    [testCommand, testCommand + '.only'],
                    this.config.beforeHook,
                    this.config.afterHook,
                    fnName
                )
            })
        })

        await executeHooksWithArgs(this.config.before, [this.capabilities, this.specs])
        let result = await new Promise((resolve, reject) => {
            this.runner = mocha.run(resolve)

            Object.keys(EVENTS).forEach((e) =>
                this.runner.on(e, this.emit.bind(this, EVENTS[e])))

            this.runner.suite.beforeAll(this.wrapHook('beforeSuite'))
            this.runner.suite.beforeEach(this.wrapHook('beforeTest'))
            this.runner.suite.afterEach(this.wrapHook('afterTest'))
            this.runner.suite.afterAll(this.wrapHook('afterSuite'))
        })
        await executeHooksWithArgs(this.config.after, [result, this.capabilities, this.specs])
        await this.waitUntilSettled()

        return result
    }

    /**
     * Hooks which are added as true Mocha hooks need to call done() to notify async
     */
    wrapHook (hookName) {
        return () => executeHooksWithArgs(
            this.config[hookName],
            this.prepareMessage(hookName)
        ).catch((e) => {
            console.log(`Error in ${hookName} hook`, e.stack)
        })
    }

    prepareMessage (hookName) {
        const params = { type: hookName }

        switch (hookName) {
        case 'beforeSuite':
        case 'afterSuite':
            params.payload = this.runner.suite.suites[0]
            break
        case 'beforeTest':
        case 'afterTest':
            params.payload = this.runner.test
            break
        }

        params.err = this.runner.lastError
        delete this.runner.lastError
        return this.formatMessage(params)
    }

    formatMessage (params) {
        let message = {
            type: params.type
        }

        if (params.err) {
            message.err = {
                message: params.err.message,
                stack: params.err.stack,
                type: params.err.type || params.err.name,
                expected: params.err.expected,
                actual: params.err.actual
            }
        }

        if (params.payload) {
            message.title = params.payload.title
            message.parent = params.payload.parent ? params.payload.parent.title : null

            /**
             * get title for hooks in root suite
             */
            if (message.parent === '' && params.payload.parent && params.payload.parent.suites) {
                message.parent = params.payload.parent.suites[0].title
            }

            message.fullTitle = params.payload.fullTitle ? params.payload.fullTitle() : message.parent + ' ' + message.title
            message.pending = params.payload.pending || false
            message.file = params.payload.file

            // Add the current test title to the payload for cases where it helps to
            // identify the test, e.g. when running inside a beforeEach hook
            if (params.payload.ctx && params.payload.ctx.currentTest) {
                message.currentTest = params.payload.ctx.currentTest.title
            }

            if (params.type.match(/Test/)) {
                message.passed = (params.payload.state === 'passed')
                message.duration = params.payload.duration
            }

            if (params.payload.context) { message.context = params.payload.context }
        }

        return message
    }

    requireExternalModules (modules, context) {
        modules.forEach(module => {
            if (module) {
                module = module.replace(/.*:/, '')

                if (module.substr(0, 1) === '.') {
                    module = path.join(process.cwd(), module)
                }

                this.load(module, context)
            }
        })
    }

    emit (event, payload, err) {
        // 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

        let message = this.formatMessage({type: event, payload, err})

        message.cid = this.cid
        message.specs = this.specs
        message.event = event
        message.runner = {}
        message.runner[this.cid] = this.capabilities

        if (err) {
            this.runner.lastError = err
        }

        let {uid, parentUid} = this.generateUID(message)
        message.uid = uid
        message.parentUid = parentUid

        // When starting a new test, propagate the details to the test runner so that
        // commands, results, screenshots and hooks can be associated with this test
        if (event === 'test:start') {
            this.sendInternal(event, message)
        }

        this.send(message, null, {}, () => ++this.receivedMessages)
        this.sentMessages++
    }

    generateUID (message) {
        var uid, parentUid

        switch (message.type) {
        case 'suite:start':
            uid = this.getUID(message.title, 'suite', true)
            parentUid = uid
            break

        case 'suite:end':
            uid = this.getUID(message.title, 'suite')
            parentUid = uid
            break

        case 'hook:start':
            uid = this.getUID(message.title, 'hook', true)
            parentUid = this.getUID(message.parent, 'suite')
            break

        case 'hook:end':
            uid = this.getUID(message.title, 'hook')
            parentUid = this.getUID(message.parent, 'suite')
            break

        case 'test:start':
            uid = this.getUID(message.title, 'test', true)
            parentUid = this.getUID(message.parent, 'suite')
            break

        case 'test:pending':
        case 'test:end':
        case 'test:pass':
        case 'test:fail':
            uid = this.getUID(message.title, 'test')
            parentUid = this.getUID(message.parent, 'suite')
            break

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

        return {
            uid,
            parentUid
        }
    }

    getUID (title, type, start) {
        if (start !== true && this.messageUIDs[type][title]) {
            return this.messageUIDs[type][title]
        }

        let uid = title + this.messageCounter++

        this.messageUIDs[type][title] = uid

        return uid
    }

    sendInternal (event, message) {
        process.emit(event, message)
    }

    /**
     * reset globals to rewire it out in tests
     */
    send (...args) {
        return process.send.apply(process, args)
    }

    /**
     * wait until all messages were sent to parent
     */
    waitUntilSettled () {
        return new Promise((resolve) => {
            const start = (new Date()).getTime()
            const interval = setInterval(() => {
                const now = (new Date()).getTime()

                if (this.sentMessages !== this.receivedMessages && now - start < SETTLE_TIMEOUT) return
                clearInterval(interval)
                resolve()
            }, 100)
        })
    }

    load (name, context) {
        try {
            module.context = context

            require(name)
        } catch (e) {
            throw new Error(`Module ${name} can't get loaded. Are you sure you have installed it?\n` +
                            `Note: if you've installed WebdriverIO globally you need to install ` +
                            `these external modules globally too!`)
        }
    }
}

const _MochaAdapter = MochaAdapter
const adapterFactory = {}

adapterFactory.run = async function (cid, config, specs, capabilities) {
    const adapter = new _MochaAdapter(cid, config, specs, capabilities)
    const result = await adapter.run()
    return result
}

export default adapterFactory
export { MochaAdapter, adapterFactory }