karma-mocha-reporter
Version:
Karma reporter with mocha style logging.
614 lines (515 loc) • 21.5 kB
JavaScript
;
var chalk = require('chalk');
var symbols = require('log-symbols');
var stripAnsi = require('strip-ansi');
/**
* The MochaReporter.
*
* @param {!object} baseReporterDecorator The karma base reporter.
* @param {!Function} formatError The karma function to format an error.
* @param {!object} config The karma config.
* @constructor
*/
var MochaReporter = function (baseReporterDecorator, formatError, config) {
// extend the base reporter
baseReporterDecorator(this);
var self = this;
var firstRun = true;
var isRunCompleted = false;
var internalPrefix = '$%$';
/**
* Returns the text repeated n times.
*
* @param {!string} text The text.
* @param {!number} n The number of times the string should be repeated.
* @returns {string}
*/
function repeatString(text, n) {
var res = [];
var i;
for (i = 0; i < n; i++) {
res.push(text);
}
return res.join('');
}
config.mochaReporter = config.mochaReporter || {};
var outputMode = config.mochaReporter.output || 'full';
var ignoreSkipped = config.mochaReporter.ignoreSkipped || false;
var divider = config.mochaReporter.hasOwnProperty('divider') ? config.mochaReporter.divider : '=';
divider = repeatString(divider || '', process.stdout.columns || 80);
// disable chalk when colors is set to false
chalk.enabled = config.colors !== false;
// set color functions
config.mochaReporter.colors = config.mochaReporter.colors || {};
// set symbol functions
config.mochaReporter.symbols = config.mochaReporter.symbols || {};
// set diff output
config.mochaReporter.showDiff = config.mochaReporter.showDiff || false;
// print first successful result
config.mochaReporter.printFirstSuccess = config.mochaReporter.printFirstSuccess || false;
var colors = {
success: {
symbol: config.mochaReporter.symbols.success || stripAnsi(symbols.success),
print: chalk[config.mochaReporter.colors.success] || chalk.green
},
info: {
symbol: config.mochaReporter.symbols.info || stripAnsi(symbols.info),
print: chalk[config.mochaReporter.colors.info] || chalk.grey
},
warning: {
symbol: config.mochaReporter.symbols.warning || stripAnsi(symbols.warning),
print: chalk[config.mochaReporter.colors.warning] || chalk.yellow
},
error: {
symbol: config.mochaReporter.symbols.error || stripAnsi(symbols.error),
print: chalk[config.mochaReporter.colors.error] || chalk.red
}
};
// init max number of log lines
config.mochaReporter.maxLogLines = config.mochaReporter.maxLogLines || 999;
if (isNaN(config.mochaReporter.maxLogLines)) {
self.write(colors.warning.print('Option "config.mochaReporter.maxLogLines" must be of type number. Default value 999 is used!'));
config.mochaReporter.maxLogLines = 999;
}
// check if mocha is installed when showDiff is enabled
if (config.mochaReporter.showDiff) {
try {
var mocha = require('mocha');
var diff = require('diff');
} catch (e) {
self.write(colors.error.print('Error loading module mocha!\nYou have enabled diff output. That only works with karma-mocha and mocha installed!\nRun the following command in your command line:\n npm install karma-mocha mocha diff\n'));
return;
}
}
function getLogSymbol(color) {
return chalk.enabled ? color.print(color.symbol) : stripAnsi(color.symbol);
}
/**
* Returns a unified diff between two strings.
*
* @param {Error} err with actual/expected
* @return {string} The diff.
*/
function unifiedDiff(err) {
var indent = ' ';
function cleanUp(line) {
if (line[0] === '+') {
return indent + colors.success.print(line);
}
if (line[0] === '-') {
return indent + colors.error.print(line);
}
if (line.match(/\@\@/)) {
return null;
}
if (line.match(/\\ No newline/)) {
return null;
}
return indent + line;
}
function notBlank(line) {
return line !== null;
}
var msg = diff.createPatch('string', err.actual, err.expected);
var lines = msg.split('\n').splice(4);
return '\n ' +
colors.success.print('+ expected') + ' ' +
colors.error.print('- actual') +
'\n\n' +
lines.map(cleanUp).filter(notBlank).join('\n');
}
/**
* Return a character diff for `err`.
*
* @param {Error} err
* @param {string} type
* @return {string}
*/
function errorDiff(err, type) {
var actual = err.actual;
var expected = err.expected;
return diff['diff' + type](actual, expected).map(function (str) {
if (str.added) {
return colors.success.print(str.value);
}
if (str.removed) {
return colors.error.print(str.value);
}
return str.value;
}).join('');
}
/**
* Pad the given `str` to `len`.
*
* @param {string} str
* @param {string} len
* @return {string}
*/
function pad(str, len) {
str = String(str);
return Array(len - str.length + 1).join(' ') + str;
}
/**
* Returns an inline diff between 2 strings with coloured ANSI output
*
* @param {Error} err with actual/expected
* @return {string} Diff
*/
function inlineDiff(err) {
var msg = errorDiff(err, 'WordsWithSpace');
// linenos
var lines = msg.split('\n');
if (lines.length > 4) {
var width = String(lines.length).length;
msg = lines.map(function (str, i) {
return pad(++i, width) + ' |' + ' ' + str;
}).join('\n');
}
// legend
msg = '\n' +
colors.success.print('expected') +
' ' +
colors.error.print('actual') +
'\n\n' +
msg +
'\n';
// indent
msg = msg.replace(/^/gm, ' ');
return msg;
}
/**
* Returns a formatted time interval
*
* @param {!number} time The time.
* @returns {string}
*/
function formatTimeInterval(time) {
var mins = Math.floor(time / 60000);
var secs = (time - mins * 60000) / 1000;
var str = secs + (secs === 1 ? ' sec' : ' secs');
if (mins) {
str = mins + (mins === 1 ? ' min ' : ' mins ') + str;
}
return str;
}
/**
* Checks if all items are completed
*
* @param {object} items The item objects
* @returns {boolean}
*/
function allChildItemsAreCompleted(items) {
var item;
var isCompleted = true;
Object.keys(items).forEach(function (key) {
item = items[key];
if (item.type === 'it') {
isCompleted = isCompleted && item.isCompleted;
} else if (item.items) {
// recursive check of child items
isCompleted = isCompleted && allChildItemsAreCompleted(item.items);
}
});
return isCompleted;
}
/**
* Prints a single item
*
* @param {!object} item The item to print
* @param {number} depth The depth
*/
function printItem(item, depth) {
// only print to output once
if (item.name && !item.printed && (!item.skipped || !ignoreSkipped)) {
// only print it block when it was ran through all browsers
if (item.type === 'it' && !item.isCompleted) {
return;
}
// indent
var line = repeatString(' ', depth) + item.name.replace(internalPrefix, '');
// it block
if (item.type === 'it') {
if (item.skipped) {
// print skipped tests info
line = colors.info.print(stripAnsi(line) + ' (skipped)');
} else {
// set color to success or error
line = item.success ? colors.success.print(line) : colors.error.print(line);
}
} else {
// print name of a suite block in bold
line = chalk.bold(line);
}
// use write method of baseReporter
self.write(line + '\n');
// set item as printed
item.printed = true;
}
}
/**
* Writes the test results to the output
*
* @param {!object} suite The test suite
* @param {number=} depth The indention.
*/
function print(suite, depth) {
var keys = Object.keys(suite);
var length = keys.length;
var i, item;
for (i = 0; i < length; i++) {
item = suite[keys[i]];
// start of a new suite
if (item.isRoot) {
depth = 1;
}
if (item.items) {
var allChildItemsCompleted = allChildItemsAreCompleted(item.items);
if (allChildItemsCompleted) {
// print current item because all children are completed
printItem(item, depth);
// print all child items
print(item.items, depth + 1);
}
} else {
// print current item which has no children
printItem(item, depth);
}
}
}
/**
* Writes the failed test to the output
*
* @param {!object} suite The test suite
* @param {number=} depth The indention.
*/
function printFailures(suite, depth) {
var keys = Object.keys(suite);
var length = keys.length;
var i, item;
for (i = 0; i < length; i++) {
item = suite[keys[i]];
// start of a new suite
if (item.isRoot) {
depth = 1;
}
// only print to output when test failed
if (item.name && !item.success && !item.skipped) {
// indent
var line = repeatString(' ', depth) + item.name.replace(internalPrefix, '');
// it block
if (item.type === 'it') {
// make item name error
line = colors.error.print(line) + '\n';
// add all browser in which the test failed with color warning
for (var bi = 0; bi < item.failed.length; bi++) {
var browserName = item.failed[bi];
line += repeatString(' ', depth + 1) + chalk.italic(colors.warning.print(browserName)) + '\n';
}
// add the error log in error color
item.log = item.log || [];
var log = item.log.length ? item.log[0].split('\n') : [];
var linesToLog = config.mochaReporter.maxLogLines;
var ii = 0;
// set number of lines to output
if (log.length < linesToLog) {
linesToLog = log.length;
}
// print diff
if (config.mochaReporter.showDiff && item.assertionErrors && item.assertionErrors[0]) {
var errorMessage = log.splice(0, 1)[0];
// print error message before diff
line += colors.error.print(repeatString(' ', depth) + errorMessage + '\n');
var expected = item.assertionErrors[0].expected;
var actual = item.assertionErrors[0].actual;
var utils = mocha.utils;
var err = {
actual: actual,
expected: expected
};
if (String(err.actual).match(/^".*"$/) && String(err.expected).match(/^".*"$/)) {
try {
err.actual = JSON.parse(err.actual);
err.expected = JSON.parse(err.expected);
} catch (e) { }
}
// ensure that actual and expected are strings
if (!(utils.isString(actual) && utils.isString(expected))) {
err.actual = utils.stringify(actual);
err.expected = utils.stringify(expected);
}
// create diff
var diff = config.mochaReporter.showDiff === 'inline' ? inlineDiff(err) : unifiedDiff(err);
line += diff + '\n';
// print formatted stack trace after diff
for (ii; ii < linesToLog; ii++) {
line += colors.error.print(formatError(log[ii]));
}
} else {
for (ii; ii < linesToLog; ii++) {
line += colors.error.print(formatError(log[ii], repeatString(' ', depth)));
}
}
}
// use write method of baseReporter
self.write(line + '\n');
}
if (item.items) {
// print all child items
printFailures(item.items, depth + 1);
}
}
}
/**
* Returns a singularized or plularized noun for "test" based on test count
*
* @param {!Number} testCount
* @returns {String}
*/
function getTestNounFor(testCount) {
if (testCount === 1) {
return 'test';
}
return 'tests';
}
/**
* Called each time a test is completed in a given browser.
*
* @param {!object} browser The current browser.
* @param {!object} result The result of the test.
*/
function specComplete(browser, result) {
// complete path of the test
var path = [].concat(result.suite, result.description);
var maxDepth = path.length - 1;
path.reduce(function (suite, description, depth) {
// add prefix to description to prevent errors when the description is a reserved name (e.g. 'toString' or 'hasOwnProperty')
description = internalPrefix + description;
var item;
if (suite.hasOwnProperty(description) && suite[description].type === 'it' && self.numberOfBrowsers === 1) {
item = {};
description += ' ';
} else {
item = suite[description] || {};
}
suite[description] = item;
item.name = description;
item.isRoot = depth === 0;
item.type = 'describe';
item.skipped = result.skipped;
item.success = (item.success === undefined ? true : item.success) && result.success;
// set item success to true when item is skipped
if (item.skipped) {
item.success = true;
}
// it block
if (depth === maxDepth) {
item.type = 'it';
item.count = item.count || 0;
item.count++;
item.failed = item.failed || [];
item.success = result.success && item.success;
item.name = (item.success ? getLogSymbol(colors.success) : getLogSymbol(colors.error)) + ' ' + item.name;
item.skipped = result.skipped;
item.visited = item.visited || [];
item.visited.push(browser.name);
self.netTime += result.time;
if (result.skipped) {
self.numberOfSkippedTests++;
}
if (result.success === false) {
// add browser to failed browsers array
item.failed.push(browser.name);
// add error log
item.log = result.log;
// add assertion errors if available (currently in karma-mocha)
item.assertionErrors = result.assertionErrors;
}
if (config.reportSlowerThan && result.time > config.reportSlowerThan) {
// add slow report warning
item.name += colors.warning.print((' (slow: ' + formatTimeInterval(result.time) + ')'));
self.numberOfSlowTests++;
}
if (item.count === self.numberOfBrowsers || config.mochaReporter.printFirstSuccess) {
item.isCompleted = true;
// print results to output when test was ran through all browsers
if (outputMode !== 'minimal') {
print(self.allResults, depth);
}
}
} else {
item.items = item.items || {};
}
return item.items;
}, self.allResults);
}
self.specSuccess = specComplete;
self.specSkipped = specComplete;
self.specFailure = specComplete;
self.onSpecComplete = function (browser, result) {
specComplete(browser, result);
};
self.onRunStart = function () {
if (!firstRun && divider) {
self.write('\n' + chalk.bold(divider) + '\n');
}
firstRun = false;
isRunCompleted = false;
self.write('\n' + chalk.underline.bold('START:') + '\n');
self._browsers = [];
self.allResults = {};
self.totalTime = 0;
self.netTime = 0;
self.numberOfSlowTests = 0;
self.numberOfSkippedTests = 0;
self.numberOfBrowsers = (config.browsers || []).length || 1;
};
self.onBrowserStart = function (browser) {
self._browsers.push(browser);
};
self.onRunComplete = function (browsers, results) {
browsers.forEach(function (browser) {
self.totalTime += browser.lastResult.totalTime;
});
// print extra error message for some special cases, e.g. when having the error "Some of your tests did a full page reload!" the onRunComplete() method is called twice
if (results.error && isRunCompleted) {
self.write('\n');
self.write(getLogSymbol(colors.error) + colors.error.print(' Error while running the tests! Exit code: ' + results.exitCode));
self.write('\n\n');
return;
}
isRunCompleted = true;
self.write('\n' + colors.success.print('Finished in ' + formatTimeInterval(self.totalTime) + ' / ' +
formatTimeInterval(self.netTime) + ' @ ' + new Date().toTimeString()));
self.write('\n\n');
if (browsers.length > 0 && !results.disconnected) {
self.write(chalk.underline.bold('SUMMARY:') + '\n');
self.write(colors.success.print(getLogSymbol(colors.success) + ' ' + results.success + ' ' + getTestNounFor(results.success) + ' completed'));
self.write('\n');
if (self.numberOfSkippedTests > 0) {
self.write(colors.info.print(getLogSymbol(colors.info) + ' ' + self.numberOfSkippedTests + ' ' + getTestNounFor(self.numberOfSkippedTests) + ' skipped'));
self.write('\n');
}
if (self.numberOfSlowTests > 0) {
self.write(colors.warning.print(getLogSymbol(colors.warning) + ' ' + self.numberOfSlowTests + ' ' + getTestNounFor(self.numberOfSlowTests) + ' slow'));
self.write('\n');
}
if (results.failed) {
self.write(colors.error.print(getLogSymbol(colors.error) + ' ' + results.failed + ' ' + getTestNounFor(results.failed) + ' failed'));
self.write('\n');
if (outputMode !== 'noFailures') {
self.write('\n' + chalk.underline.bold('FAILED TESTS:') + '\n');
printFailures(self.allResults);
}
}
}
if (outputMode === 'autowatch') {
outputMode = 'minimal';
}
};
};
// inject karma runner baseReporter and config
MochaReporter.$inject = ['baseReporterDecorator', 'formatError', 'config'];
// PUBLISH DI MODULE
module.exports = {
'reporter:mocha': ['type', MochaReporter]
};