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
236 changes: 234 additions & 2 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Awaitable } from '@vitest/utils'
import type { Awaitable, TestError } from '@vitest/utils'
import type { DiffOptions } from '@vitest/utils/diff'
import type { FileSpecification, VitestRunner } from './types/runner'
import type {
Expand Down Expand Up @@ -34,6 +34,46 @@
const unixNow = Date.now
const { clearTimeout, setTimeout } = getSafeTimers()

/**
* Normalizes retry configuration to extract individual values.
* Handles both number and object forms.
*/
function getRetryCount(retry: number | { count?: number } | undefined): number {
if (retry === undefined)
return 0

Check failure on line 43 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
if (typeof retry === 'number')
return retry

Check failure on line 45 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
return retry.count ?? 0
}

function getRetryDelay(retry: number | { delay?: number } | undefined): number {
if (retry === undefined)
return 0

Check failure on line 51 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
if (typeof retry === 'number')
return 0

Check failure on line 53 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
return retry.delay ?? 0
}

function getRetryCondition(
retry: number | { condition?: string | ((error: Error) => boolean) } | undefined,
): string | ((error: Error) => boolean) | undefined {
if (retry === undefined)
return undefined

Check failure on line 61 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
if (typeof retry === 'number')
return undefined

Check failure on line 63 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
return retry.condition
}

function getRetryStrategy(
retry: number | { strategy?: 'immediate' | 'test-file' | 'deferred' } | undefined,
): 'immediate' | 'test-file' | 'deferred' {
if (retry === undefined)
return 'immediate'

Check failure on line 71 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
if (typeof retry === 'number')
return 'immediate'

Check failure on line 73 in packages/runner/src/run.ts

View workflow job for this annotation

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

Expected { after 'if' condition
return retry.strategy ?? 'immediate'
}

function updateSuiteHookState(
task: Task,
name: keyof SuiteHooks,
Expand Down Expand Up @@ -266,6 +306,51 @@
}
}

/**
* Determines if a test should be retried based on its retryCondition configuration
*/
function shouldRetryTest(test: Test, errors: TestError[] | undefined): boolean {
const condition = getRetryCondition(test.retry)

// No errors means test passed, shouldn't get here but handle it
if (!errors || errors.length === 0) {
return false
}

// No condition means always retry
if (!condition) {
return true
}

// Check only the most recent error (last in array) against the condition
const error = errors[errors.length - 1]

if (typeof condition === 'string') {
// String condition is treated as regex pattern
const regex = new RegExp(condition, 'i')
return regex.test(error.message || '')
}
else if (typeof condition === 'function') {
// Function condition is called with error object
try {
// Convert TestError back to Error-like object for the condition function
const errorObj = Object.assign(new Error(error.message), {
name: error.name,
stack: error.stack,
cause: error.cause,
})
return condition(errorObj)
}
catch (e) {
// If condition function throws, treat as no match
console.error('retryCondition function threw error:', e)
return false
}
}

return false
}

export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
await runner.onBeforeRunTask?.(test)

Expand Down Expand Up @@ -299,7 +384,7 @@

const repeats = test.repeats ?? 0
for (let repeatCount = 0; repeatCount <= repeats; repeatCount++) {
const retry = test.retry ?? 0
const retry = getRetryCount(test.retry)
for (let retryCount = 0; retryCount <= retry; retryCount++) {
let beforeEachCleanups: unknown[] = []
try {
Expand Down Expand Up @@ -407,9 +492,34 @@
}

if (retryCount < retry) {
// Check if we should retry based on the error condition
const shouldRetry = shouldRetryTest(test, test.result.errors)

if (!shouldRetry) {
// Error doesn't match retry condition, stop retrying
break
}

// Check retry strategy
const strategy = getRetryStrategy(test.retry)

if (strategy === 'test-file' || strategy === 'deferred') {
// For test-file and deferred strategies, exit the retry loop
// test-file: let the suite handle it
// deferred: let the global runner handle it
break
}

// For immediate strategy (default), retry immediately
// reset state when retry test
test.result.state = 'run'
test.result.retryCount = (test.result.retryCount ?? 0) + 1

// Apply retry delay if configured
const delay = getRetryDelay(test.retry)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
}

// update retry info
Expand Down Expand Up @@ -468,6 +578,40 @@
})
}

/**
* Collects tests that failed and have test-file retry strategy
*/
function collectDeferredRetryTests(suite: Suite, strategy: 'test-file' | 'deferred'): Test[] {
const tests: Test[] = []

function collectFromTask(task: Task) {
if (task.type === 'test') {
const retry = getRetryCount(task.retry)
const retryCount = task.result?.retryCount ?? 0
const testStrategy = getRetryStrategy(task.retry)

if (
testStrategy === strategy
&& task.result?.state === 'fail'
&& retryCount < retry
) {
tests.push(task)
}
}
else if (task.type === 'suite') {
for (const child of task.tasks) {
collectFromTask(child)
}
}
}

for (const task of suite.tasks) {
collectFromTask(task)
}

return tests
}

export async function runSuite(suite: Suite, runner: VitestRunner): Promise<void> {
await runner.onBeforeRunSuite?.(suite)

Expand Down Expand Up @@ -544,6 +688,48 @@
}
}
}

