diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b781a4a1b0..1ebb0c21cd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Fixes +- `[jest-circus]` Include `Error.cause` in JSON `failureMessages` output ([#15949](https://github.com/jestjs/jest/issues/15949)) +- `[jest-jasmine2]` Include `Error.cause` in JSON `failureMessages` output ([#15949](https://github.com/jestjs/jest/issues/15949)) - `[jest-mock]` Use `Symbol` from test environment ([#15858](https://github.com/jestjs/jest/pull/15858)) - `[jest-reporters]` Fix issue where console output not displayed for GHA reporter even with `silent: false` option ([#15864](https://github.com/jestjs/jest/pull/15864)) - `[jest-runtime]` Fix issue where user cannot utilize dynamic import despite specifying `--experimental-vm-modules` Node option ([#15842](https://github.com/jestjs/jest/pull/15842)) diff --git a/e2e/__tests__/failures.test.ts b/e2e/__tests__/failures.test.ts index 3a9dcfcd840f..8d958f1d4ade 100644 --- a/e2e/__tests__/failures.test.ts +++ b/e2e/__tests__/failures.test.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import {extractSummary, runYarnInstall} from '../Utils'; -import runJest from '../runJest'; +import runJest, {json as runJestJson} from '../runJest'; const dir = path.resolve(__dirname, '../failures'); @@ -113,6 +113,21 @@ test('works with error with cause thrown outside tests', () => { ).toMatchSnapshot(); }); +test('includes error causes in JSON failureMessages', () => { + // Stderr cause coverage is handled by snapshot tests above; this assertion + // targets the structured JSON payload consumed by reporters and integrations. + const {json} = runJestJson(dir, ['errorWithCause.test.js']); + + const result = json.testResults[0]; + const failureMessages = + result.assertionResults.flatMap(result => result.failureMessages) ?? []; + const failureOutput = failureMessages.join('\n'); + + expect(failureMessages).toHaveLength(3); + expect(failureOutput).toContain('[cause]: Error: error during g'); + expect(failureOutput).toContain('[cause]: here is the cause'); +}); + test('errors after test has completed', () => { const {stderr} = runJest(dir, ['errorAfterTestComplete.test.js']); diff --git a/packages/jest-circus/src/__tests__/utils.test.ts b/packages/jest-circus/src/__tests__/utils.test.ts index aeaa15fe6210..4b63a13287e8 100644 --- a/packages/jest-circus/src/__tests__/utils.test.ts +++ b/packages/jest-circus/src/__tests__/utils.test.ts @@ -6,6 +6,27 @@ */ import {runTest} from '../__mocks__/testUtils'; +import {ROOT_DESCRIBE_BLOCK_NAME} from '../state'; +import {makeDescribe, makeSingleTestResult, makeTest} from '../utils'; + +const makeFailedTestResult = (error: Error) => { + const rootDescribe = makeDescribe(ROOT_DESCRIBE_BLOCK_NAME); + const test = makeTest( + () => {}, + undefined, + false, + 'fails with cause', + rootDescribe, + undefined, + new Error('async error'), + false, + ); + + test.errors.push(error); + test.status = 'done'; + + return makeSingleTestResult(test); +}; test('makeTestResults does not thrown a stack overflow exception', () => { let testString = 'describe("top level describe", () => {'; @@ -22,3 +43,30 @@ test('makeTestResults does not thrown a stack overflow exception', () => { expect(stdout.split('\n')).toHaveLength(900_010); }); + +test('makeSingleTestResult serializes nested Error.cause', () => { + const error = new Error('error during f', { + cause: new Error('error during g'), + }); + + const result = makeFailedTestResult(error); + + expect(result.errors[0]).toContain('[cause]: Error: error during g'); +}); + +test('makeSingleTestResult serializes string Error.cause', () => { + const error = new Error('error during f', {cause: 'here is the cause'}); + + const result = makeFailedTestResult(error); + + expect(result.errors[0]).toContain('[cause]: here is the cause'); +}); + +test('makeSingleTestResult protects against circular Error.cause', () => { + const error = new Error('error during f') as Error & {cause?: unknown}; + error.cause = error; + + const result = makeFailedTestResult(error); + + expect(result.errors[0]).toContain('[Circular cause]'); +}); diff --git a/packages/jest-circus/src/utils.ts b/packages/jest-circus/src/utils.ts index 81d477290cdd..de18a9969609 100644 --- a/packages/jest-circus/src/utils.ts +++ b/packages/jest-circus/src/utils.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import {types} from 'node:util'; import * as path from 'path'; import co from 'co'; import dedent from 'dedent'; @@ -438,10 +439,40 @@ const _getError = ( return asyncError; }; +const isErrorOrStackWithCause = ( + errorOrStack: Error | string, +): errorOrStack is Error & {cause: Error | string} => + typeof errorOrStack !== 'string' && + 'cause' in errorOrStack && + (typeof errorOrStack.cause === 'string' || + types.isNativeError(errorOrStack.cause) || + errorOrStack.cause instanceof Error); + +const formatErrorStackWithCause = (error: Error, seen: Set): string => { + const stack = + typeof error.stack === 'string' && error.stack !== '' + ? error.stack + : error.message; + + if (!isErrorOrStackWithCause(error)) { + return stack; + } + + let cause: string; + if (typeof error.cause === 'string') { + cause = error.cause; + } else if (seen.has(error.cause)) { + cause = '[Circular cause]'; + } else { + seen.add(error); + cause = formatErrorStackWithCause(error.cause, seen); + } + + return `${stack}\n\n[cause]: ${cause}`; +}; + const getErrorStack = (error: Error): string => - typeof error.stack === 'string' && error.stack !== '' - ? error.stack - : error.message; + formatErrorStackWithCause(error, new Set()); export const addErrorToEachTestUnderDescribe = ( describeBlock: Circus.DescribeBlock, diff --git a/packages/jest-jasmine2/src/__tests__/reporter.test.ts b/packages/jest-jasmine2/src/__tests__/reporter.test.ts index 652bed3482af..d7924b39c805 100644 --- a/packages/jest-jasmine2/src/__tests__/reporter.test.ts +++ b/packages/jest-jasmine2/src/__tests__/reporter.test.ts @@ -43,4 +43,58 @@ describe('Jasmine2Reporter', () => { expect(secondResult.ancestorTitles[1]).toBe('child 2'); }); }); + + const extractFailureMessage = (error: Error) => { + const spec = { + description: 'description', + failedExpectations: [ + { + error, + matcherName: '', + message: error.message, + passed: false, + stack: error.stack, + }, + ], + fullName: 'spec with cause', + id: '1', + status: 'failed', + } as any as SpecResult; + + const extracted = ( + reporter as unknown as { + _extractSpecResults: ( + specResult: SpecResult, + ancestorTitles: Array, + ) => {failureMessages: Array}; + } + )._extractSpecResults(spec, []); + + return extracted.failureMessages[0]; + }; + + it('serializes nested Error.cause in failure messages', () => { + const message = extractFailureMessage( + new Error('error during f', {cause: new Error('error during g')}), + ); + + expect(message).toContain('[cause]: Error: error during g'); + }); + + it('serializes string Error.cause in failure messages', () => { + const message = extractFailureMessage( + new Error('error during f', {cause: 'here is the cause'}), + ); + + expect(message).toContain('[cause]: here is the cause'); + }); + + it('protects against circular Error.cause in failure messages', () => { + const error = new Error('error during f') as Error & {cause?: unknown}; + error.cause = error; + + const message = extractFailureMessage(error); + + expect(message).toContain('[Circular cause]'); + }); }); diff --git a/packages/jest-jasmine2/src/reporter.ts b/packages/jest-jasmine2/src/reporter.ts index 4182b370cc3d..1a526a5e7e71 100644 --- a/packages/jest-jasmine2/src/reporter.ts +++ b/packages/jest-jasmine2/src/reporter.ts @@ -5,8 +5,10 @@ * LICENSE file in the root directory of this source tree. */ +import {types} from 'util'; import { type AssertionResult, + type FailedAssertion, type TestResult, createEmptyTestResult, } from '@jest/test-result'; @@ -18,6 +20,38 @@ import type {Reporter, RunDetails} from './types'; type Microseconds = number; +const isErrorWithCause = ( + error: unknown, +): error is Error & {cause: Error | string} => + (types.isNativeError(error) || error instanceof Error) && + 'cause' in error && + (typeof error.cause === 'string' || + types.isNativeError(error.cause) || + error.cause instanceof Error); + +const formatErrorStackWithCause = (error: Error, seen: Set): string => { + const stack = + typeof error.stack === 'string' && error.stack !== '' + ? error.stack + : error.message; + + if (!isErrorWithCause(error)) { + return stack; + } + + let cause: string; + if (typeof error.cause === 'string') { + cause = error.cause; + } else if (seen.has(error.cause)) { + cause = '[Circular cause]'; + } else { + seen.add(error); + cause = formatErrorStackWithCause(error.cause, seen); + } + + return `${stack}\n\n[cause]: ${cause}`; +}; + export default class Jasmine2Reporter implements Reporter { private readonly _testResults: Array; private readonly _globalConfig: Config.GlobalConfig; @@ -125,6 +159,19 @@ export default class Jasmine2Reporter implements Reporter { return stack; } + private _getFailureMessage(failed: FailedAssertion): string { + const message = + !failed.matcherName && typeof failed.stack === 'string' + ? this._addMissingMessageToStack(failed.stack, failed.message) + : failed.message || ''; + + if (isErrorWithCause(failed.error)) { + return formatErrorStackWithCause(failed.error, new Set()); + } + + return message; + } + private _extractSpecResults( specResult: SpecResult, ancestorTitles: Array, @@ -155,10 +202,7 @@ export default class Jasmine2Reporter implements Reporter { }; for (const failed of specResult.failedExpectations) { - const message = - !failed.matcherName && typeof failed.stack === 'string' - ? this._addMissingMessageToStack(failed.stack, failed.message) - : failed.message || ''; + const message = this._getFailureMessage(failed); results.failureMessages.push(message); results.failureDetails.push(failed); }