From 319776d2413f42c3da13d6a605118cdcf775e541 Mon Sep 17 00:00:00 2001 From: Charley Date: Thu, 5 Feb 2026 20:50:32 -0600 Subject: [PATCH] Report the constituent errors of an AggregateError Fixes #2063 --- spec/core/ExceptionFormatterSpec.js | 167 ++++++++++++++++++ .../integration/ExceptionFormattingSpec.js | 84 +++++++++ src/core/ExceptionFormatter.js | 15 +- 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 spec/core/integration/ExceptionFormattingSpec.js diff --git a/spec/core/ExceptionFormatterSpec.js b/spec/core/ExceptionFormatterSpec.js index 47217fd6..d8c2d94a 100644 --- a/spec/core/ExceptionFormatterSpec.js +++ b/spec/core/ExceptionFormatterSpec.js @@ -346,5 +346,172 @@ describe('ExceptionFormatter', function() { }).not.toThrowError(); }); }); + + describe('when the error has an errors array (AggregateError)', function() { + it('includes all aggregated errors in the stack trace', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const error1 = new Error('first error'); + const error2 = new Error('second error'); + const error3 = new Error('third error'); + const aggregateError = new Error('Multiple errors occurred'); + aggregateError.errors = [error1, error2, error3]; + + const lines = subject.stack(aggregateError).split('\n'); + + const error1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: first error') + ); + expect(error1MsgIx) + .withContext('first error message') + .toBeGreaterThan(-1); + + const error2MsgIx = lines.findIndex(line => + line.includes('Error 2: Error: second error') + ); + expect(error2MsgIx) + .withContext('second error message') + .toBeGreaterThan(error1MsgIx); + + const error3MsgIx = lines.findIndex(line => + line.includes('Error 3: Error: third error') + ); + expect(error3MsgIx) + .withContext('third error message') + .toBeGreaterThan(error2MsgIx); + }); + + it('handles AggregateError with single error', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const error1 = new Error('single error'); + const aggregateError = new Error('One error occurred'); + aggregateError.errors = [error1]; + + const lines = subject.stack(aggregateError).split('\n'); + + const error1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: single error') + ); + expect(error1MsgIx).toBeGreaterThan(-1); + }); + + it('handles empty errors array', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const aggregateError = new Error('No errors'); + aggregateError.errors = []; + + expect(function() { + subject.stack(aggregateError); + }).not.toThrowError(); + }); + + it('handles nested AggregateError', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const innerError1 = new Error('inner error 1'); + const innerError2 = new Error('inner error 2'); + const innerAggregate = new Error('Inner aggregate'); + innerAggregate.errors = [innerError1, innerError2]; + + const outerError = new Error('outer error'); + const outerAggregate = new Error('Outer aggregate'); + outerAggregate.errors = [innerAggregate, outerError]; + + const lines = subject.stack(outerAggregate).split('\n'); + + const innerAggMsgIx = lines.findIndex(line => + line.includes('Error 1: Error: Inner aggregate') + ); + expect(innerAggMsgIx).toBeGreaterThan(-1); + + const innerError1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: inner error 1') + ); + expect(innerError1MsgIx).toBeGreaterThan(innerAggMsgIx); + + const innerError2MsgIx = lines.findIndex(line => + line.includes('Error 2: Error: inner error 2') + ); + expect(innerError2MsgIx).toBeGreaterThan(innerError1MsgIx); + + const outerErrorMsgIx = lines.findIndex(line => + line.includes('Error 2: Error: outer error') + ); + expect(outerErrorMsgIx).toBeGreaterThan(innerError2MsgIx); + }); + + it('handles AggregateError containing error with cause', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const rootCause = new Error('root cause'); + const errorWithCause = new Error('error with cause', { + cause: rootCause + }); + const aggregateError = new Error('Aggregate with cause chain'); + aggregateError.errors = [errorWithCause]; + + const lines = subject.stack(aggregateError).split('\n'); + + const error1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: error with cause') + ); + expect(error1MsgIx).toBeGreaterThan(-1); + + const causeMsgIx = lines.findIndex(line => + line.includes('Caused by: Error: root cause') + ); + expect(causeMsgIx).toBeGreaterThan(error1MsgIx); + }); + + it('skips non-Error items in errors array', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const error1 = new Error('real error'); + const aggregateError = new Error('Mixed array'); + aggregateError.errors = [ + error1, + 'string error', + { message: 'object error' }, + null, + undefined, + 42 + ]; + + const lines = subject.stack(aggregateError).split('\n'); + + const error1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: real error') + ); + expect(error1MsgIx).toBeGreaterThan(-1); + + const hasStringError = lines.some(line => + line.includes('string error') + ); + expect(hasStringError).toBe(false); + + const hasObjectError = lines.some(line => + line.includes('object error') + ); + expect(hasObjectError).toBe(false); + }); + + it('works with native AggregateError constructor', function() { + const subject = new privateUnderTest.ExceptionFormatter(); + const error1 = new Error('first error'); + const error2 = new Error('second error'); + const aggregateError = new AggregateError( + [error1, error2], + 'Multiple errors' + ); + + const lines = subject.stack(aggregateError).split('\n'); + + const error1MsgIx = lines.findIndex(line => + line.includes('Error 1: Error: first error') + ); + expect(error1MsgIx).toBeGreaterThan(-1); + + const error2MsgIx = lines.findIndex(line => + line.includes('Error 2: Error: second error') + ); + expect(error2MsgIx).toBeGreaterThan(error1MsgIx); + }); + }); }); }); diff --git a/spec/core/integration/ExceptionFormattingSpec.js b/spec/core/integration/ExceptionFormattingSpec.js new file mode 100644 index 00000000..f8664228 --- /dev/null +++ b/spec/core/integration/ExceptionFormattingSpec.js @@ -0,0 +1,84 @@ +describe('Exception formatting (integration)', function() { + let env; + + beforeEach(function() { + specHelpers.registerIntegrationMatchers(); + env = new privateUnderTest.Env(); + }); + + afterEach(function() { + env.cleanup_(); + }); + + describe('AggregateError formatting', function() { + it('formats AggregateError with individual errors', async function() { + env.it('should format AggregateError with individual errors', function() { + const errors = [ + new Error('Database connection failed'), + new Error('Invalid configuration'), + new Error('Service unavailable') + ]; + throw new AggregateError(errors, 'Multiple initialization errors'); + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + await env.execute(); + + expect(reporter.specDone).toHaveBeenCalledTimes(1); + const result = reporter.specDone.calls.argsFor(0)[0]; + expect(result.status).toEqual('failed'); + expect(result.failedExpectations.length).toEqual(1); + + const failure = result.failedExpectations[0]; + expect(failure.message).toContain('AggregateError'); + expect(failure.message).toContain('Multiple initialization errors'); + + expect(failure.stack).toContain( + 'Error 1: Error: Database connection failed' + ); + expect(failure.stack).toContain('Error 2: Error: Invalid configuration'); + expect(failure.stack).toContain('Error 3: Error: Service unavailable'); + }); + + it('formats nested AggregateError', async function() { + env.it('should format nested AggregateError', function() { + const innerErrors = [ + new Error('Inner error 1'), + new Error('Inner error 2') + ]; + const innerAggregate = new AggregateError( + innerErrors, + 'Inner operation failed' + ); + + const outerErrors = [ + innerAggregate, + new Error('Outer error'), + new Error('Other outer error') + ]; + throw new AggregateError(outerErrors, 'Multiple operations failed'); + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + await env.execute(); + + expect(reporter.specDone).toHaveBeenCalledTimes(1); + const result = reporter.specDone.calls.argsFor(0)[0]; + expect(result.status).toEqual('failed'); + + const failure = result.failedExpectations[0]; + + // Firefox & Safari don't preserve types for nested errors + expect(failure.stack).toMatch( + /Error 1: (AggregateError|Error): Inner operation failed/ + ); + expect(failure.stack).toContain('Error 2: Error: Outer error'); + expect(failure.stack).toContain('Error 3: Error: Other outer error'); + + expect(failure.stack).toContain('Error 1: Error: Inner error 1'); + expect(failure.stack).toContain('Error 2: Error: Inner error 2'); + }); + }); +}); diff --git a/src/core/ExceptionFormatter.js b/src/core/ExceptionFormatter.js index 12d73645..933a4b00 100644 --- a/src/core/ExceptionFormatter.js +++ b/src/core/ExceptionFormatter.js @@ -11,7 +11,8 @@ getJasmineRequireObj().ExceptionFormatter = function(j$) { 'lineNumber', 'column', 'description', - 'jasmineMessage' + 'jasmineMessage', + 'errors' ]; function ExceptionFormatter(options) { @@ -77,6 +78,18 @@ getJasmineRequireObj().ExceptionFormatter = function(j$) { lines = lines.concat(substack); } + if (Array.isArray(error.errors)) { + error.errors.forEach((aggregatedError, index) => { + if (aggregatedError instanceof Error) { + const substack = this.stack_(aggregatedError, { + messageHandling: 'require' + }); + substack[0] = 'Error ' + (index + 1) + ': ' + substack[0]; + lines = lines.concat(substack); + } + }); + } + return lines; };