UNPKG

mocha-progress-reporter

Version:

Custom Mocha reporter with spinners and progress reporting ability

393 lines (250 loc) 11.2 kB
import Mocha from 'mocha'; import { EventEmitter } from 'events'; import ora from 'ora'; import color from 'chalk'; import util from 'util'; import stripAnsi from 'strip-ansi'; import Base from 'mocha/lib/reporters/base'; let chalk = new color.Instance(); let reporterOptions: Partial<ProgressReporterOptions> = {}; interface ProgressReporterOptions { /** Toggle logs (default: true). */ logs: boolean; /** Toggle output colors (default: true). */ colors: boolean; /** Toggle hook reports (default: true). */ hooks: boolean; } class ProgressReporter { private events = new EventEmitter(); /** Sends a progress message to the reporter for the current test. */ progress(message: string) { this.events.emit('progress', message); } /** Clears the last progress message. */ clear() { this.events.emit('clear'); } /** Logs a message without interrupting the reporter spinners. */ log(message: any, ...additionalMessages: any[]) { this.events.emit('log', [message, ...additionalMessages].map(m => { if ( m && typeof m === 'object' && (m.constructor === Object || m.constructor === Array) ) return util.inspect(m, false, 4, true); else return m; }).join(' ')); } /** Logs a warning message without interrupting the reporter spinners. */ warn(message: any, ...additionalMessages: any[]) { this.log(chalk.yellow(message), ...additionalMessages.map(m => chalk.yellow(m))); } /** Logs an error message without interrupting the reporter spinners. */ error(message: any, ...additionalMessages: any[]) { this.log(chalk.redBright(message), ...additionalMessages.map(m => chalk.redBright(m))); } /** Configures the reporter. */ config(options: Partial<ProgressReporterOptions>) { this.events.emit('config', options); reporterOptions = Object.assign({}, options); } } declare global { /** The global instance for interacting with the progress reporter. */ const reporter: ProgressReporter; } const { EVENT_RUN_BEGIN, EVENT_RUN_END, EVENT_TEST_BEGIN, EVENT_TEST_PENDING, EVENT_TEST_PASS, EVENT_TEST_FAIL, EVENT_TEST_RETRY, EVENT_HOOK_BEGIN, EVENT_HOOK_END } = Mocha.Runner.constants; (<any>global).reporter = new ProgressReporter(); export = class CustomReporter extends Base { private stats: Mocha.Stats; private spinner = ora(); private currentTestMessage: string = null; private currentProgressMessage: string = null; private currentLogs: string[] = []; private retries: number = 0; private testInProgress: boolean = false; private logsRenderedLast: boolean = false; private hookInProgress: boolean = false; private config: ProgressReporterOptions = Object.assign({ logs: true, colors: true, hooks: true }, reporterOptions); constructor(runner: Mocha.Runner) { super(runner); (<any>reporter).events .on('progress', (message: string) => { if ( ! this.testInProgress && ! this.hookInProgress ) return; this.currentProgressMessage = message; this.rerender(); }) .on('log', (message: string) => { if ( ! this.config.logs ) return; if ( ! this.testInProgress && ! this.hookInProgress ) return console.log('\n ' + message); this.currentLogs.push(this.config.colors ? message : stripAnsi(message)); this.rerender(); }) .on('clear', () => { this.currentProgressMessage = null; this.rerender(); }) .on('config', (options: Partial<ProgressReporterOptions>) => { this.config = Object.assign({}, this.config, options); chalk = this.config.colors ? new color.Instance() : new color.Instance({ level: 0 }); this.spinner.color = this.config.colors ? 'cyan' : 'white'; }); runner .once(EVENT_RUN_BEGIN, () => { console.log(); this.spinner.stopAndPersist({ symbol: chalk.blueBright('i'), text: 'Tests have been started' }); console.log(); }) .on(EVENT_HOOK_BEGIN, hook => { if ( ! this.config.hooks ) return; this.hookInProgress = true; this.currentTestMessage = this.getHookMessage(hook.fullTitle(), true); if ( this.logsRenderedLast ) console.log(); this.logsRenderedLast = false; this.spinner.start(this.currentTestMessage); }) .on(EVENT_HOOK_END, hook => { if ( ! this.config.hooks && ! this.hookInProgress ) return; this.hookInProgress = false; this.currentProgressMessage = null; this.rerender(); this.renderFinal('hook-success', this.getHookMessage(hook.fullTitle())); this.currentLogs = []; }) .on(EVENT_TEST_BEGIN, test => { this.testInProgress = true; this.currentTestMessage = `${chalk.bold(test.parent.title)} ${test.title} ${this.getRetryTag()}${chalk.yellow.bold('(running)')}`; if ( this.logsRenderedLast ) console.log(); this.logsRenderedLast = false; this.spinner.start(this.currentTestMessage); }) .on(EVENT_TEST_RETRY, (test: Mocha.Test, error: Error) => { this.currentProgressMessage = null; this.rerender(); this.renderFinal('fail', `${chalk.bold(test.parent.title)} ${test.title} ${this.getRetryTag()}${chalk.redBright.bold('(failed)')} ${this.getTimeoutTag(error)}${this.getTimeTag(test.duration)}`); this.currentLogs = []; this.retries++; }) .on(EVENT_TEST_PASS, test => { this.testInProgress = false; this.currentProgressMessage = null; this.rerender(); this.renderFinal('success', `${chalk.bold(test.parent.title)} ${test.title} ${this.getRetryTag()}${chalk.greenBright.bold('(passed)')} ${this.getTimeTag(test.duration)}`); this.currentLogs = []; this.retries = 0; }) .on(EVENT_TEST_FAIL, (test, error) => { const hookFailed = this.hookInProgress; if ( hookFailed ) this.hookInProgress = false; else this.testInProgress = false; this.currentProgressMessage = null; this.rerender(); if ( hookFailed ) { const hook = (<Mocha.Hook><unknown>test); this.renderFinal('hook-fail', `${this.getHookMessage(hook.fullTitle())} ${chalk.redBright.bold('(failed)')} ${this.getTimeoutTag(error)}${this.getTimeTag(test.duration)}`); } else { this.renderFinal('fail', `${chalk.bold(test.parent.title)} ${test.title} ${this.getRetryTag()}${chalk.redBright.bold('(failed)')} ${this.getTimeoutTag(error)}${this.getTimeTag(test.duration)}`); } this.currentLogs = []; this.retries = 0; this.displayError(error); }) .on(EVENT_TEST_PENDING, test => { this.spinner.stopAndPersist({ symbol: chalk.dim('-'), text: chalk.dim(`${chalk.bold(test.parent.title)} ${test.title} ${chalk.bold('(skipped)')}`) }); }) .once(EVENT_RUN_END, () => { console.log(); this.spinner.stopAndPersist({ symbol: chalk.blueBright('i'), text: `Tests have finished with ${chalk.greenBright.bold(`${this.stats.passes} passes`)}, ${chalk.redBright.bold(`${this.stats.failures} failures`)}, and ${chalk.dim.bold.white(`${this.stats.pending} skips`)} after ${this.getDurationColorFunction(this.stats.duration)(`${this.stats.duration}ms`)}` }); console.log(); }); } private displayError(error: any) { // If not timeout error if ( this.getTimeoutTag(error) === '' ) { if ( error.showDiff ) { // Display diff let diff = Base.generateDiff(error.actual, error.expected); if ( ! this.config.colors ) diff = stripAnsi(diff); if ( diff.includes('failed to generate Mocha diff') ) console.error('\n' + chalk.redBright(' ' + error.message.replace(/\n/g, ' \n')) + '\n'); else console.log(diff.split('\n').map(line => line.substr(4)).join('\n')); console.error(chalk.dim(' ' + error.stack.replace(/\n/g, ' \n'))); } else { console.error('\n' + chalk.redBright(' '+ error.message.replace(/\n/g, ' \n')) + '\n'); console.error(chalk.dim(' ' + error.stack.replace(/\n/g, ' \n'))); } } console.log(); } private parseHookName(name: string): { suiteName?: string; hookType: string; hookName?: string; testName: string; } { const match = name.match(/^((?<suiteName>.*?) )?"(?<hookType>.+?)" hook(: (?<hookName>.+))? ((for)|(in)) "(?<testName>.+)"$/); if ( ! match ) return null; return <any>match.groups; } private getHookMessage(fullTitle: string, begin?: boolean) { const parsed = this.parseHookName(fullTitle); if ( ! parsed ) return chalk.yellow(`Unkown hook ${begin ? 'is running' : 'has finished running'}`); return `${chalk.yellow.bold(parsed.hookType)} hook ` + `${parsed.hookName ? `${chalk.bold(`"${parsed.hookName}"`)} ` : ''}` + `${begin ? 'is running' : 'has finished running'} ` + `${['after each', 'before each'].includes(parsed.hookType) ? 'for the last test' : (parsed.testName === '{root}' ? `${parsed.hookType} suites` : `${parsed.hookType.split(' ')[0]} ${chalk.blueBright.bold(parsed.suiteName)} suite`)}`; } private rerender() { const progressLog = this.currentProgressMessage ? `\n ${chalk.dim(this.currentProgressMessage)}` : ''; const logsLog = this.renderLogs(); this.spinner.text = `${this.currentTestMessage}${progressLog}${logsLog}`; this.logsRenderedLast = !! logsLog; } private renderFinal(result: 'success'|'fail'|'hook-success'|'hook-fail', message: string) { const logsLog = this.renderLogs(); if ( result === 'success' ) this.spinner.stopAndPersist({ text: message + logsLog, symbol: chalk.greenBright('✔') }); else if ( result === 'fail' ) this.spinner.stopAndPersist({ text: message + logsLog, symbol: chalk.redBright('✖') }); else if ( result === 'hook-success' ) this.spinner.stopAndPersist({ text: message + logsLog, symbol: chalk.yellow.bold('~') }); else this.spinner.stopAndPersist({ text: message + logsLog, symbol: chalk.redBright.bold('!') }); this.logsRenderedLast = !! logsLog; } private renderLogs() { return this.currentLogs.length ? `\n\n ${this.currentLogs.join('\n').replace(/\n/g, '\n ')}` : ''; } private getTimeTag(duration: number) { return this.getDurationColorFunction(duration)(`(${duration}ms)`); } private getDurationColorFunction(duration: number) { if ( duration < 500 ) return chalk.white.bold; if ( duration < 1000 ) return chalk.yellow.bold; if ( duration < 1500 ) return chalk.magenta.bold; return chalk.redBright.bold; } private getRetryTag() { if ( ! this.retries ) return ''; let tag = this.retries + ''; if ( ['11', '12', '13'].includes(tag.substr(-2)) ) tag += 'th'; else if ( tag.substr(-1) === '1' ) tag += 'st'; else if ( tag.substr(-1) === '2') tag += 'nd'; else if ( tag.substr(-1) === '3' ) tag += 'rd'; else tag += 'th'; return chalk.yellow.bold(`(${tag} retry) `); } private getTimeoutTag(error: Error) { const match = error.message.match(/^Timeout of \d+ms exceeded\..+/); if ( ! match ) return ''; return chalk.cyanBright.bold(`(timeout) `); } }