Skip to content
Closed
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
24 changes: 15 additions & 9 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import type {
TestContext,
WriteableTestContext,
} from './types/tasks'
import { processError } from '@vitest/utils/error' // TODO: load dynamically
import { shuffle } from '@vitest/utils/helpers'
import { getSafeTimers } from '@vitest/utils/timers'

import { collectTests } from './collect'
import { abortContextSignal, getFileContext } from './context'
import { PendingError, TestRunAbortError } from './errors'
Expand Down Expand Up @@ -299,9 +297,10 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
const suite = test.suite || test.file

const repeats = test.repeats ?? 0
const retryConfig = normalizeRetryConfig(test.retry ?? runner.config.retry)

for (let repeatCount = 0; repeatCount <= repeats; repeatCount++) {
const retry = test.retry ?? 0
for (let retryCount = 0; retryCount <= retry; retryCount++) {
for (let retryCount = 0; retryCount <= retryConfig.count; retryCount++) {
let beforeEachCleanups: unknown[] = []
try {
await runner.onBeforeTryTask?.(test, {
Expand Down Expand Up @@ -341,13 +340,17 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
if (!test.repeats) {
test.result.state = 'pass'
}
else if (test.repeats && retry === retryCount) {
else if (test.repeats && retryConfig.count === retryCount) {
test.result.state = 'pass'
}
}
}
catch (e) {
failTask(test.result, e, runner.config.diffOptions)

if (e instanceof Error && !shouldRetryOnError(e, retryConfig.condition)) {
break
}
}

try {
Expand Down Expand Up @@ -402,13 +405,16 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
break
}

if (retryCount < retry) {
// reset state when retry test
if (retryCount < retryConfig.count) {
test.result.state = 'run'
test.result.retryCount = (test.result.retryCount ?? 0) + 1

if (retryConfig.delay > 0) {
const delayMs = calculateBackoffDelay(retryConfig.delay, retryCount)
await sleep(delayMs)
}
}

// update retry info
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not remove comments not related to the changes made

updateTask('test-retried', test, runner)
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runner/src/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ function createSuiteCollector(
type: 'test',
file: undefined!,
timeout,
retry: options.retry ?? runner.config.retry,
retry: options.retry ?? runner.config.retry ?? 0,
repeats: options.repeats,
mode: options.only
? 'only'
Expand Down
2 changes: 1 addition & 1 deletion packages/runner/src/types/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface VitestRunnerConfig {
maxConcurrency: number
testTimeout: number
hookTimeout: number
retry: number
retry: number | import('./tasks').RetryConfig
includeTaskLocation?: boolean
diffOptions?: DiffOptions
}
Expand Down
15 changes: 8 additions & 7 deletions packages/runner/src/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,18 +442,19 @@ type ChainableTestAPI<ExtraContext = object> = ChainableFunction<

type TestCollectorOptions = Omit<TestOptions, 'shuffle'>

export interface RetryConfig {
count?: number
strategy?: 'immediate' | 'test-file' | 'deferred'
delay?: number
condition?: string | RegExp | ((error: Error) => boolean)
}

export interface TestOptions {
/**
* Test timeout.
*/
timeout?: number
/**
* Times to retry the test if fails. Useful for making flaky tests more stable.
* When retries is up, the last test error will be thrown.
*
* @default 0
*/
retry?: number
Comment on lines -450 to -456
Copy link
Contributor

@flx-sta flx-sta Sep 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the doc-comment be replaced/extended instead of removed completely?

retry?: number | RetryConfig
/**
* How many times the test will run again.
* Only inner tests will repeat if set on `describe()`, nested `describe()` will inherit parent's repeat by default.
Expand Down
72 changes: 72 additions & 0 deletions packages/runner/src/utils/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { RetryConfig } from '../types/tasks'

export function normalizeRetryConfig(
retry: number | RetryConfig | undefined,
): {
count: number
strategy: 'immediate' | 'test-file' | 'deferred'
delay: number
condition?: string | RegExp | ((error: Error) => boolean)
} {
if (typeof retry === 'number') {
return {
count: retry,
strategy: 'immediate',
delay: 0,
}
}

if (!retry) {
return {
count: 0,
strategy: 'immediate',
delay: 0,
}
}

return {
count: retry.count ?? 0,
strategy: retry.strategy ?? 'immediate',
delay: retry.delay ?? 0,
condition: retry.condition,
}
}

export function shouldRetryOnError(
error: Error,
condition?: string | RegExp | ((error: Error) => boolean),
): boolean {
if (!condition) {
return true
}

if (typeof condition === 'string') {
return error.message.includes(condition)
}

if (condition instanceof RegExp) {
return condition.test(error.message)
}

if (typeof condition === 'function') {
try {
return condition(error)
}
catch {
return false
}
}

return true
}

export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

export function calculateBackoffDelay(
baseDelay: number,
retryCount: number,
): number {
return baseDelay * 2 ** retryCount
}
22 changes: 20 additions & 2 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,26 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
retry: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, add a test for CLI input to test/core/cli-test.test.ts

description:
'Retry the test specific number of times if it fails (default: `0`)',
argument: '<times>',
'Retry configuration. Can be a number or advanced options (default: `0`)',
argument: '<times|config>',
subcommands: {
count: {
description: 'Number of times to retry the test if it fails (default: 0)',
argument: '<times>',
},
strategy: {
description: 'Retry strategy: immediate, test-file, or deferred (default: immediate)',
argument: '<strategy>',
},
delay: {
description: 'Delay in milliseconds between retries (default: 0)',
argument: '<milliseconds>',
},
condition: {
description: 'Condition for retrying: string pattern to match error messages',
argument: '<pattern>',
} as any,
},
},
diff: {
description:
Expand Down
33 changes: 31 additions & 2 deletions packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,11 +742,40 @@ export interface InlineConfig {
bail?: number

/**
* Retry the test specific number of times if it fails.
* Retry configuration for tests.
* Can be a number (for backward compatibility) or an object with advanced options.
*
* @default 0
*/
retry?: number
retry?: number | {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep comments, please

/**
* Number of times to retry the test if it fails.
* @default 0
*/
count?: number

/**
* Strategy for when retries are executed.
* - "immediate": retry failed test immediately (default)
* - "test-file": run retries until the end of the test file
* - "deferred": defer retries after all other tests have run across all test files
* @default "immediate"
*/
strategy?: 'immediate' | 'test-file' | 'deferred'

/**
* Delay in milliseconds between retries.
* @default 0
*/
delay?: number

/**
* Condition for when to retry. Can be a RegExp pattern to match error messages
* or a function that receives the error and returns boolean.
* @default undefined (retry on any error)
*/
condition?: string | RegExp | ((error: Error) => boolean)
}

/**
* Show full diff when snapshot fails instead of a patch.
Expand Down
7 changes: 6 additions & 1 deletion packages/vitest/src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export interface SerializedConfig {
truncateThreshold?: number
} | undefined
diff: string | SerializedDiffOptions | undefined
retry: number
retry: number | {
count?: number
strategy?: 'immediate' | 'test-file' | 'deferred'
delay?: number
condition?: string | RegExp | ((error: Error) => boolean)
}
includeTaskLocation: boolean | undefined
inspect: boolean | string | undefined
inspectBrk: boolean | string | undefined
Expand Down
70 changes: 70 additions & 0 deletions packages/vitest/src/utils/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { SerializedConfig } from '../runtime/config'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole file should be in packages/runner. There is no reason to keep it in another package


export function normalizeRetryConfig(
retry: SerializedConfig['retry']

Check failure on line 4 in packages/vitest/src/utils/retry.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Missing trailing comma
): {
count: number
strategy: 'immediate' | 'test-file' | 'deferred'
delay: number
condition?: string | RegExp | ((error: Error) => boolean)
} {
if (typeof retry === 'number') {
return {
count: retry,
strategy: 'immediate',
delay: 0,
}
}

return {
count: retry.count ?? 0,
strategy: retry.strategy ?? 'immediate',
delay: retry.delay ?? 0,
condition: retry.condition,
}
}

export function shouldRetryOnError(
error: Error,
condition?: string | RegExp | ((error: Error) => boolean)

Check failure on line 29 in packages/vitest/src/utils/retry.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Missing trailing comma
): boolean {
if (!condition) {
return true
}

if (typeof condition === 'string') {
return error.message.includes(condition)
}

if (condition instanceof RegExp) {
return condition.test(error.message)
}

if (typeof condition === 'function') {
try {
return condition(error)
} catch {

Check failure on line 46 in packages/vitest/src/utils/retry.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Closing curly brace appears on the same line as the subsequent block
return false
}
}

return true
}

export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

export function calculateBackoffDelay(
baseDelay: number,
retryCount: number,
algorithm: 'linear' | 'exponential' = 'linear'

Check failure on line 61 in packages/vitest/src/utils/retry.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Missing trailing comma
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused why this needs to be customizable when we always pass down exponential. Should this check be removed?

): number {
switch (algorithm) {
case 'exponential':
return baseDelay * Math.pow(2, retryCount)

Check failure on line 65 in packages/vitest/src/utils/retry.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Use the '**' operator instead of 'Math.pow'
case 'linear':
default:
return baseDelay
}
}
22 changes: 22 additions & 0 deletions test/core/test/cli-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,25 @@ test('should include builtin reporters list', () => {
const expected = Object.keys(ReportersMap)
expect(new Set(listed)).toEqual(new Set(expected))
})

test('retry options work correctly', async () => {
expect(getCLIOptions('--retry 3')).toEqual({
retry: 3,
})

expect(getCLIOptions('--retry.count 2 --retry.delay 1000 --retry.strategy immediate --retry.condition "NetworkError"')).toEqual({
retry: {
count: 2,
delay: 1000,
strategy: 'immediate',
condition: 'NetworkError',
},
})

expect(getCLIOptions('--retry.count 1 --retry.delay 500')).toEqual({
retry: {
count: 1,
delay: 500,
},
})
})
Loading