Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support error logging before jest retry #12201

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@

- `[babel-jest]` Export `createTransformer` function ([#12399](https://github.com/facebook/jest/pull/12399))
- `[expect]` Expose `AsymmetricMatchers`, `MatcherFunction` and `MatcherFunctionWithState` interfaces ([#12363](https://github.com/facebook/jest/pull/12363), [#12376](https://github.com/facebook/jest/pull/12376))
- `[jest-circus]` Support error logging before retry` ([#12201](https://github.com/facebook/jest/pull/12201))
SimenB marked this conversation as resolved.
Show resolved Hide resolved
- `[jest-circus, jest-jasmine2]` Allowed classes and functions as `describe` and `it`/`test` names ([#12484](https://github.com/facebook/jest/pull/12484))
- `[jest-cli, jest-config]` [**BREAKING**] Remove `testURL` config, use `testEnvironmentOptions.url` instead ([#10797](https://github.com/facebook/jest/pull/10797))
- `[jest-cli, jest-core]` Add `--shard` parameter for distributed parallel test execution ([#12546](https://github.com/facebook/jest/pull/12546))
Expand Down
13 changes: 11 additions & 2 deletions docs/JestObjectAPI.md
Expand Up @@ -831,9 +831,9 @@ Example:
jest.setTimeout(1000); // 1 second
```

### `jest.retryTimes()`
### `jest.retryTimes(numRetries, options)`

Runs failed tests n-times until they pass or until the max number of retries is exhausted. This only works with the default [jest-circus](https://github.com/facebook/jest/tree/main/packages/jest-circus) runner! This must live at the top-level of a test file or in a describe block. Retries _will not_ work if `jest.retryTimes()` is called in a `beforeEach` or a `test` block.
Runs failed tests n-times until they pass or until the max number of retries is exhausted. `options` are optional. This only works with the default [jest-circus](https://github.com/facebook/jest/tree/main/packages/jest-circus) runner! This must live at the top-level of a test file or in a describe block. Retries _will not_ work if `jest.retryTimes()` is called in a `beforeEach` or a `test` block.

Example in a test:

Expand All @@ -844,4 +844,13 @@ test('will fail', () => {
});
```

If `logErrorsBeforeRetry` is enabled, Jest will log the error(s) that caused the test to fail to the console, providing visibility on why a retry occurred.

```js
jest.retryTimes(3, {logErrorsBeforeRetry: true});
test('will fail', () => {
expect(true).toBe(false);
});
```

Returns the `jest` object for chaining.
10 changes: 10 additions & 0 deletions e2e/__tests__/testRetries.test.ts
Expand Up @@ -19,6 +19,8 @@ describe('Test Retries', () => {
'e2e/test-retries/',
outputFileName,
);
const logErrorsBeforeRetryErrorMessage =
'Errors that caused Jest to retry test: ';

afterAll(() => {
fs.unlinkSync(outputFilePath);
Expand All @@ -29,6 +31,14 @@ describe('Test Retries', () => {

expect(result.exitCode).toEqual(0);
expect(result.failed).toBe(false);
expect(result.stdout).not.toContain(logErrorsBeforeRetryErrorMessage);
});

it('logs error(s) before retry', () => {
const result = runJest('test-retries', ['logErrorsBeforeRetries.test.js']);
expect(result.exitCode).toEqual(0);
expect(result.failed).toBe(false);
expect(result.stdout).toContain(logErrorsBeforeRetryErrorMessage);
});

it('reporter shows more than 1 invocation if test is retried', () => {
Expand Down
18 changes: 18 additions & 0 deletions e2e/test-retries/__tests__/logErrorsBeforeRetries.test.js
@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';

let i = 0;
jest.retryTimes(3, {logErrorsBeforeRetry: true});
it('retryTimes set', () => {
i++;
if (i === 2) {
expect(true).toBeTruthy();
} else {
expect(true).toBeFalsy();
}
});
7 changes: 6 additions & 1 deletion packages/jest-circus/src/eventHandler.ts
Expand Up @@ -6,11 +6,12 @@
*/

import type {Circus} from '@jest/types';
import {serialize} from 'jest-serializer';
import {
injectGlobalErrorHandlers,
restoreGlobalErrorHandlers,
} from './globalErrorHandlers';
import {TEST_TIMEOUT_SYMBOL} from './types';
import {LOG_ERRORS_BEFORE_RETRY, TEST_TIMEOUT_SYMBOL} from './types';
import {
addErrorToEachTestUnderDescribe,
describeBlockHasTests,
Expand Down Expand Up @@ -205,6 +206,10 @@ const eventHandler: Circus.EventHandler = (event, state) => {
break;
}
case 'test_retry': {
const logErrorsBeforeRetry = global[LOG_ERRORS_BEFORE_RETRY] || false;
if (logErrorsBeforeRetry) {
event.test.retryReasons.push(serialize(event.test.errors));
}
event.test.errors = [];
break;
}
Expand Down
Expand Up @@ -220,6 +220,7 @@ export const runAndTransformResultsToJestFormat = async ({
invocations: testResult.invocations,
location: testResult.location,
numPassingAsserts: 0,
retryReasons: testResult.retryReasons,
status,
title: testResult.testPath[testResult.testPath.length - 1],
};
Expand Down
4 changes: 4 additions & 0 deletions packages/jest-circus/src/types.ts
Expand Up @@ -11,13 +11,17 @@ export const STATE_SYM = Symbol('JEST_STATE_SYMBOL');
export const RETRY_TIMES = Symbol.for('RETRY_TIMES');
// To pass this value from Runtime object to state we need to use global[sym]
export const TEST_TIMEOUT_SYMBOL = Symbol.for('TEST_TIMEOUT_SYMBOL');
export const LOG_ERRORS_BEFORE_RETRY = Symbol.for(
'LOG_ERRORS_BEFORE_RETRY',
);

declare global {
namespace NodeJS {
interface Global {
[STATE_SYM]: Circus.State;
[RETRY_TIMES]: string;
[TEST_TIMEOUT_SYMBOL]: number;
[LOG_ERRORS_BEFORE_RETRY]: boolean;
}
}
}
3 changes: 3 additions & 0 deletions packages/jest-circus/src/utils.ts
Expand Up @@ -70,6 +70,7 @@ export const makeTest = (
mode,
name: convertDescriptorToString(name),
parent,
retryReasons: [],
seenDone: false,
startedAt: null,
status: null,
Expand Down Expand Up @@ -354,6 +355,7 @@ export const makeSingleTestResult = (
errorsDetailed,
invocations: test.invocations,
location,
retryReasons: test.retryReasons,
status,
testPath: Array.from(testPath),
};
Expand Down Expand Up @@ -475,6 +477,7 @@ export const parseSingleTestResult = (
invocations: testResult.invocations,
location: testResult.location,
numPassingAsserts: 0,
retryReasons: testResult.retryReasons,
status,
title: testResult.testPath[testResult.testPath.length - 1],
};
Expand Down
10 changes: 9 additions & 1 deletion packages/jest-environment/src/index.ts
Expand Up @@ -232,10 +232,18 @@ export interface Jest {
* Runs failed tests n-times until they pass or until the max number of
* retries is exhausted.
*
* If `logErrorsBeforeRetry` is enabled, Jest will log the error(s) that caused
* the test to fail to the console, providing visibility on why a retry occurred.
* retries is exhausted.
*
* @remarks
* Only available with `jest-circus` runner.
*/
retryTimes(numRetries: number): Jest;
retryTimes(
numRetries: number,
options?: {logErrorsBeforeRetry?: boolean},
): Jest;

/**
* Exhausts tasks queued by `setImmediate()`.
*
Expand Down
21 changes: 21 additions & 0 deletions packages/jest-reporters/src/DefaultReporter.ts
Expand Up @@ -14,6 +14,7 @@ import type {
TestResult,
} from '@jest/test-result';
import type {Config} from '@jest/types';
import {deserialize, } from 'v8';
import {clearLine, isInteractive} from 'jest-util';
import BaseReporter from './BaseReporter';
import Status from './Status';
Expand Down Expand Up @@ -185,6 +186,26 @@ export default class DefaultReporter extends BaseReporter {
config: Config.ProjectConfig,
result: TestResult,
): void {
// log retry errors if any exist
result.testResults.forEach(testResult => {
const testRetryReasons = testResult.retryReasons;
if (testRetryReasons && testRetryReasons.length > 0) {
this.log(
`${chalk.reset.inverse.bold.yellow(
' LOGGING RETRY ERRORS ',
)} ${chalk.bold(testResult.fullName)}\n`,
);
testRetryReasons.forEach((retryReasons, index) => {
this.log(
`${chalk.reset.inverse.bold.redBright(` RETRY ${index + 1} `)}\n`,
);
// eslint-disable-next-line no-console
console.log(deserialize(retryReasons));
this.log('\n');
});
}
});

this.log(getResultHeader(result, this._globalConfig, config));
if (result.console) {
this.log(
Expand Down
12 changes: 11 additions & 1 deletion packages/jest-runtime/src/index.ts
Expand Up @@ -119,6 +119,7 @@ type ResolveOptions = Parameters<typeof require.resolve>[1] & {

const testTimeoutSymbol = Symbol.for('TEST_TIMEOUT_SYMBOL');
const retryTimesSymbol = Symbol.for('RETRY_TIMES');
const logErrorsBeforeRetrySymbol = Symbol.for('LOG_ERRORS_BEFORE_RETRY');

const NODE_MODULES = `${path.sep}node_modules${path.sep}`;

Expand Down Expand Up @@ -2117,8 +2118,17 @@ export default class Runtime {
return jestObject;
};

const retryTimes = (numTestRetries: number) => {
const retryTimes = (
numTestRetries: number,
options?: {
logErrorsBeforeRetry?: boolean;
},
) => {
this._environment.global[retryTimesSymbol] = numTestRetries;
// @ts-expect-error: https://github.com/Microsoft/TypeScript/issues/24587
this._environment.global[logErrorsBeforeRetrySymbol] =
options?.logErrorsBeforeRetry;

return jestObject;
};

Expand Down
1 change: 1 addition & 0 deletions packages/jest-types/__typetests__/jest.test.ts
Expand Up @@ -117,6 +117,7 @@ expectError(jest.unmock());

// Mock Functions

expectType<typeof jest>(jest.retryTimes(3, {logErrorsBeforeRetry: true}));
expectType<typeof jest>(jest.clearAllMocks());
expectError(jest.clearAllMocks('moduleName'));

Expand Down
2 changes: 2 additions & 0 deletions packages/jest-types/src/Circus.ts
Expand Up @@ -183,6 +183,7 @@ export type TestResult = {
status: TestStatus;
location?: {column: number; line: number} | null;
testPath: Array<TestName | BlockName>;
retryReasons: Array<Buffer>;
};

export type RunResult = {
Expand Down Expand Up @@ -234,6 +235,7 @@ export type TestEntry = {
type: 'test';
asyncError: Exception; // Used if the test failure contains no usable stack trace
errors: Array<TestError>;
retryReasons: Array<Buffer>;
fn: TestFn;
invocations: number;
mode: TestMode;
Expand Down
1 change: 1 addition & 0 deletions packages/jest-types/src/TestResult.ts
Expand Up @@ -26,6 +26,7 @@ export type AssertionResult = {
numPassingAsserts: number;
status: Status;
title: string;
retryReasons?: Array<Buffer>;
};

export type SerializableError = {
Expand Down
2 changes: 1 addition & 1 deletion packages/jest-util/src/deepCyclicCopy.ts
Expand Up @@ -17,7 +17,7 @@ export default function deepCyclicCopy<T>(
options: DeepCyclicCopyOptions = {blacklist: EMPTY, keepPrototype: false},
cycles: WeakMap<any, any> = new WeakMap(),
): T {
if (typeof value !== 'object' || value === null) {
if (typeof value !== 'object' || value === null || Buffer.isBuffer(value)) {
return value;
} else if (cycles.has(value)) {
return cycles.get(value);
Expand Down