Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 572dbae

Browse files
authored
fix(jest-circus): preserve Error.cause in JSON failureMessages (#15949) (#15967)
1 parent d6d44e8 commit 572dbae

6 files changed

Lines changed: 201 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- `[expect-utils]` Fix `toStrictEqual` failing on `structuredClone` results due to cross-realm constructor mismatch ([#14011](https://github.com/jestjs/jest/issues/14011))
1515
- `[fake-timers]` Convert `Date` to milliseconds before passing to `@sinonjs/fake-timers` ([#16029](https://github.com/jestjs/jest/pull/16029))
1616
- `[jest-circus]` Prevent crash when `asyncError` is undefined for non-Error throws ([#16003](https://github.com/jestjs/jest/pull/16003))
17+
- `[jest-circus, jest-jasmine2]` Include `Error.cause` in JSON `failureMessages` output ([#15949](https://github.com/jestjs/jest/issues/15949))
1718
- `[jest-runtime]` Improve CJS-from-ESM interop: `__esModule`/Babel default unwrap, broader named-export coverage, and shared CJS singleton across importers ([#16050](https://github.com/jestjs/jest/pull/16050))
1819
- `[jest-runtime]` Load `.js` files with ESM syntax but no `"type":"module"` marker as native ESM ([#16050](https://github.com/jestjs/jest/pull/16050))
1920
- `[jest-runtime]` Fix deadlocks and double-evaluation in concurrent ESM and wasm imports ([#16050](https://github.com/jestjs/jest/pull/16050))

e2e/__tests__/failures.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import * as path from 'path';
99
import {extractSummary, runYarnInstall} from '../Utils';
10-
import runJest from '../runJest';
10+
import runJest, {json as runJestJson} from '../runJest';
1111

1212
const dir = path.resolve(__dirname, '../failures');
1313

@@ -113,6 +113,21 @@ test('works with error with cause thrown outside tests', () => {
113113
).toMatchSnapshot();
114114
});
115115

116+
test('includes error causes in JSON failureMessages', () => {
117+
// Stderr cause coverage is handled by snapshot tests above; this assertion
118+
// targets the structured JSON payload consumed by reporters and integrations.
119+
const {json} = runJestJson(dir, ['errorWithCause.test.js']);
120+
121+
const result = json.testResults[0];
122+
const failureMessages =
123+
result.assertionResults.flatMap(result => result.failureMessages) ?? [];
124+
const failureOutput = failureMessages.join('\n');
125+
126+
expect(failureMessages).toHaveLength(3);
127+
expect(failureOutput).toContain('[cause]: Error: error during g');
128+
expect(failureOutput).toContain('[cause]: here is the cause');
129+
});
130+
116131
test('errors after test has completed', () => {
117132
const {stderr} = runJest(dir, ['errorAfterTestComplete.test.js']);
118133

packages/jest-circus/src/__tests__/utils.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@
66
*/
77

88
import {runTest} from '../__mocks__/testUtils';
9+
import {ROOT_DESCRIBE_BLOCK_NAME} from '../state';
10+
import {makeDescribe, makeSingleTestResult, makeTest} from '../utils';
11+
12+
const makeFailedTestResult = (error: Error) => {
13+
const rootDescribe = makeDescribe(ROOT_DESCRIBE_BLOCK_NAME);
14+
const test = makeTest(
15+
() => {},
16+
undefined,
17+
false,
18+
'fails with cause',
19+
rootDescribe,
20+
undefined,
21+
new Error('async error'),
22+
false,
23+
);
24+
25+
test.errors.push(error);
26+
test.status = 'done';
27+
28+
return makeSingleTestResult(test);
29+
};
930

1031
test('makeTestResults does not thrown a stack overflow exception', () => {
1132
let testString = 'describe("top level describe", () => {';
@@ -22,3 +43,30 @@ test('makeTestResults does not thrown a stack overflow exception', () => {
2243

2344
expect(stdout.split('\n')).toHaveLength(900_010);
2445
});
46+
47+
test('makeSingleTestResult serializes nested Error.cause', () => {
48+
const error = new Error('error during f', {
49+
cause: new Error('error during g'),
50+
});
51+
52+
const result = makeFailedTestResult(error);
53+
54+
expect(result.errors[0]).toContain('[cause]: Error: error during g');
55+
});
56+
57+
test('makeSingleTestResult serializes string Error.cause', () => {
58+
const error = new Error('error during f', {cause: 'here is the cause'});
59+
60+
const result = makeFailedTestResult(error);
61+
62+
expect(result.errors[0]).toContain('[cause]: here is the cause');
63+
});
64+
65+
test('makeSingleTestResult protects against circular Error.cause', () => {
66+
const error = new Error('error during f') as Error & {cause?: unknown};
67+
error.cause = error;
68+
69+
const result = makeFailedTestResult(error);
70+
71+
expect(result.errors[0]).toContain('[Circular cause]');
72+
});

packages/jest-circus/src/utils.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import {types} from 'node:util';
89
import * as path from 'path';
910
import co from 'co';
1011
import dedent from 'dedent';
@@ -441,10 +442,40 @@ const _getError = (
441442
return new Error(`thrown: ${prettyFormat(error, {maxDepth: 3})}`);
442443
};
443444

445+
const isErrorOrStackWithCause = (
446+
errorOrStack: Error | string,
447+
): errorOrStack is Error & {cause: Error | string} =>
448+
typeof errorOrStack !== 'string' &&
449+
'cause' in errorOrStack &&
450+
(typeof errorOrStack.cause === 'string' ||
451+
types.isNativeError(errorOrStack.cause) ||
452+
errorOrStack.cause instanceof Error);
453+
454+
const formatErrorStackWithCause = (error: Error, seen: Set<Error>): string => {
455+
const stack =
456+
typeof error.stack === 'string' && error.stack !== ''
457+
? error.stack
458+
: error.message;
459+
460+
if (!isErrorOrStackWithCause(error)) {
461+
return stack;
462+
}
463+
464+
let cause: string;
465+
if (typeof error.cause === 'string') {
466+
cause = error.cause;
467+
} else if (seen.has(error.cause)) {
468+
cause = '[Circular cause]';
469+
} else {
470+
seen.add(error);
471+
cause = formatErrorStackWithCause(error.cause, seen);
472+
}
473+
474+
return `${stack}\n\n[cause]: ${cause}`;
475+
};
476+
444477
const getErrorStack = (error: Error): string =>
445-
typeof error.stack === 'string' && error.stack !== ''
446-
? error.stack
447-
: error.message;
478+
formatErrorStackWithCause(error, new Set());
448479

449480
export const addErrorToEachTestUnderDescribe = (
450481
describeBlock: Circus.DescribeBlock,

packages/jest-jasmine2/src/__tests__/reporter.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,58 @@ describe('Jasmine2Reporter', () => {
4343
expect(secondResult.ancestorTitles[1]).toBe('child 2');
4444
});
4545
});
46+
47+
const extractFailureMessage = (error: Error) => {
48+
const spec = {
49+
description: 'description',
50+
failedExpectations: [
51+
{
52+
error,
53+
matcherName: '',
54+
message: error.message,
55+
passed: false,
56+
stack: error.stack,
57+
},
58+
],
59+
fullName: 'spec with cause',
60+
id: '1',
61+
status: 'failed',
62+
} as any as SpecResult;
63+
64+
const extracted = (
65+
reporter as unknown as {
66+
_extractSpecResults: (
67+
specResult: SpecResult,
68+
ancestorTitles: Array<string>,
69+
) => {failureMessages: Array<string>};
70+
}
71+
)._extractSpecResults(spec, []);
72+
73+
return extracted.failureMessages[0];
74+
};
75+
76+
it('serializes nested Error.cause in failure messages', () => {
77+
const message = extractFailureMessage(
78+
new Error('error during f', {cause: new Error('error during g')}),
79+
);
80+
81+
expect(message).toContain('[cause]: Error: error during g');
82+
});
83+
84+
it('serializes string Error.cause in failure messages', () => {
85+
const message = extractFailureMessage(
86+
new Error('error during f', {cause: 'here is the cause'}),
87+
);
88+
89+
expect(message).toContain('[cause]: here is the cause');
90+
});
91+
92+
it('protects against circular Error.cause in failure messages', () => {
93+
const error = new Error('error during f') as Error & {cause?: unknown};
94+
error.cause = error;
95+
96+
const message = extractFailureMessage(error);
97+
98+
expect(message).toContain('[Circular cause]');
99+
});
46100
});

packages/jest-jasmine2/src/reporter.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import {types} from 'util';
89
import {
910
type AssertionResult,
11+
type FailedAssertion,
1012
type TestResult,
1113
createEmptyTestResult,
1214
} from '@jest/test-result';
@@ -18,6 +20,38 @@ import type {Reporter, RunDetails} from './types';
1820

1921
type Microseconds = number;
2022

23+
const isErrorWithCause = (
24+
error: unknown,
25+
): error is Error & {cause: Error | string} =>
26+
(types.isNativeError(error) || error instanceof Error) &&
27+
'cause' in error &&
28+
(typeof error.cause === 'string' ||
29+
types.isNativeError(error.cause) ||
30+
error.cause instanceof Error);
31+
32+
const formatErrorStackWithCause = (error: Error, seen: Set<Error>): string => {
33+
const stack =
34+
typeof error.stack === 'string' && error.stack !== ''
35+
? error.stack
36+
: error.message;
37+
38+
if (!isErrorWithCause(error)) {
39+
return stack;
40+
}
41+
42+
let cause: string;
43+
if (typeof error.cause === 'string') {
44+
cause = error.cause;
45+
} else if (seen.has(error.cause)) {
46+
cause = '[Circular cause]';
47+
} else {
48+
seen.add(error);
49+
cause = formatErrorStackWithCause(error.cause, seen);
50+
}
51+
52+
return `${stack}\n\n[cause]: ${cause}`;
53+
};
54+
2155
export default class Jasmine2Reporter implements Reporter {
2256
private readonly _testResults: Array<AssertionResult>;
2357
private readonly _globalConfig: Config.GlobalConfig;
@@ -125,6 +159,19 @@ export default class Jasmine2Reporter implements Reporter {
125159
return stack;
126160
}
127161

162+
private _getFailureMessage(failed: FailedAssertion): string {
163+
const message =
164+
!failed.matcherName && typeof failed.stack === 'string'
165+
? this._addMissingMessageToStack(failed.stack, failed.message)
166+
: failed.message || '';
167+
168+
if (isErrorWithCause(failed.error)) {
169+
return formatErrorStackWithCause(failed.error, new Set());
170+
}
171+
172+
return message;
173+
}
174+
128175
private _extractSpecResults(
129176
specResult: SpecResult,
130177
ancestorTitles: Array<string>,
@@ -155,10 +202,7 @@ export default class Jasmine2Reporter implements Reporter {
155202
};
156203

157204
for (const failed of specResult.failedExpectations) {
158-
const message =
159-
!failed.matcherName && typeof failed.stack === 'string'
160-
? this._addMissingMessageToStack(failed.stack, failed.message)
161-
: failed.message || '';
205+
const message = this._getFailureMessage(failed);
162206
results.failureMessages.push(message);
163207
results.failureDetails.push(failed);
164208
}

0 commit comments

Comments
 (0)