diff --git a/CHANGELOG.md b/CHANGELOG.md index a24a100ebd79..165b04d5a465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[jest-circus, jest-cli, jest-config]` Add `waitNextEventLoopTurnForUnhandledRejectionEvents` flag to minimise performance impact of correct detection of unhandled promise rejections introduced in [#14315](https://github.com/jestjs/jest/pull/14315) ([#14681](https://github.com/jestjs/jest/pull/14681)) +- `[jest-circus]` Add a `waitBeforeRetry` option to `jest.retryTimes` ([#14738](https://github.com/jestjs/jest/pull/14738)) - `[jest-config]` [**BREAKING**] Add `mts` and `cts` to default `moduleFileExtensions` config ([#14369](https://github.com/facebook/jest/pull/14369)) - `[jest-config]` [**BREAKING**] Update `testMatch` and `testRegex` default option for supporting `mjs`, `cjs`, `mts`, and `cts` ([#14584](https://github.com/jestjs/jest/pull/14584)) - `[jest-config]` Loads config file from provided path in `package.json` ([#14044](https://github.com/facebook/jest/pull/14044)) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index f0b6b0066ed8..dcf16206e6bb 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -1071,6 +1071,16 @@ test('will fail', () => { }); ``` +`waitBeforeRetry` is the number of milliseconds to wait before retrying. + +```js +jest.retryTimes(3, {waitBeforeRetry: 1000}); + +test('will fail', () => { + expect(true).toBe(false); +}); +``` + Returns the `jest` object for chaining. :::caution diff --git a/e2e/__tests__/__snapshots__/testRetries.test.ts.snap b/e2e/__tests__/__snapshots__/testRetries.test.ts.snap index 09c1f807a028..7c1e4433d0cd 100644 --- a/e2e/__tests__/__snapshots__/testRetries.test.ts.snap +++ b/e2e/__tests__/__snapshots__/testRetries.test.ts.snap @@ -37,3 +37,79 @@ exports[`Test Retries logs error(s) before retry 1`] = ` PASS __tests__/logErrorsBeforeRetries.test.js ✓ retryTimes set" `; + +exports[`Test Retries wait before retry 1`] = ` +"LOGGING RETRY ERRORS retryTimes set + RETRY 1 + + expect(received).toBeFalsy() + + Received: true + + 15 | expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + 16 | } else { + > 17 | expect(true).toBeFalsy(); + | ^ + 18 | } + 19 | }); + 20 | + + at Object.toBeFalsy (__tests__/waitBeforeRetry.test.js:17:18) + + RETRY 2 + + expect(received).toBeFalsy() + + Received: true + + 15 | expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + 16 | } else { + > 17 | expect(true).toBeFalsy(); + | ^ + 18 | } + 19 | }); + 20 | + + at Object.toBeFalsy (__tests__/waitBeforeRetry.test.js:17:18) + +PASS __tests__/waitBeforeRetry.test.js + ✓ retryTimes set" +`; + +exports[`Test Retries wait before retry with fake timers 1`] = ` +"LOGGING RETRY ERRORS retryTimes set with fake timers + RETRY 1 + + expect(received).toBeFalsy() + + Received: true + + 16 | expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + 17 | } else { + > 18 | expect(true).toBeFalsy(); + | ^ + 19 | jest.runAllTimers(); + 20 | } + 21 | }); + + at Object.toBeFalsy (__tests__/waitBeforeRetryFakeTimers.test.js:18:18) + + RETRY 2 + + expect(received).toBeFalsy() + + Received: true + + 16 | expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + 17 | } else { + > 18 | expect(true).toBeFalsy(); + | ^ + 19 | jest.runAllTimers(); + 20 | } + 21 | }); + + at Object.toBeFalsy (__tests__/waitBeforeRetryFakeTimers.test.js:18:18) + +PASS __tests__/waitBeforeRetryFakeTimers.test.js + ✓ retryTimes set with fake timers" +`; diff --git a/e2e/__tests__/testRetries.test.ts b/e2e/__tests__/testRetries.test.ts index cb6b8951d33a..551829cd7874 100644 --- a/e2e/__tests__/testRetries.test.ts +++ b/e2e/__tests__/testRetries.test.ts @@ -42,6 +42,24 @@ describe('Test Retries', () => { expect(extractSummary(result.stderr).rest).toMatchSnapshot(); }); + it('wait before retry', () => { + const result = runJest('test-retries', ['waitBeforeRetry.test.js']); + expect(result.exitCode).toBe(0); + expect(result.failed).toBe(false); + expect(result.stderr).toContain(logErrorsBeforeRetryErrorMessage); + expect(extractSummary(result.stderr).rest).toMatchSnapshot(); + }); + + it('wait before retry with fake timers', () => { + const result = runJest('test-retries', [ + 'waitBeforeRetryFakeTimers.test.js', + ]); + expect(result.exitCode).toBe(0); + expect(result.failed).toBe(false); + expect(result.stderr).toContain(logErrorsBeforeRetryErrorMessage); + expect(extractSummary(result.stderr).rest).toMatchSnapshot(); + }); + it('reporter shows more than 1 invocation if test is retried', () => { let jsonResult; @@ -54,7 +72,7 @@ describe('Test Retries', () => { runJest('test-retries', [ '--config', JSON.stringify(reporterConfig), - 'retry.test.js', + '__tests__/retry.test.js', ]); const testOutput = fs.readFileSync(outputFilePath, 'utf8'); diff --git a/e2e/test-retries/__tests__/waitBeforeRetry.test.js b/e2e/test-retries/__tests__/waitBeforeRetry.test.js new file mode 100644 index 000000000000..adddd5906179 --- /dev/null +++ b/e2e/test-retries/__tests__/waitBeforeRetry.test.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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; +const startTimeInSeconds = new Date().getTime(); +jest.retryTimes(3, {logErrorsBeforeRetry: true, waitBeforeRetry: 100}); +it('retryTimes set', () => { + i++; + if (i === 3) { + expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + } else { + expect(true).toBeFalsy(); + } +}); diff --git a/e2e/test-retries/__tests__/waitBeforeRetryFakeTimers.test.js b/e2e/test-retries/__tests__/waitBeforeRetryFakeTimers.test.js new file mode 100644 index 000000000000..cce19619fe17 --- /dev/null +++ b/e2e/test-retries/__tests__/waitBeforeRetryFakeTimers.test.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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; +const startTimeInSeconds = new Date().getTime(); +jest.retryTimes(3, {logErrorsBeforeRetry: true, waitBeforeRetry: 100}); +it('retryTimes set with fake timers', () => { + jest.useFakeTimers(); + i++; + if (i === 3) { + expect(new Date().getTime() - startTimeInSeconds).toBeGreaterThan(200); + } else { + expect(true).toBeFalsy(); + jest.runAllTimers(); + } +}); diff --git a/packages/jest-circus/src/run.ts b/packages/jest-circus/src/run.ts index 861ed4c5efae..d4fd22e4ac33 100644 --- a/packages/jest-circus/src/run.ts +++ b/packages/jest-circus/src/run.ts @@ -15,7 +15,7 @@ import shuffleArray, { rngBuilder, } from './shuffleArray'; import {dispatch, getState} from './state'; -import {RETRY_TIMES} from './types'; +import {RETRY_TIMES, WAIT_BEFORE_RETRY} from './types'; import { callAsyncCircusFn, getAllHooksForDescribe, @@ -24,6 +24,10 @@ import { makeRunResult, } from './utils'; +// Global values can be overwritten by mocks or tests. We'll capture +// the original values in the variables before we require any files. +const {setTimeout} = globalThis; + type ConcurrentTestEntry = Omit & { fn: Circus.ConcurrentTestFn; }; @@ -67,6 +71,10 @@ const _runTestsForDescribeBlock = async ( const retryTimes = // eslint-disable-next-line no-restricted-globals parseInt((global as Global.Global)[RETRY_TIMES] as string, 10) || 0; + + const waitBeforeRetry = + // eslint-disable-next-line no-restricted-globals + parseInt((global as Global.Global)[WAIT_BEFORE_RETRY] as string, 10) || 0; const deferredRetryTests = []; if (rng) { @@ -102,6 +110,10 @@ const _runTestsForDescribeBlock = async ( // Clear errors so retries occur await dispatch({name: 'test_retry', test}); + if (waitBeforeRetry > 0) { + await new Promise(resolve => setTimeout(resolve, waitBeforeRetry)); + } + await _runTest(test, isSkipped); numRetriesAvailable--; } diff --git a/packages/jest-circus/src/types.ts b/packages/jest-circus/src/types.ts index 704a35b3733d..0e64cf823033 100644 --- a/packages/jest-circus/src/types.ts +++ b/packages/jest-circus/src/types.ts @@ -7,6 +7,7 @@ export const STATE_SYM = Symbol('JEST_STATE_SYMBOL'); export const RETRY_TIMES = Symbol.for('RETRY_TIMES'); +export const WAIT_BEFORE_RETRY = Symbol.for('WAIT_BEFORE_RETRY'); // 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'); diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 254f323c99e7..ad3e6c1be1ce 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -298,12 +298,14 @@ export interface Jest { * the test to fail to the console, providing visibility on why a retry occurred. * retries is exhausted. * + * `waitBeforeRetry` is the number of milliseconds to wait before retrying + * * @remarks * Only available with `jest-circus` runner. */ retryTimes( numRetries: number, - options?: {logErrorsBeforeRetry?: boolean}, + options?: {logErrorsBeforeRetry?: boolean; waitBeforeRetry?: number}, ): Jest; /** * Exhausts tasks queued by `setImmediate()`. diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 9156101ccae1..fe148134a4ae 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -122,6 +122,7 @@ type ResolveOptions = Parameters[1] & { const testTimeoutSymbol = Symbol.for('TEST_TIMEOUT_SYMBOL'); const retryTimesSymbol = Symbol.for('RETRY_TIMES'); +const waitBeforeRetrySymbol = Symbol.for('WAIT_BEFORE_RETRY'); const logErrorsBeforeRetrySymbol = Symbol.for('LOG_ERRORS_BEFORE_RETRY'); const NODE_MODULES = `${path.sep}node_modules${path.sep}`; @@ -2265,6 +2266,8 @@ export default class Runtime { this._environment.global[retryTimesSymbol] = numTestRetries; this._environment.global[logErrorsBeforeRetrySymbol] = options?.logErrorsBeforeRetry; + this._environment.global[waitBeforeRetrySymbol] = + options?.waitBeforeRetry; return jestObject; };