From 37358738260cfae7c244c157aee21654f2b588f2 Mon Sep 17 00:00:00 2001 From: Pelle Wessman Date: Mon, 4 Mar 2024 15:27:18 +0100 Subject: [PATCH] feat: include `.cause` stacks in the error stack traces (#4829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Append the cause stacks to the main stack trace It would be great to get the full error stack chain for errors with causes, especially as all current browsers and Node.js >=16 supports it, see eg https://v8.dev/features/error-cause and https://dev.to/voxpelli/pony-cause-1-0-error-causes-2l2o Eg. `pino` merged support for this as well: https://github.com/pinojs/pino-std-serializers/pull/78 * Fix tests * Skip some string concatenation * Don't export needlessly + improve docs * Improved recursive filtering * Added loop protection * Same logic for "message" in cause trail as in top * Apply suggestions from code review Co-authored-by: Josh Goldberg ✨ * Revert "Apply suggestions from code review" This reverts commit 04f700820e91a5b2edad8dd23ac3ee1d89ab2973. --------- Co-authored-by: Josh Goldberg ✨ --- lib/reporters/base.js | 70 ++++++++++++++++++------- lib/runner.js | 21 ++++++-- test/reporters/base.spec.js | 102 ++++++++++++++++++++++++++++++++++++ test/unit/runner.spec.js | 34 ++++++++++++ 4 files changed, 203 insertions(+), 24 deletions(-) diff --git a/lib/reporters/base.js b/lib/reporters/base.js index 40b5996461..5af6e7bd8a 100644 --- a/lib/reporters/base.js +++ b/lib/reporters/base.js @@ -221,6 +221,56 @@ var generateDiff = (exports.generateDiff = function (actual, expected) { } }); +/** + * Traverses err.cause and returns all stack traces + * + * @private + * @param {Error} err + * @param {Set} [seen] + * @return {{ message: string, msg: string, stack: string }} + */ +var getFullErrorStack = function (err, seen) { + if (seen && seen.has(err)) { + return { message: '', msg: '', 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. * @@ -241,7 +291,6 @@ exports.list = function (failures) { color('error stack', '\n%s\n'); // msg - var msg; var err; if (test.err && test.err.multiple) { if (multipleTest !== test) { @@ -252,25 +301,8 @@ exports.list = function (failures) { } else { err = test.err; } - 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 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); - } + var { message, msg, stack } = getFullErrorStack(err); // uncaught if (err.uncaught) { diff --git a/lib/runner.js b/lib/runner.js index 12807725fb..60a19f0e3f 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -443,11 +443,22 @@ Runner.prototype.fail = function (test, err, force) { err = thrown2Error(err); } - try { - err.stack = - this.fullStackTrace || !err.stack ? err.stack : stackFilter(err.stack); - } catch (ignore) { - // some environments do not take kindly to monkeying with the stack + // Filter the stack traces + if (!this.fullStackTrace) { + const alreadyFiltered = new Set(); + let currentErr = err; + + while (currentErr && currentErr.stack && !alreadyFiltered.has(currentErr)) { + alreadyFiltered.add(currentErr); + + try { + currentErr.stack = stackFilter(currentErr.stack); + } catch (ignore) { + // some environments do not take kindly to monkeying with the stack + } + + currentErr = currentErr.cause; + } } this.emit(constants.EVENT_TEST_FAIL, test, err); diff --git a/test/reporters/base.spec.js b/test/reporters/base.spec.js index 6b30b2ccc7..84778f3693 100644 --- a/test/reporters/base.spec.js +++ b/test/reporters/base.spec.js @@ -491,6 +491,108 @@ describe('Base reporter', function () { expect(errOut, 'to be', '1) test title:\n Error\n foo\n bar'); }); + describe('error causes', function () { + it('should append any error cause trail to stack traces', function () { + var err = { + message: 'Error', + stack: 'Error\nfoo\nbar', + showDiff: false, + cause: { + message: 'Cause1', + stack: 'Cause1\nbar\nfoo', + showDiff: false, + cause: { + message: 'Cause2', + stack: 'Cause2\nabc\nxyz', + showDiff: false + } + } + }; + var test = makeTest(err); + + list([test]); + + var errOut = stdout.join('\n').trim(); + expect( + errOut, + 'to be', + '1) test title:\n Error\n foo\n bar\n Caused by: Cause1\n bar\n foo\n Caused by: Cause2\n abc\n xyz' + ); + }); + + it('should not get stuck in a hypothetical circular error cause trail', function () { + var err1 = { + message: 'Error', + stack: 'Error\nfoo\nbar', + showDiff: false, + }; + var err2 = { + message: 'Cause1', + stack: 'Cause1\nbar\nfoo', + showDiff: false, + cause: err1 + } + err1.cause = err2; + + var test = makeTest(err1); + + list([test]); + + var errOut = stdout.join('\n').trim(); + expect( + errOut, + 'to be', + '1) test title:\n Error\n foo\n bar\n Caused by: Cause1\n bar\n foo\n Caused by: ' + ); + }); + + it("should set an empty cause if neither 'inspect' nor 'message' is set", function () { + var err = { + message: 'Error', + stack: 'Error\nfoo\nbar', + showDiff: false, + cause: { + showDiff: false, + } + }; + + var test = makeTest(err); + + list([test]); + + var errOut = stdout.join('\n').trim(); + expect( + errOut, + 'to be', + '1) test title:\n Error\n foo\n bar\n Caused by:' + ); + }); + + it('should not add cause trail if error does not contain message', function () { + var err = { + message: 'Error', + stack: 'foo\nbar', + showDiff: false, + cause: { + message: 'Cause1', + stack: 'Cause1\nbar\nfoo', + showDiff: false, + cause: { + message: 'Cause2', + stack: 'Cause2\nabc\nxyz', + showDiff: false + } + } + }; + var test = makeTest(err); + + list([test]); + + var errOut = stdout.join('\n').trim(); + expect(errOut, 'to be', '1) test title:\n Error\n foo\n bar'); + }); + }); + it('should list multiple Errors per test', function () { var err = new Error('First Error'); err.multiple = [new Error('Second Error - same test')]; diff --git a/test/unit/runner.spec.js b/test/unit/runner.spec.js index dd96558017..66ca6a0532 100644 --- a/test/unit/runner.spec.js +++ b/test/unit/runner.spec.js @@ -629,6 +629,22 @@ describe('Runner', function () { }); runner.fail(hook, err); }); + + it('should prettify stack-traces in error cause trail', function (done) { + var hook = new Hook(); + hook.parent = suite; + var causeErr = new Error(); + // Fake stack-trace + causeErr.stack = stack.join('\n'); + var err = new Error(); + err.cause = causeErr; + + runner.on(EVENT_TEST_FAIL, function (_hook, _err) { + expect(_err.cause.stack, 'to be', stack.slice(0, 3).join('\n')); + done(); + }); + runner.fail(hook, err); + }); }); describe('long', function () { @@ -647,6 +663,24 @@ describe('Runner', function () { }); runner.fail(hook, err); }); + + it('should display full stack-traces in error cause trail', function (done) { + var hook = new Hook(); + hook.parent = suite; + var causeErr = new Error(); + // Fake stack-trace + causeErr.stack = stack.join('\n'); + var err = new Error(); + err.cause = causeErr; + // Add --stack-trace option + runner.fullStackTrace = true; + + runner.on(EVENT_TEST_FAIL, function (_hook, _err) { + expect(_err.cause.stack, 'to be', stack.join('\n')); + done(); + }); + runner.fail(hook, err); + }); }); describe('ginormous', function () {