mocha
Version:
Classic, reliable, trusted test framework for Node.js and the browser
658 lines (574 loc) • 14.8 kB
JavaScript
/**
* @typedef {import('../runner.cjs')} Runner
* @typedef {import('../test.js')} Test
* @typedef {import('../types.d.ts').FullErrorStack} FullErrorStack
*/
/**
* @module Base
*/
/**
* Module dependencies.
*/
import * as diff from "diff";
import milliseconds from "ms";
import utils from "../utils.cjs";
import supportsColor from "supports-color";
import Runner from "../runner.cjs";
const { constants } = Runner;
var EVENT_TEST_PASS = constants.EVENT_TEST_PASS;
var EVENT_TEST_FAIL = constants.EVENT_TEST_FAIL;
const isBrowser = utils.isBrowser();
function getBrowserWindowSize() {
if ("innerHeight" in global) {
return [global.innerHeight, global.innerWidth];
}
// In a Web Worker, the DOM Window is not available.
return [640, 480];
}
/**
* Check if both stdio streams are associated with a tty.
*/
var isatty = isBrowser || (process.stdout.isTTY && process.stderr.isTTY);
/**
* Save log references to avoid tests interfering (see GH-3604).
*/
var consoleLog = console.log;
/**
* @abstract
* @description
* All other reporters generally inherit from this reporter.
*/
export class Base {
/**
* Constructs a new `Base` reporter instance.
*
* @public
* @memberof Mocha.reporters
* @param {Runner} runner - Instance triggers reporter actions.
* @param {Object} [options] - runner options
*/
constructor(runner, options) {
var failures = (this.failures = []);
if (!runner) {
throw new TypeError("Missing runner argument");
}
this.options = options || {};
this.runner = runner;
this.stats = runner.stats; // assigned so Reporters keep a closer reference
var maxDiffSizeOpt =
this.options.reporterOption && this.options.reporterOption.maxDiffSize;
if (maxDiffSizeOpt !== undefined && !isNaN(Number(maxDiffSizeOpt))) {
Base.maxDiffSize = Number(maxDiffSizeOpt);
}
runner.on(EVENT_TEST_PASS, function (test) {
if (test.duration > test.slow()) {
test.speed = "slow";
} else if (test.duration > test.slow() / 2) {
test.speed = "medium";
} else {
test.speed = "fast";
}
});
runner.on(EVENT_TEST_FAIL, function (test, err) {
if (Base.showDiff(err)) {
stringifyDiffObjs(err);
}
// more than one error per test
if (test.err && err instanceof Error) {
test.err.multiple = (test.err.multiple || []).concat(err);
} else {
test.err = err;
}
failures.push(test);
});
}
/**
* Outputs common epilogue used by many of the bundled reporters.
*
* @public
* @memberof Mocha.reporters
*/
epilogue() {
var stats = this.stats;
var fmt;
Base.consoleLog();
// passes
fmt =
Base.color("bright pass", " ") +
Base.color("green", " %d passing") +
Base.color("light", " (%s)");
Base.consoleLog(fmt, stats.passes || 0, milliseconds(stats.duration));
// pending
if (stats.pending) {
fmt = Base.color("pending", " ") + Base.color("pending", " %d pending");
Base.consoleLog(fmt, stats.pending);
}
// failures
if (stats.failures) {
fmt = Base.color("fail", " %d failing");
Base.consoleLog(fmt, stats.failures);
Base.list(this.failures);
Base.consoleLog();
}
Base.consoleLog();
}
}
Base.consoleLog = consoleLog;
Base.abstract = true;
/**
* Enable coloring by default, except in the browser interface.
*/
Base.useColors =
!isBrowser &&
(supportsColor.stdout || process.env.MOCHA_COLORS !== undefined);
/**
* Inline diffs instead of +/-
*/
Base.inlineDiffs = false;
/**
* Truncate diffs longer than this value to avoid slow performance
*/
Base.maxDiffSize = 8192;
/**
* Default color map.
*/
Base.colors = {
pass: 90,
fail: 31,
"bright pass": 92,
"bright fail": 91,
"bright yellow": 93,
pending: 36,
suite: 0,
"error title": 0,
"error message": 31,
"error stack": 90,
checkmark: 32,
fast: 90,
medium: 33,
slow: 31,
green: 32,
light: 90,
"diff gutter": 90,
"diff added": 32,
"diff removed": 31,
"diff added inline": "30;42",
"diff removed inline": "30;41",
};
/**
* Default symbol map.
*/
Base.symbols = {
ok: utils.logSymbols.success,
err: utils.logSymbols.error,
dot: ".",
comma: ",",
bang: "!",
};
/**
* Color `str` with the given `type`,
* allowing colors to be disabled,
* as well as user-defined color
* schemes.
*
* @private
* @param {string} type
* @param {string} str
* @return {string}
*/
Base.color = function (type, str) {
if (!Base.useColors) {
return String(str);
}
return "\u001b[" + Base.colors[type] + "m" + str + "\u001b[0m";
};
/**
* Expose term window size, with some defaults for when stderr is not a tty.
*/
Base.window = {
width: 75,
};
if (isatty) {
if (isBrowser) {
Base.window.width = getBrowserWindowSize()[1];
} else {
Base.window.width = process.stdout.getWindowSize(1)[0];
}
}
/**
* Expose some basic cursor interactions that are common among reporters.
*/
Base.cursor = {
hide: function () {
isatty && process.stdout.write("\u001b[?25l");
},
show: function () {
isatty && process.stdout.write("\u001b[?25h");
},
deleteLine: function () {
isatty && process.stdout.write("\u001b[2K");
},
beginningOfLine: function () {
isatty && process.stdout.write("\u001b[0G");
},
CR: function () {
if (isatty) {
Base.cursor.deleteLine();
Base.cursor.beginningOfLine();
} else {
process.stdout.write("\r");
}
},
};
Base.showDiff = function (err) {
return (
err &&
err.showDiff !== false &&
sameType(err.actual, err.expected) &&
err.expected !== undefined
);
};
/**
* Estimate the serialized size of a value.
* Returns -1 if the value is too complex to estimate safely.
*
* @private
* @param {*} val
* @param {number} maxDepth
* @param {Set<*>} [seen]
* @returns {number}
*/
function estimateSize(val, maxDepth, seen) {
if (maxDepth <= 0) return -1;
seen = seen || new Set();
const type = typeof val;
if (type === "string") return val.length;
if (type === "number" || type === "boolean") return 8;
if (val === null || val === undefined) return 4;
// Avoid circular references
if (type === "object") {
if (seen.has(val)) return -1;
seen.add(val);
// Special handling for Buffers - they can be massive
if (typeof Buffer !== "undefined" && Buffer.isBuffer(val)) {
return val.length > 10000 ? -1 : val.length * 2;
}
let size = 2; // {} or []
try {
const keys = Object.keys(val);
if (keys.length > 1000) return -1; // Too many keys
for (const key of keys) {
const childSize = estimateSize(val[key], maxDepth - 1, seen);
if (childSize === -1) return -1;
size += key.length + childSize + 4; // key + value + formatting
if (size > 100000) return -1; // Bail early if getting huge
}
} catch {
return -1;
}
seen.delete(val);
return size;
}
return 50; // Default estimate for other types
}
function stringifyDiffObjs(err) {
if (!utils.isString(err.actual) || !utils.isString(err.expected)) {
// Estimate size before stringifying to avoid hangs
const maxSafeSize = Base.maxDiffSize || 8192;
const actualSize = estimateSize(err.actual, 10);
const expectedSize = estimateSize(err.expected, 10);
if (
actualSize === -1 ||
expectedSize === -1 ||
actualSize > maxSafeSize ||
expectedSize > maxSafeSize
) {
// Values too large/complex - provide safe fallback
err.actual = "[object too large to diff]";
err.expected = "[object too large to diff]";
return;
}
err.actual = utils.stringify(err.actual);
err.expected = utils.stringify(err.expected);
}
}
/**
* Returns a diff between 2 strings with coloured ANSI output.
*
* @description
* The diff will be either inline or unified dependent on the value
* of `Base.inlineDiff`.
*
* @param {string} actual
* @param {string} expected
* @return {string} Diff
*/
Base.generateDiff = function (actual, expected) {
try {
var maxLen = Base.maxDiffSize;
var skipped = 0;
if (maxLen > 0) {
skipped = Math.max(actual.length - maxLen, expected.length - maxLen);
actual = actual.slice(0, maxLen);
expected = expected.slice(0, maxLen);
}
let result = Base.inlineDiffs
? inlineDiff(actual, expected)
: unifiedDiff(actual, expected);
if (skipped > 0) {
result = `${result}\n [mocha] output truncated to ${maxLen} characters, see "maxDiffSize" reporter-option\n`;
}
return result;
} catch {
var msg =
"\n " +
Base.color("diff added", "+ expected") +
" " +
Base.color("diff removed", "- actual: failed to generate Mocha diff") +
"\n";
return msg;
}
};
/**
* Traverses err.cause and returns all stack traces
*
* @private
* @param {Error} err
* @param {Set<Error>} [seen]
* @return {FullErrorStack}
*/
var getFullErrorStack = function (err, seen) {
if (seen && seen.has(err)) {
return { message: "", msg: "<circular>", stack: "" };
}
var message;
if (typeof err.inspect === "function") {
message = err.inspect() + "";
} else if (err.message && typeof err.message.toString === "function") {
message = err.message + "";
} else {
message = "";
}
var msg;
var stack = err.stack || message;
var index = message ? stack.indexOf(message) : -1;
if (index === -1) {
msg = message;
} else {
index += message.length;
msg = stack.slice(0, index);
// remove msg from stack
stack = stack.slice(index + 1);
if (err.cause) {
seen = seen || new Set();
seen.add(err);
const causeStack = getFullErrorStack(err.cause, seen);
stack +=
"\n Caused by: " +
causeStack.msg +
(causeStack.stack ? "\n" + causeStack.stack : "");
}
}
return {
message,
msg,
stack,
};
};
/**
* Outputs the given `failures` as a list.
*
* @public
* @memberof Mocha.reporters.Base
* @variation 1
* @param {Object[]} failures - Each is Test instance with corresponding
* Error property
*/
Base.list = function (failures) {
var multipleErr, multipleTest;
Base.consoleLog();
failures.forEach(function (test, i) {
// format
var fmt =
Base.color("error title", " %s) %s:\n") +
Base.color("error message", " %s") +
Base.color("error stack", "\n%s\n");
// msg
var err;
if (test.err && test.err.multiple) {
if (multipleTest !== test) {
multipleTest = test;
multipleErr = [test.err].concat(test.err.multiple);
}
err = multipleErr.shift();
} else {
err = test.err;
}
var { message, msg, stack } = getFullErrorStack(err);
// uncaught
if (err.uncaught) {
msg = "Uncaught " + msg;
}
// explicitly show diff
if (!Base.hideDiff && Base.showDiff(err)) {
stringifyDiffObjs(err);
fmt =
Base.color("error title", " %s) %s:\n%s") +
Base.color("error stack", "\n%s\n");
var match = message.match(/^([^:]+): expected/);
msg = "\n " + Base.color("error message", match ? match[1] : msg);
msg += Base.generateDiff(err.actual, err.expected);
}
// indent stack trace
stack = stack.replace(/^/gm, " ");
// indented test title
var testTitle = "";
test.titlePath().forEach(function (str, index) {
if (index !== 0) {
testTitle += "\n ";
}
for (var i = 0; i < index; i++) {
testTitle += " ";
}
testTitle += str;
});
Base.consoleLog(fmt, i + 1, testTitle, msg, stack);
});
};
/**
* Pads the given `str` to `len`.
*
* @private
* @param {string} str
* @param {string} len
* @return {string}
*/
function pad(str, len) {
str = String(str);
return Array(len - str.length + 1).join(" ") + str;
}
/**
* Returns inline diff between 2 strings with coloured ANSI output.
*
* @private
* @param {String} actual
* @param {String} expected
* @return {string} Diff
*/
function inlineDiff(actual, expected) {
var msg = errorDiff(actual, expected);
// 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 + 1, width) + " |" + " " + str;
})
.join("\n");
}
// legend
msg =
"\n" +
Base.color("diff removed inline", "actual") +
" " +
Base.color("diff added inline", "expected") +
"\n\n" +
msg +
"\n";
// indent
msg = msg.replace(/^/gm, " ");
return msg;
}
/**
* Returns unified diff between two strings with coloured ANSI output.
*
* @private
* @param {String} actual
* @param {String} expected
* @return {string} The diff.
*/
function unifiedDiff(actual, expected) {
var indent = " ";
function cleanUp(line) {
if (line[0] === "+") {
return indent + colorLines("diff added", line);
}
if (line[0] === "-") {
return indent + colorLines("diff removed", line);
}
if (line.match(/@@/)) {
return "--";
}
if (line.match(/\\ No newline/)) {
return null;
}
return indent + line;
}
function notBlank(line) {
return typeof line !== "undefined" && line !== null;
}
var msg = diff.createPatch("string", actual, expected);
var lines = msg.split("\n").splice(5);
return (
"\n " +
colorLines("diff added", "+ expected") +
" " +
colorLines("diff removed", "- actual") +
"\n\n" +
lines.map(cleanUp).filter(notBlank).join("\n")
);
}
/**
* Returns character diff for `err`.
*
* @private
* @param {String} actual
* @param {String} expected
* @return {string} the diff
*/
function errorDiff(actual, expected) {
return diff
.diffWordsWithSpace(actual, expected)
.map(function (str) {
if (str.added) {
return colorLines("diff added inline", str.value);
}
if (str.removed) {
return colorLines("diff removed inline", str.value);
}
return str.value;
})
.join("");
}
/**
* Colors lines for `str`, using the color `name`.
*
* @private
* @param {string} name
* @param {string} str
* @return {string}
*/
function colorLines(name, str) {
return str
.split("\n")
.map(function (str) {
return Base.color(name, str);
})
.join("\n");
}
/**
* Object#toString reference.
*/
var objToString = Object.prototype.toString;
/**
* Checks that a / b have the same type.
*
* @private
* @param {Object} a
* @param {Object} b
* @return {boolean}
*/
function sameType(a, b) {
return objToString.call(a) === objToString.call(b);
}