From 2793d0a8a0d905f777178cfd2df020c24647350e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:52:11 +0900 Subject: [PATCH] feat(runner): expose test attempts Co-authored-by: Codex (GPT-6) --- .../src/node/reporters/reported-tasks.ts | 39 +++++++++++++++++++ packages/vitest/src/public/index.ts | 1 + packages/vitest/src/public/node.ts | 1 + packages/vitest/src/runtime/runner/run.ts | 19 +++++++++ packages/vitest/src/runtime/runner/types.ts | 24 ++++++++++++ 5 files changed, 84 insertions(+) diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index 87c33bd48..d98e5a5c0 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -198,6 +198,26 @@ export class TestCase extends ReportedTaskImplementation { } satisfies TestResultFailed } + /** + * @experimental + * + * Individual results for every retry and repeat attempt. + */ + public attempts(): ReadonlyArray { + return (this.task.result?.attempts || []).map(attempt => ({ + state: attempt.state === 'pass' + ? 'passed' + : attempt.state === 'fail' + ? 'failed' + : 'skipped', + errors: attempt.errors as TestError[] | undefined, + duration: attempt.duration, + startTime: attempt.startTime, + retryCount: attempt.retryCount, + repeatCount: attempt.repeatCount, + })) + } + /** * Test annotations added via the `task.annotate` API during the test execution. */ @@ -619,6 +639,25 @@ export type TestSuiteState = 'skipped' | 'pending' | 'failed' | 'passed' export type TestModuleState = TestSuiteState | 'queued' export type TestState = TestResult['state'] +/** + * The result of an individual test attempt. + * @experimental + */ +export interface TestAttempt { + /** The state after applying the test's expected failure option. */ + readonly state: 'passed' | 'failed' | 'skipped' + /** Errors produced by this attempt. */ + readonly errors: ReadonlyArray | undefined + /** How long in milliseconds the attempt took to run. */ + readonly duration: number + /** Time in milliseconds when the attempt started running. */ + readonly startTime: number + /** The total number of retries before this attempt. */ + readonly retryCount: number + /** The zero-based repeat index of this attempt. */ + readonly repeatCount: number +} + export type TestResult = | TestResultPassed | TestResultFailed diff --git a/packages/vitest/src/public/index.ts b/packages/vitest/src/public/index.ts index 9b2f59c5a..9dc39916f 100644 --- a/packages/vitest/src/public/index.ts +++ b/packages/vitest/src/public/index.ts @@ -92,6 +92,7 @@ export type { TaskBase as RunnerTaskBase, TaskEventPack as RunnerTaskEventPack, TaskResult as RunnerTaskResult, + TaskResultAttempt as RunnerTaskResultAttempt, TaskResultPack as RunnerTaskResultPack, Test as RunnerTestCase, File as RunnerTestFile, diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index e6f9e43eb..b75ddc140 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -77,6 +77,7 @@ export type { ModuleDiagnostic, TaskOptions, + TestAttempt, TestCase, TestCollection, TestDiagnostic, diff --git a/packages/vitest/src/runtime/runner/run.ts b/packages/vitest/src/runtime/runner/run.ts index cb16912ff..4ae400e01 100644 --- a/packages/vitest/src/runtime/runner/run.ts +++ b/packages/vitest/src/runtime/runner/run.ts @@ -12,6 +12,7 @@ import type { Task, TaskMeta, TaskResult, + TaskResultAttempt, TaskResultPack, TaskState, TaskUpdateEvent, @@ -616,13 +617,27 @@ async function runTest(test: Test, runner: VitestRunner): Promise { const $ = runner.trace! const repeats = test.repeats ?? 0 + const attempts: TaskResultAttempt[] = [] let hasFailedRepeat = false for (let repeatCount = 0; repeatCount <= repeats; repeatCount++) { // Force widening to TaskState because TypeScript cannot track mutations made by hooks and the test. test.result.state = 'run' as TaskState const retry = getRetryCount(test.retry) for (let retryCount = 0; retryCount <= retry; retryCount++) { + const attemptStart = now() + const attemptStartTime = unixNow() const attemptErrorsStart = test.result.errors?.length ?? 0 + const recordAttempt = (state: TaskResultAttempt['state']) => { + const errors = test.result!.errors?.slice(attemptErrorsStart) + attempts.push({ + state, + errors: errors?.length ? errors : undefined, + duration: now() - attemptStart, + startTime: attemptStartTime, + retryCount: test.result!.retryCount ?? 0, + repeatCount: test.result!.repeatCount ?? 0, + }) + } let beforeEachCleanups: unknown[] = [] // fixtureCheckpoint is passed by callAroundEachHooks - it represents the count // of fixture cleanup functions AFTER all aroundEach fixtures have been resolved @@ -729,12 +744,14 @@ async function runTest(test: Test, runner: VitestRunner): Promise { // skipped with new PendingError if (test.result?.pending || test.result?.state === 'skip') { + recordAttempt('skip') test.mode = 'skip' test.result = { state: 'skip', note: test.result?.note, pending: true, duration: now() - start, + attempts, } updateTask('test-finished', test, runner) setCurrentTest(undefined) @@ -758,6 +775,7 @@ async function runTest(test: Test, runner: VitestRunner): Promise { } } } + recordAttempt(test.result.state === 'pass' ? 'pass' : 'fail') if (test.result.state === 'pass') { break } @@ -790,6 +808,7 @@ async function runTest(test: Test, runner: VitestRunner): Promise { if (hasFailedRepeat) { test.result.state = 'fail' } + test.result.attempts = attempts cleanupRunningTest() setCurrentTest(undefined) diff --git a/packages/vitest/src/runtime/runner/types.ts b/packages/vitest/src/runtime/runner/types.ts index 5658e180a..1ea1d35f0 100644 --- a/packages/vitest/src/runtime/runner/types.ts +++ b/packages/vitest/src/runtime/runner/types.ts @@ -158,6 +158,25 @@ export interface TaskMeta { __vitest_label__?: string } +/** + * The result of an individual test attempt. + * @experimental + */ +export interface TaskResultAttempt { + /** The state after applying the test's expected failure option. */ + state: 'pass' | 'fail' | 'skip' + /** Errors produced by this attempt. */ + errors?: TestError[] + /** How long in milliseconds the attempt took to run. */ + duration: number + /** Time in milliseconds when the attempt started running. */ + startTime: number + /** The total number of retries before this attempt. */ + retryCount: number + /** The zero-based repeat index of this attempt. */ + repeatCount: number +} + /** * The result of calling a task. */ @@ -174,6 +193,11 @@ export interface TaskResult { * if `expect.soft()` failed multiple times or `retry` was triggered. */ errors?: TestError[] + /** + * Individual results for every retry and repeat attempt. + * @experimental + */ + attempts?: TaskResultAttempt[] /** * How long in milliseconds the task took to run. */ -- 2.51.2