// For file-level suites, retry tests with 'test-file' strategy
if (suite.file === suite) {
const deferredRetryTests = collectDeferredRetryTests(suite, 'test-file')
for (const test of deferredRetryTests) {
if (!test.result) {
continue
}

const retry = getRetryCount(test.retry)
const retryCount = test.result.retryCount ?? 0

// Retry the test up to the remaining retry count
for (let i = retryCount; i < retry; i++) {
// Reset test state for retry
test.result.state = 'run'
test.result.retryCount = i + 1

// Apply retry delay if configured
const delay = getRetryDelay(test.retry)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}

// Run the test again
await runTest(test, runner)

// If test passed, stop retrying (use any to bypass TypeScript flow analysis)
if (test.result && (test.result as any).state === 'pass') {
break
}

// Check retry condition for next attempt
if (i + 1 < retry && test.result) {
const shouldRetry = shouldRetryTest(test, test.result.errors)
if (!shouldRetry) {
break
}
}
}
}
}
}
catch (e) {
failTask(suite.result, e, runner.config.diffOptions)
Expand Down Expand Up @@ -615,6 +801,52 @@
}
await runSuite(file, runner)
}

// After all files have run, retry tests with 'deferred' strategy
const globalDeferredTests: Test[] = []
for (const file of files) {
const deferredTests = collectDeferredRetryTests(file, 'deferred')
globalDeferredTests.push(...deferredTests)
}

// Retry all deferred tests
for (const test of globalDeferredTests) {
if (!test.result) {
continue
}

const retry = getRetryCount(test.retry)
const retryCount = test.result.retryCount ?? 0

// Retry the test up to the remaining retry count
for (let i = retryCount; i < retry; i++) {
// Reset test state for retry
test.result.state = 'run'
test.result.retryCount = i + 1

// Apply retry delay if configured
const delay = getRetryDelay(test.retry)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}

// Run the test again
await runTest(test, runner)

// If test passed, stop retrying
if (test.result && (test.result as any).state === 'pass') {
break
}

// Check retry condition for next attempt
if (i + 1 < retry && test.result) {
const shouldRetry = shouldRetryTest(test, test.result.errors)
if (!shouldRetry) {
break
}
}
}
}
}

const workerRunners = new WeakSet<VitestRunner>()
Expand Down
7 changes: 6 additions & 1 deletion packages/runner/src/types/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ export interface VitestRunnerConfig {
maxConcurrency: number
testTimeout: number
hookTimeout: number
retry: number
retry: number | {
count?: number
delay?: number
condition?: string | ((error: Error) => boolean)
strategy?: 'immediate' | 'test-file' | 'deferred'
}
includeTaskLocation?: boolean
diffOptions?: DiffOptions
}
Expand Down
74 changes: 68 additions & 6 deletions packages/runner/src/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,42 @@ export interface TaskBase {
*/
result?: TaskResult
/**
* The amount of times the task should be retried if it fails.
* Retry configuration for the task.
* - If a number, specifies how many times to retry
* - If an object, allows fine-grained retry control
* @default 0
*/
retry?: number
retry?: number | {
/**
* The number of times to retry the test if it fails.
* @default 0
*/
count?: number
/**
* Delay in milliseconds between retry attempts.
* @default 0
*/
delay?: number
/**
* Condition to determine if a test should be retried based on the error.
* - If a string, treated as a regular expression to match against error message
* - If a function, called with the error object; return true to retry
*
* NOTE: Functions can only be used in test files, not in vitest.config.ts,
* because the configuration is serialized when passed to worker threads.
*
* @default undefined (retry on all errors)
*/
condition?: string | ((error: Error) => boolean)
/**
* Strategy for when to retry failed tests.
* - 'immediate': Retry immediately after failure (default)
* - 'test-file': Defer retries until after all tests in the file complete
* - 'deferred': Defer retries until after all test files complete
* @default 'immediate'
*/
strategy?: 'immediate' | 'test-file' | 'deferred'
}
/**
* The amount of times the task should be repeated after the successful run.
* If the task fails, it will not be retried unless `retry` is specified.
Expand Down Expand Up @@ -433,12 +465,42 @@ export interface TestOptions {
*/
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.
*
* Retry configuration for the test.
* - If a number, specifies how many times to retry
* - If an object, allows fine-grained retry control
* @default 0
*/
retry?: number
retry?: number | {
/**
* The number of times to retry the test if it fails.
* @default 0
*/
count?: number
/**
* Delay in milliseconds between retry attempts.
* @default 0
*/
delay?: number
/**
* Condition to determine if a test should be retried based on the error.
* - If a string, treated as a regular expression to match against error message
* - If a function, called with the error object; return true to retry
*
* NOTE: Functions can only be used in test files, not in vitest.config.ts,
* because the configuration is serialized when passed to worker threads.
*
* @default undefined (retry on all errors)
*/
condition?: string | ((error: Error) => boolean)
/**
* Strategy for when to retry failed tests.
* - 'immediate': Retry immediately after failure (default)
* - 'test-file': Defer retries until after all tests in the file complete
* - 'deferred': Defer retries until after all test files complete
* @default 'immediate'
*/
strategy?: 'immediate' | 'test-file' | 'deferred'
}
/**
* 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
Loading
Loading