mochawesome
Version:
A gorgeous reporter for Mocha.js
341 lines (308 loc) • 9.95 kB
JavaScript
const { randomUUID } = require('node:crypto');
const { stripVTControlCharacters } = require('node:util');
const { utils: mochaUtils } = require('mocha');
const stringify = require('json-stringify-safe');
const diff = require('diff');
const stripFnStart = require('./stripFnStart');
// Gray the log prefix with a bare ANSI escape instead of `util.styleText`, which
// is only stable on Node >= 22 and was the sole reason this package required
// Node 22. Mirrors the helper in `addContext.js`. Only emit ANSI for a TTY so
// CI logs stay plain.
/* c8 ignore start */
const gray = str =>
typeof process !== 'undefined' && process.stdout?.isTTY
? `\x1b[90m${str}\x1b[39m`
: str;
/* c8 ignore stop */
/**
* 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(`[${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;
/* c8 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
) {
/* c8 ignore start */
if (!(typeof actual === 'string' && typeof expected === 'string')) {
err.actual = mochaUtils.stringify(actual);
err.expected = mochaUtils.stringify(expected);
}
/* c8 ignore stop */
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}: ${stripVTControlCharacters(message)}`;
} else if (stack) {
errMessage = stack.replace(/\n.*/g, '');
}
return {
message: errMessage,
estack: stack && stripVTControlCharacters(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 =
typeof test.fullTitle === 'function'
? stripVTControlCharacters(test.fullTitle())
: stripVTControlCharacters(test.title);
const cleaned = {
title: stripVTControlCharacters(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 || /* c8 ignore next */ randomUUID(),
parentUUID: test.parent && test.parent.uuid,
isHook: test.type === 'hook',
};
cleaned.skipped =
!cleaned.pass && !cleaned.fail && !cleaned.pending && !cleaned.isHook;
return cleaned;
}
/**
* Make a suite's file path relative to the cwd by stripping the cwd prefix.
* Paths that don't start with the cwd (already relative, or run from a
* different root) are returned unchanged.
*
* @param {String} file Suite file path
* @param {String} cwd Directory to strip (defaults to process.cwd())
* @return {String} file relative to cwd
*/
function stripCwd(file, cwd = process.cwd()) {
return file.startsWith(cwd) ? file.slice(cwd.length) : file;
}
/**
* 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 || /* c8 ignore next */ randomUUID(),
title: stripVTControlCharacters(suite.title),
fullFile: suite.file || '',
file: suite.file ? stripCwd(suite.file) : '',
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 =
cleaned.suites.length === 0 &&
cleaned.tests.length === 0 &&
cleaned.beforeHooks.length === 0 &&
cleaned.afterHooks.length === 0;
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);
}
/**
* Build the finalized report stats from mocha's stats: derived percentages
* plus failed-hook (`other`) reconciliation. Returns a new object.
*
* @param {Object} stats mocha stats object
* @param {Array} failures failed runnables collected by the Base reporter
* @param {Object} testTotals Cumulative counts { registered, skipped }
* @return {Object} a new stats object
*/
function getFinalizedStats(stats, failures, testTotals) {
const { passes, failures: totalFailures, pending } = stats;
const { registered, skipped } = testTotals;
// `other` counts failed hooks, which are not test failures.
const hookFailures = (failures || []).filter(
f => f && f.type === 'hook'
).length;
return {
...stats,
testsRegistered: registered,
passPercent: (passes / (registered - pending)) * 100,
pendingPercent: (pending / registered) * 100,
other: hookFailures,
hasOther: hookFailures > 0,
skipped,
hasSkipped: skipped > 0,
failures: totalFailures - hookFailures,
};
}
module.exports = {
log,
cleanCode,
cleanTest,
cleanSuite,
mapSuites,
getFinalizedStats,
stripCwd,
};