Skip to content

Commit 3735873

Browse files
feat: include .cause stacks in the error stack traces (#4829)
* 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: pinojs/pino-std-serializers#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 ✨ <[email protected]> * Revert "Apply suggestions from code review" This reverts commit 04f7008. --------- Co-authored-by: Josh Goldberg ✨ <[email protected]>
1 parent b88978d commit 3735873

File tree

4 files changed

+203
-24
lines changed

4 files changed

+203
-24
lines changed

lib/reporters/base.js

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,56 @@ var generateDiff = (exports.generateDiff = function (actual, expected) {
221221
}
222222
});
223223

224+
/**
225+
* Traverses err.cause and returns all stack traces
226+
*
227+
* @private
228+
* @param {Error} err
229+
* @param {Set<Error>} [seen]
230+
* @return {{ message: string, msg: string, stack: string }}
231+
*/
232+
var getFullErrorStack = function (err, seen) {
233+
if (seen && seen.has(err)) {
234+
return { message: '', msg: '<circular>', stack: '' };
235+
}
236+
237+
var message;
238+
239+
if (typeof err.inspect === 'function') {
240+
message = err.inspect() + '';
241+
} else if (err.message && typeof err.message.toString === 'function') {
242+
message = err.message + '';
243+
} else {
244+
message = '';
245+
}
246+
247+
var msg;
248+
var stack = err.stack || message;
249+
var index = message ? stack.indexOf(message) : -1;
250+
251+
if (index === -1) {
252+
msg = message;
253+
} else {
254+
index += message.length;
255+
msg = stack.slice(0, index);
256+
// remove msg from stack
257+
stack = stack.slice(index + 1);
258+
259+
if (err.cause) {
260+
seen = seen || new Set();
261+
seen.add(err);
262+
const causeStack = getFullErrorStack(err.cause, seen)
263+
stack += '\n Caused by: ' + causeStack.msg + (causeStack.stack ? '\n' + causeStack.stack : '');
264+
}
265+
}
266+
267+
return {
268+
message,
269+
msg,
270+
stack
271+
};
272+
};
273+
224274
/**
225275
* Outputs the given `failures` as a list.
226276
*
@@ -241,7 +291,6 @@ exports.list = function (failures) {
241291
color('error stack', '\n%s\n');
242292

243293
// msg
244-
var msg;
245294
var err;
246295
if (test.err && test.err.multiple) {
247296
if (multipleTest !== test) {
@@ -252,25 +301,8 @@ exports.list = function (failures) {
252301
} else {
253302
err = test.err;
254303
}
255-
var message;
256-
if (typeof err.inspect === 'function') {
257-
message = err.inspect() + '';
258-
} else if (err.message && typeof err.message.toString === 'function') {
259-
message = err.message + '';
260-
} else {
261-
message = '';
262-
}
263-
var stack = err.stack || message;
264-
var index = message ? stack.indexOf(message) : -1;
265304

266-
if (index === -1) {
267-
msg = message;
268-
} else {
269-
index += message.length;
270-
msg = stack.slice(0, index);
271-
// remove msg from stack
272-
stack = stack.slice(index + 1);
273-
}
305+
var { message, msg, stack } = getFullErrorStack(err);
274306

275307
// uncaught
276308
if (err.uncaught) {

lib/runner.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -443,11 +443,22 @@ Runner.prototype.fail = function (test, err, force) {
443443
err = thrown2Error(err);
444444
}
445445

446-
try {
447-
err.stack =
448-
this.fullStackTrace || !err.stack ? err.stack : stackFilter(err.stack);
449-
} catch (ignore) {
450-
// some environments do not take kindly to monkeying with the stack
446+
// Filter the stack traces
447+
if (!this.fullStackTrace) {
448+
const alreadyFiltered = new Set();
449+
let currentErr = err;
450+
451+
while (currentErr && currentErr.stack && !alreadyFiltered.has(currentErr)) {
452+
alreadyFiltered.add(currentErr);
453+
454+
try {
455+
currentErr.stack = stackFilter(currentErr.stack);
456+
} catch (ignore) {
457+
// some environments do not take kindly to monkeying with the stack
458+
}
459+
460+
currentErr = currentErr.cause;
461+
}
451462
}
452463

453464
this.emit(constants.EVENT_TEST_FAIL, test, err);

test/reporters/base.spec.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,108 @@ describe('Base reporter', function () {
491491
expect(errOut, 'to be', '1) test title:\n Error\n foo\n bar');
492492
});
493493

494+
describe('error causes', function () {
495+
it('should append any error cause trail to stack traces', function () {
496+
var err = {
497+
message: 'Error',
498+
stack: 'Error\nfoo\nbar',
499+
showDiff: false,
500+
cause: {
501+
message: 'Cause1',
502+
stack: 'Cause1\nbar\nfoo',
503+
showDiff: false,
504+
cause: {
505+
message: 'Cause2',
506+
stack: 'Cause2\nabc\nxyz',
507+
showDiff: false
508+
}
509+
}
510+
};
511+
var test = makeTest(err);
512+
513+
list([test]);
514+
515+
var errOut = stdout.join('\n').trim();
516+
expect(
517+
errOut,
518+
'to be',
519+
'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'
520+
);
521+
});
522+
523+
it('should not get stuck in a hypothetical circular error cause trail', function () {
524+
var err1 = {
525+
message: 'Error',
526+
stack: 'Error\nfoo\nbar',
527+
showDiff: false,
528+
};
529+
var err2 = {
530+
message: 'Cause1',
531+
stack: 'Cause1\nbar\nfoo',
532+
showDiff: false,
533+
cause: err1
534+
}
535+
err1.cause = err2;
536+
537+
var test = makeTest(err1);
538+
539+
list([test]);
540+
541+
var errOut = stdout.join('\n').trim();
542+
expect(
543+
errOut,
544+
'to be',
545+
'1) test title:\n Error\n foo\n bar\n Caused by: Cause1\n bar\n foo\n Caused by: <circular>'
546+
);
547+
});
548+
549+
it("should set an empty cause if neither 'inspect' nor 'message' is set", function () {
550+
var err = {
551+
message: 'Error',
552+
stack: 'Error\nfoo\nbar',
553+
showDiff: false,
554+
cause: {
555+
showDiff: false,
556+
}
557+
};
558+
559+
var test = makeTest(err);
560+
561+
list([test]);
562+
563+
var errOut = stdout.join('\n').trim();
564+
expect(
565+
errOut,
566+
'to be',
567+
'1) test title:\n Error\n foo\n bar\n Caused by:'
568+
);
569+
});
570+
571+
it('should not add cause trail if error does not contain message', function () {
572+
var err = {
573+
message: 'Error',
574+
stack: 'foo\nbar',
575+
showDiff: false,
576+
cause: {
577+
message: 'Cause1',
578+
stack: 'Cause1\nbar\nfoo',
579+
showDiff: false,
580+
cause: {
581+
message: 'Cause2',
582+
stack: 'Cause2\nabc\nxyz',
583+
showDiff: false
584+
}
585+
}
586+
};
587+
var test = makeTest(err);
588+
589+
list([test]);
590+
591+
var errOut = stdout.join('\n').trim();
592+
expect(errOut, 'to be', '1) test title:\n Error\n foo\n bar');
593+
});
594+
});
595+
494596
it('should list multiple Errors per test', function () {
495597
var err = new Error('First Error');
496598
err.multiple = [new Error('Second Error - same test')];

test/unit/runner.spec.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,22 @@ describe('Runner', function () {
629629
});
630630
runner.fail(hook, err);
631631
});
632+
633+
it('should prettify stack-traces in error cause trail', function (done) {
634+
var hook = new Hook();
635+
hook.parent = suite;
636+
var causeErr = new Error();
637+
// Fake stack-trace
638+
causeErr.stack = stack.join('\n');
639+
var err = new Error();
640+
err.cause = causeErr;
641+
642+
runner.on(EVENT_TEST_FAIL, function (_hook, _err) {
643+
expect(_err.cause.stack, 'to be', stack.slice(0, 3).join('\n'));
644+
done();
645+
});
646+
runner.fail(hook, err);
647+
});
632648
});
633649

634650
describe('long', function () {
@@ -647,6 +663,24 @@ describe('Runner', function () {
647663
});
648664
runner.fail(hook, err);
649665
});
666+
667+
it('should display full stack-traces in error cause trail', function (done) {
668+
var hook = new Hook();
669+
hook.parent = suite;
670+
var causeErr = new Error();
671+
// Fake stack-trace
672+
causeErr.stack = stack.join('\n');
673+
var err = new Error();
674+
err.cause = causeErr;
675+
// Add --stack-trace option
676+
runner.fullStackTrace = true;
677+
678+
runner.on(EVENT_TEST_FAIL, function (_hook, _err) {
679+
expect(_err.cause.stack, 'to be', stack.join('\n'));
680+
done();
681+
});
682+
runner.fail(hook, err);
683+
});
650684
});
651685

652686
describe('ginormous', function () {

0 commit comments

Comments
 (0)