UNPKG

@morlay/ava

Version:

Futuristic test runner 🚀

299 lines (257 loc) • 9.05 kB
'use strict'; const StringDecoder = require('string_decoder').StringDecoder; const cliCursor = require('cli-cursor'); const lastLineTracker = require('last-line-stream/tracker'); const plur = require('plur'); const spinners = require('cli-spinners'); const chalk = require('chalk'); const cliTruncate = require('cli-truncate'); const cross = require('figures').cross; const indentString = require('indent-string'); const ansiEscapes = require('ansi-escapes'); const formatAssertError = require('../format-assert-error'); const extractStack = require('../extract-stack'); const codeExcerpt = require('../code-excerpt'); const colors = require('../colors'); const improperUsageMessages = require('./improper-usage-messages'); class MiniReporter { constructor(options) { this.options = Object.assign({}, options); chalk.enabled = this.options.color; for (const key of Object.keys(colors)) { colors[key].enabled = this.options.color; } const spinnerDef = spinners[process.platform === 'win32' ? 'line' : 'dots']; this.spinnerFrames = spinnerDef.frames.map(c => chalk.gray.dim(c)); this.spinnerInterval = spinnerDef.interval; this.reset(); this.stream = process.stderr; this.stringDecoder = new StringDecoder(); } start() { this.interval = setInterval(() => { this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length; this.write(this.prefix()); }, this.spinnerInterval); return this.prefix(''); } reset() { this.clearInterval(); this.passCount = 0; this.knownFailureCount = 0; this.failCount = 0; this.skipCount = 0; this.todoCount = 0; this.rejectionCount = 0; this.exceptionCount = 0; this.currentStatus = ''; this.currentTest = ''; this.statusLineCount = 0; this.spinnerIndex = 0; this.lastLineTracker = lastLineTracker(); } spinnerChar() { return this.spinnerFrames[this.spinnerIndex]; } clearInterval() { clearInterval(this.interval); this.interval = null; } test(test) { if (test.todo) { this.todoCount++; } else if (test.skip) { this.skipCount++; } else if (test.error) { this.failCount++; } else { this.passCount++; if (test.failing) { this.knownFailureCount++; } } if (test.todo || test.skip) { return; } return this.prefix(this._test(test)); } prefix(str) { str = str || this.currentTest; this.currentTest = str; // The space before the newline is required for proper formatting // TODO(jamestalmage): Figure out why it's needed and document it here return ` \n ${this.spinnerChar()} ${str}`; } _test(test) { const SPINNER_WIDTH = 3; const PADDING = 1; let title = cliTruncate(test.title, process.stdout.columns - SPINNER_WIDTH - PADDING); if (test.error || test.failing) { title = colors.error(test.title); } return title + '\n' + this.reportCounts(); } unhandledError(err) { if (err.type === 'exception') { this.exceptionCount++; } else { this.rejectionCount++; } } reportCounts(time) { const lines = [ this.passCount > 0 ? '\n ' + colors.pass(this.passCount, 'passed') : '', this.knownFailureCount > 0 ? '\n ' + colors.error(this.knownFailureCount, plur('known failure', this.knownFailureCount)) : '', this.failCount > 0 ? '\n ' + colors.error(this.failCount, 'failed') : '', this.skipCount > 0 ? '\n ' + colors.skip(this.skipCount, 'skipped') : '', this.todoCount > 0 ? '\n ' + colors.todo(this.todoCount, 'todo') : '' ].filter(Boolean); if (time && lines.length > 0) { lines[0] += ' ' + time; } return lines.join(''); } finish(runStatus) { this.clearInterval(); let time; if (this.options.watching) { time = chalk.gray.dim('[' + new Date().toLocaleTimeString('en-US', {hour12: false}) + ']'); } let status = this.reportCounts(time); if (this.rejectionCount > 0) { status += '\n ' + colors.error(this.rejectionCount, plur('rejection', this.rejectionCount)); } if (this.exceptionCount > 0) { status += '\n ' + colors.error(this.exceptionCount, plur('exception', this.exceptionCount)); } if (runStatus.previousFailCount > 0) { status += '\n ' + colors.error(runStatus.previousFailCount, 'previous', plur('failure', runStatus.previousFailCount), 'in test files that were not rerun'); } if (this.knownFailureCount > 0) { for (const test of runStatus.knownFailures) { const title = test.title; status += '\n\n ' + colors.title(title); // TODO: Output description with link // status += colors.stack(description); } } if (this.failCount > 0) { runStatus.errors.forEach((test, index) => { if (!test.error) { return; } const beforeSpacing = index === 0 ? '\n\n' : '\n\n\n\n'; status += beforeSpacing + ' ' + colors.title(test.title) + '\n'; if (test.error.source) { status += ' ' + colors.errorSource(test.error.source.file + ':' + test.error.source.line) + '\n'; const excerpt = codeExcerpt(test.error.source, {maxWidth: process.stdout.columns}); if (excerpt) { status += '\n' + indentString(excerpt, 2) + '\n'; } } if (test.error.message) { status += '\n' + indentString(test.error.message, 2) + '\n'; } if (test.error.avaAssertionError) { const formatted = formatAssertError.formatSerializedError(test.error); if (formatted) { status += '\n' + indentString(formatted, 2); } const message = improperUsageMessages.forError(test.error); if (message) { status += '\n' + indentString(message, 2) + '\n'; } } if (test.error.stack) { const extracted = extractStack(test.error.stack); if (extracted.includes('\n')) { status += '\n' + indentString(colors.errorStack(extracted), 2); } } }); } if (this.rejectionCount > 0 || this.exceptionCount > 0) { // TODO(sindresorhus): Figure out why this causes a test failure when switched to a for-of loop runStatus.errors.forEach(err => { if (err.title) { return; } if (err.type === 'exception' && err.name === 'AvaError') { status += '\n\n ' + colors.error(cross + ' ' + err.message); } else { const title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception'; let description = err.stack ? err.stack.trimRight() : JSON.stringify(err); description = description.split('\n'); const errorTitle = err.name ? description[0] : 'Threw non-error: ' + description[0]; const errorStack = description.slice(1).join('\n'); status += '\n\n ' + colors.title(title) + '\n'; status += ' ' + colors.stack(errorTitle) + '\n'; status += colors.errorStack(errorStack); } }); } if (runStatus.failFastEnabled === true && runStatus.remainingCount > 0 && runStatus.failCount > 0) { const remaining = 'At least ' + runStatus.remainingCount + ' ' + plur('test was', 'tests were', runStatus.remainingCount) + ' skipped.'; status += '\n\n ' + colors.information('`--fail-fast` is on. ' + remaining); } if (runStatus.hasExclusive === true && runStatus.remainingCount > 0) { status += '\n\n ' + colors.information('The .only() modifier is used in some tests.', runStatus.remainingCount, plur('test', runStatus.remainingCount), plur('was', 'were', runStatus.remainingCount), 'not run'); } return status + '\n\n'; } section() { return '\n' + chalk.gray.dim('\u2500'.repeat(process.stdout.columns || 80)); } clear() { return ''; } write(str) { cliCursor.hide(); this.currentStatus = str; this._update(); this.statusLineCount = this.currentStatus.split('\n').length; } stdout(data) { this._update(data); } stderr(data) { this._update(data); } _update(data) { let str = ''; let ct = this.statusLineCount; const columns = process.stdout.columns; let lastLine = this.lastLineTracker.lastLine(); // Terminals automatically wrap text. We only need the last log line as seen on the screen. lastLine = lastLine.substring(lastLine.length - (lastLine.length % columns)); // Don't delete the last log line if it's completely empty. if (lastLine.length > 0) { ct++; } // Erase the existing status message, plus the last log line. str += ansiEscapes.eraseLines(ct); // Rewrite the last log line. str += lastLine; if (str.length > 0) { this.stream.write(str); } if (data) { // Send new log data to the terminal, and update the last line status. this.lastLineTracker.update(this.stringDecoder.write(data)); this.stream.write(data); } let currentStatus = this.currentStatus; if (currentStatus.length > 0) { lastLine = this.lastLineTracker.lastLine(); // We need a newline at the end of the last log line, before the status message. // However, if the last log line is the exact width of the terminal a newline is implied, // and adding a second will cause problems. if (lastLine.length % columns) { currentStatus = '\n' + currentStatus; } // Rewrite the status message. this.stream.write(currentStatus); } } } module.exports = MiniReporter;