Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 16 additions & 1 deletion e2e/__tests__/failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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']);

Expand Down
48 changes: 48 additions & 0 deletions packages/jest-circus/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {';
Expand All @@ -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]');
});
37 changes: 34 additions & 3 deletions packages/jest-circus/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Error>): 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,
Expand Down
54 changes: 54 additions & 0 deletions packages/jest-jasmine2/src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
) => {failureMessages: Array<string>};
}
)._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]');
});
});
52 changes: 48 additions & 4 deletions packages/jest-jasmine2/src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Error>): 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<AssertionResult>;
private readonly _globalConfig: Config.GlobalConfig;
Expand Down Expand Up @@ -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<string>,
Expand Down Expand Up @@ -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);
}
Expand Down
Loading