mochawesome
Version:
A gorgeous reporter for Mocha.js
286 lines (258 loc) • 8.01 kB
JavaScript
const isString = require('lodash.isstring');
const isFunction = require('lodash.isfunction');
const isEmpty = require('lodash.isempty');
const chalk = require('chalk');
const uuid = require('uuid');
const mochaUtils = require('mocha/lib/utils');
const stringify = require('json-stringify-safe');
const diff = require('diff');
const stripAnsi = require('strip-ansi');
const stripFnStart = require('./stripFnStart');
/**
* Logger utility
*
* @param {String} msg - message to log
* @param {String} level - log level [log, info, warn, error]
* @param {Object} config - configuration object
*/
function log(msg, level, config) {
// Don't log messages in quiet mode
if (config && config.quiet) return;
const logMethod = console[level] || console.log;
let out = msg;
if (typeof msg === 'object') {
out = stringify(msg, null, 2);
}
logMethod(`[${chalk.gray('mochawesome')}] ${out}\n`);
}
/**
* Strip the function definition from `str`,
* and re-indent for pre whitespace.
*
* @param {String} str - code in
*
* @return {String} cleaned code string
*/
function cleanCode(str) {
str = str
.replace(/\r\n|[\r\n\u2028\u2029]/g, '\n') // unify linebreaks
.replace(/^\uFEFF/, ''); // replace zero-width no-break space
str = stripFnStart(str) // replace function declaration
.replace(/\)\s*\)\s*$/, ')') // replace closing paren
.replace(/\s*};?\s*$/, ''); // replace closing bracket
// Preserve indentation by finding leading tabs/spaces
// and removing that amount of space from each line
const spaces = str.match(/^\n?( *)/)[1].length;
const tabs = str.match(/^\n?(\t*)/)[1].length;
/* istanbul ignore next */
const indentRegex = new RegExp(
`^\n?${tabs ? '\t' : ' '}{${tabs || spaces}}`,
'gm'
);
str = str.replace(indentRegex, '').trim();
return str;
}
/**
* Create a unified diff between two strings
*
* @param {Error} err Error object
* @param {string} err.actual Actual result returned
* @param {string} err.expected Result expected
*
* @return {string} diff
*/
function createUnifiedDiff({ actual, expected }) {
return diff
.createPatch('string', actual, expected)
.split('\n')
.splice(4)
.map(line => {
if (line.match(/@@/)) {
return null;
}
if (line.match(/\\ No newline/)) {
return null;
}
return line.replace(/^(-|\+)/, '$1 ');
})
.filter(line => typeof line !== 'undefined' && line !== null)
.join('\n');
}
/**
* Create an inline diff between two strings
*
* @param {Error} err Error object
* @param {string} err.actual Actual result returned
* @param {string} err.expected Result expected
*
* @return {array} diff string objects
*/
function createInlineDiff({ actual, expected }) {
return diff.diffWordsWithSpace(actual, expected);
}
/**
* Return a normalized error object
*
* @param {Error} err Error object
*
* @return {Object} normalized error
*/
function normalizeErr(err, config) {
const { name, message, actual, expected, stack, showDiff } = err;
let errMessage;
let errDiff;
/**
* Check that a / b have the same type.
*/
function sameType(a, b) {
const objToString = Object.prototype.toString;
return objToString.call(a) === objToString.call(b);
}
// Format actual/expected for creating diff
if (
showDiff !== false &&
sameType(actual, expected) &&
expected !== undefined
) {
/* istanbul ignore if */
if (!(isString(actual) && isString(expected))) {
err.actual = mochaUtils.stringify(actual);
err.expected = mochaUtils.stringify(expected);
}
errDiff = config.useInlineDiffs
? createInlineDiff(err)
: createUnifiedDiff(err);
}
// Assertion libraries do not output consitent error objects so in order to
// get a consistent message object we need to create it ourselves
if (name && message) {
errMessage = `${name}: ${stripAnsi(message)}`;
} else if (stack) {
errMessage = stack.replace(/\n.*/g, '');
}
return {
message: errMessage,
estack: stack && stripAnsi(stack),
diff: errDiff,
};
}
/**
* Return a plain-object representation of `test`
* free of cyclic properties etc.
*
* @param {Object} test
*
* @return {Object} cleaned test
*/
function cleanTest(test, config) {
const code = config.code ? test.body || '' : '';
const fullTitle = isFunction(test.fullTitle)
? stripAnsi(test.fullTitle())
: stripAnsi(test.title);
const cleaned = {
title: stripAnsi(test.title),
fullTitle,
timedOut: test.timedOut,
duration: test.duration || 0,
state: test.state,
speed: test.speed,
pass: test.state === 'passed',
fail: test.state === 'failed',
pending: test.pending,
context: stringify(test.context, null, 2),
code: code && cleanCode(code),
err: (test.err && normalizeErr(test.err, config)) || {},
uuid: test.uuid || /* istanbul ignore next: default */ uuid.v4(),
parentUUID: test.parent && test.parent.uuid,
isHook: test.type === 'hook',
};
cleaned.skipped =
!cleaned.pass && !cleaned.fail && !cleaned.pending && !cleaned.isHook;
return cleaned;
}
/**
* Return a plain-object representation of `suite` with additional properties for rendering.
*
* @param {Object} suite
* @param {Object} testTotals Cumulative count of tests registered/skipped
* @param {Integer} testTotals.registered
* @param {Integer} testTotals.skipped
*
* @return {Object|boolean} cleaned suite or false if suite is empty
*/
function cleanSuite(suite, testTotals, config) {
let duration = 0;
const passingTests = [];
const failingTests = [];
const pendingTests = [];
const skippedTests = [];
const beforeHooks = []
.concat(suite._beforeAll, suite._beforeEach)
.map(test => cleanTest(test, config));
const afterHooks = []
.concat(suite._afterAll, suite._afterEach)
.map(test => cleanTest(test, config));
const tests = suite.tests.map(test => {
const cleanedTest = cleanTest(test, config);
duration += test.duration || 0;
if (cleanedTest.state === 'passed') passingTests.push(cleanedTest.uuid);
if (cleanedTest.state === 'failed') failingTests.push(cleanedTest.uuid);
if (cleanedTest.pending) pendingTests.push(cleanedTest.uuid);
if (cleanedTest.skipped) skippedTests.push(cleanedTest.uuid);
return cleanedTest;
});
testTotals.registered += tests.length;
testTotals.skipped += skippedTests.length;
const cleaned = {
uuid: suite.uuid || /* istanbul ignore next: default */ uuid.v4(),
title: stripAnsi(suite.title),
fullFile: suite.file || '',
file: suite.file ? suite.file.replace(process.cwd(), '') : '',
beforeHooks,
afterHooks,
tests,
suites: suite.suites,
passes: passingTests,
failures: failingTests,
pending: pendingTests,
skipped: skippedTests,
duration,
root: suite.root,
rootEmpty: suite.root && tests.length === 0,
_timeout: suite._timeout,
};
const isEmptySuite =
isEmpty(cleaned.suites) &&
isEmpty(cleaned.tests) &&
isEmpty(cleaned.beforeHooks) &&
isEmpty(cleaned.afterHooks);
return !isEmptySuite && cleaned;
}
/**
* Map over a suite, returning a cleaned suite object
* and recursively cleaning any nested suites.
*
* @param {Object} suite Suite to map over
* @param {Object} testTotals Cumulative count of tests registered/skipped
* @param {Integer} testTotals.registered
* @param {Integer} testTotals.skipped
* @param {Object} config Reporter configuration
*/
function mapSuites(suite, testTotals, config) {
const suites = suite.suites.reduce((acc, subSuite) => {
const mappedSuites = mapSuites(subSuite, testTotals, config);
if (mappedSuites) {
acc.push(mappedSuites);
}
return acc;
}, []);
const toBeCleaned = { ...suite, suites };
return cleanSuite(toBeCleaned, testTotals, config);
}
module.exports = {
log,
cleanCode,
cleanTest,
cleanSuite,
mapSuites,
};