GeezerVison is showing github.com — feat: expose per-retry/repeat attempt result (#11232) · vitest-dev/vitest@21be412
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: docs/api/advanced/runner.md +31Lines changed: 31 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -268,6 +268,37 @@ export interface TaskResult { | 268 | 268 | * The zero-based index of the current repeat. | 269 | 269 | */ | 270 | 270 | repeatCount?: number | | 271 | + /** | | 272 | + * Individual results for every retry and repeat attempt. | | 273 | + */ | | 274 | + attempts?: TaskResultAttempt[] | | 275 | +} | | 276 | + | | 277 | +export interface TaskResultAttempt { | | 278 | + /** | | 279 | + * The state after applying the test's expected failure option. | | 280 | + */ | | 281 | + state: 'pass' | 'fail' | 'skip' | | 282 | + /** | | 283 | + * Errors produced by this attempt. | | 284 | + */ | | 285 | + errors?: TestError[] | | 286 | + /** | | 287 | + * How long in milliseconds the attempt took to run. | | 288 | + */ | | 289 | + duration: number | | 290 | + /** | | 291 | + * Time in milliseconds when the attempt started running. | | 292 | + */ | | 293 | + startTime: number | | 294 | + /** | | 295 | + * The zero-based retry index within the repeat. | | 296 | + */ | | 297 | + retryIndex: number | | 298 | + /** | | 299 | + * The zero-based repeat index. | | 300 | + */ | | 301 | + repeatIndex: number | 271 | 302 | } | 272 | 303 | ``` | 273 | 304 |
| |
Collapse file Copy file name to clipboardExpand all lines: docs/api/advanced/test-case.md +53-1Lines changed: 53 additions & 1 deletion - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -228,7 +228,7 @@ interface TestResultPassed { | 228 | 228 | ``` | 229 | 229 |
| 230 | 230 | ::: warning | 231 | | -Note that the test with `passed` state can still have errors attached - this can happen if `retry` was triggered at least once. | | 231 | +Note that the test with `passed` state can still have errors attached - this can happen if `retry` was triggered at least once. Use [`attempts()`](#attempts) to see which run produced each error. | 232 | 232 | ::: | 233 | 233 |
| 234 | 234 | ## diagnostic | @@ -278,6 +278,58 @@ interface TestDiagnostic { | 278 | 278 | `diagnostic()` will return `undefined` if the test was not scheduled to run yet. | 279 | 279 | ::: | 280 | 280 |
| | 281 | +## attempts <Version>5.1.0</Version> {#attempts} | | 282 | + | | 283 | +```ts | | 284 | +function attempts(): ReadonlyArray<TestAttempt> | | 285 | +``` | | 286 | + | | 287 | +Results of every run of the test, in execution order. A test runs more than once when [`retry`](/config/retry) or [`repeats`](/api/test#repeats) is configured, and each run produces one entry. Unlike `result()`, which only reports the final outcome and merges errors from all runs, each attempt keeps its own state, errors and timing. | | 288 | + | | 289 | +```ts | | 290 | +interface TestAttempt { | | 291 | + /** | | 292 | + * The state of this attempt. `fails` tests are already inverted, | | 293 | + * so an expected failure is reported as `passed`. | | 294 | + */ | | 295 | + readonly state: 'passed' | 'failed' | 'skipped' | | 296 | + /** | | 297 | + * Errors thrown during this attempt only. | | 298 | + */ | | 299 | + readonly errors: ReadonlyArray<TestError> | undefined | | 300 | + /** | | 301 | + * How long in milliseconds the attempt took to run. | | 302 | + */ | | 303 | + readonly duration: number | | 304 | + /** | | 305 | + * Time in milliseconds when the attempt started. | | 306 | + */ | | 307 | + readonly startTime: number | | 308 | + /** | | 309 | + * The zero-based retry index within the current repeat. | | 310 | + */ | | 311 | + readonly retryIndex: number | | 312 | + /** | | 313 | + * The zero-based repeat index. | | 314 | + */ | | 315 | + readonly repeatIndex: number | | 316 | +} | | 317 | +``` | | 318 | + | | 319 | +For example, a test with `retry: 2` that passes on its third run returns: | | 320 | + | | 321 | +```ts | | 322 | +[ | | 323 | + { state: 'failed', retryIndex: 0, repeatIndex: 0, errors: [/* ... */] }, | | 324 | + { state: 'failed', retryIndex: 1, repeatIndex: 0, errors: [/* ... */] }, | | 325 | + { state: 'passed', retryIndex: 2, repeatIndex: 0, errors: undefined }, | | 326 | +] | | 327 | +``` | | 328 | + | | 329 | +::: info | | 330 | +`attempts()` returns an empty array if the test has not run yet. | | 331 | +::: | | 332 | + | 281 | 333 | ## annotations | 282 | 334 |
| 283 | 335 | ```ts | |
Collapse file Copy file name to clipboardExpand all lines: packages/vitest/src/node/reporters/reported-tasks.ts +34-1Lines changed: 34 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -194,6 +194,20 @@ export class TestCase extends ReportedTaskImplementation { | 194 | 194 | } satisfies TestResultFailed | 195 | 195 | } | 196 | 196 |
| | 197 | + /** | | 198 | + * Individual results for every retry and repeat attempt. | | 199 | + */ | | 200 | + public attempts(): ReadonlyArray<TestAttempt> { | | 201 | + return (this.task.result?.attempts || []).map((attempt) => ({ | | 202 | + state: attempt.state === 'pass' ? 'passed' : attempt.state === 'fail' ? 'failed' : 'skipped', | | 203 | + errors: attempt.errors as TestError[] | undefined, | | 204 | + duration: attempt.duration, | | 205 | + startTime: attempt.startTime, | | 206 | + retryIndex: attempt.retryIndex, | | 207 | + repeatIndex: attempt.repeatIndex, | | 208 | + })) | | 209 | + } | | 210 | + | 197 | 211 | /** | 198 | 212 | * Test annotations added via the `task.annotate` API during the test execution. | 199 | 213 | */ | @@ -609,10 +623,29 @@ function buildOptions(task: RunnerTestCase | RunnerTestSuite): TaskOptions { | 609 | 623 | } | 610 | 624 | } | 611 | 625 |
| 612 | | -export type TestSuiteState = 'skipped' | 'pending' | 'failed' | 'passed' | | 626 | +export type TestAttemptState = 'skipped' | 'failed' | 'passed' | | 627 | +export type TestSuiteState = TestAttemptState | 'pending' | 613 | 628 | export type TestModuleState = TestSuiteState | 'queued' | 614 | 629 | export type TestState = TestResult['state'] | 615 | 630 |
| | 631 | +/** | | 632 | + * The result of an individual test attempt. | | 633 | + */ | | 634 | +export interface TestAttempt { | | 635 | + /** The state after applying the test's expected failure option. */ | | 636 | + readonly state: TestAttemptState | | 637 | + /** Errors produced by this attempt. */ | | 638 | + readonly errors: ReadonlyArray<TestError> | undefined | | 639 | + /** How long in milliseconds the attempt took to run. */ | | 640 | + readonly duration: number | | 641 | + /** Time in milliseconds when the attempt started running. */ | | 642 | + readonly startTime: number | | 643 | + /** The zero-based retry index within the repeat. */ | | 644 | + readonly retryIndex: number | | 645 | + /** The zero-based repeat index. */ | | 646 | + readonly repeatIndex: number | | 647 | +} | | 648 | + | 616 | 649 | export type TestResult = TestResultPassed | TestResultFailed | TestResultSkipped | TestResultPending | 617 | 650 |
| 618 | 651 | export interface TestResultPending { | |
Collapse file Copy file name to clipboardExpand all lines: packages/vitest/src/public/index.ts +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -87,6 +87,7 @@ export type { | 87 | 87 | TaskBase as RunnerTaskBase, | 88 | 88 | TaskEventPack as RunnerTaskEventPack, | 89 | 89 | TaskResult as RunnerTaskResult, | | 90 | + TaskResultAttempt as RunnerTaskResultAttempt, | 90 | 91 | TaskResultPack as RunnerTaskResultPack, | 91 | 92 | Test as RunnerTestCase, | 92 | 93 | File as RunnerTestFile, | |
Collapse file Copy file name to clipboardExpand all lines: packages/vitest/src/public/node.ts +2Lines changed: 2 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -89,6 +89,8 @@ export type { Report } from '../node/reporters/report' | 89 | 89 | export type { | 90 | 90 | ModuleDiagnostic, | 91 | 91 | TaskOptions, | | 92 | + TestAttempt, | | 93 | + TestAttemptState, | 92 | 94 | TestCase, | 93 | 95 | TestCollection, | 94 | 96 | TestDiagnostic, | |
Collapse file Copy file name to clipboardExpand all lines: packages/vitest/src/runtime/runner/run.ts +18Lines changed: 18 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -12,6 +12,7 @@ import type { | 12 | 12 | Task, | 13 | 13 | TaskMeta, | 14 | 14 | TaskResult, | | 15 | + TaskResultAttempt, | 15 | 16 | TaskResultPack, | 16 | 17 | TaskState, | 17 | 18 | TaskUpdateEvent, | @@ -599,6 +600,7 @@ async function runTest(test: Test, runner: VitestRunner): Promise<void> { | 599 | 600 | state: 'run', | 600 | 601 | startTime: unixNow(), | 601 | 602 | retryCount: 0, | | 603 | + attempts: [], | 602 | 604 | } | 603 | 605 | updateTask('test-prepare', test, runner) | 604 | 606 |
| @@ -615,7 +617,20 @@ async function runTest(test: Test, runner: VitestRunner): Promise<void> { | 615 | 617 | test.result.state = 'run' as TaskState | 616 | 618 | const retry = getRetryCount(test.retry) | 617 | 619 | for (let retryCount = 0; retryCount <= retry; retryCount++) { | | 620 | + const attemptStart = now() | | 621 | + const attemptStartTime = unixNow() | 618 | 622 | const attemptErrorsStart = test.result.errors?.length ?? 0 | | 623 | + const recordAttempt = (state: TaskResultAttempt['state']) => { | | 624 | + const errors = test.result!.errors?.slice(attemptErrorsStart) | | 625 | + test.result!.attempts!.push({ | | 626 | + state, | | 627 | + errors: errors?.length ? errors : undefined, | | 628 | + duration: now() - attemptStart, | | 629 | + startTime: attemptStartTime, | | 630 | + retryIndex: retryCount, | | 631 | + repeatIndex: repeatCount, | | 632 | + }) | | 633 | + } | 619 | 634 | const beforeEachCleanups: unknown[] = [] | 620 | 635 | // fixtureCheckpoint is passed by callAroundEachHooks - it represents the count | 621 | 636 | // of fixture cleanup functions AFTER all aroundEach fixtures have been resolved | @@ -714,12 +729,14 @@ async function runTest(test: Test, runner: VitestRunner): Promise<void> { | 714 | 729 |
| 715 | 730 | // skipped with new PendingError | 716 | 731 | if (test.result?.pending || test.result?.state === 'skip') { | | 732 | + recordAttempt('skip') | 717 | 733 | test.mode = 'skip' | 718 | 734 | test.result = { | 719 | 735 | state: 'skip', | 720 | 736 | note: test.result?.note, | 721 | 737 | pending: true, | 722 | 738 | duration: now() - start, | | 739 | + attempts: test.result.attempts, | 723 | 740 | } | 724 | 741 | updateTask('test-finished', test, runner) | 725 | 742 | setCurrentTest(undefined) | @@ -744,6 +761,7 @@ async function runTest(test: Test, runner: VitestRunner): Promise<void> { | 744 | 761 | } | 745 | 762 | } | 746 | 763 | } | | 764 | + recordAttempt(test.result.state === 'pass' ? 'pass' : 'fail') | 747 | 765 | if (test.result.state === 'pass') { | 748 | 766 | break | 749 | 767 | } | |
Collapse file Copy file name to clipboardExpand all lines: packages/vitest/src/runtime/runner/types.ts +22Lines changed: 22 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -158,6 +158,24 @@ export interface TaskMeta { | 158 | 158 | __vitest_label__?: string | 159 | 159 | } | 160 | 160 |
| | 161 | +/** | | 162 | + * The result of an individual test attempt. | | 163 | + */ | | 164 | +export interface TaskResultAttempt { | | 165 | + /** The state after applying the test's expected failure option. */ | | 166 | + state: 'pass' | 'fail' | 'skip' | | 167 | + /** Errors produced by this attempt. */ | | 168 | + errors?: TestError[] | | 169 | + /** How long in milliseconds the attempt took to run. */ | | 170 | + duration: number | | 171 | + /** Time in milliseconds when the attempt started running. */ | | 172 | + startTime: number | | 173 | + /** The zero-based retry index within the repeat. */ | | 174 | + retryIndex: number | | 175 | + /** The zero-based repeat index. */ | | 176 | + repeatIndex: number | | 177 | +} | | 178 | + | 161 | 179 | /** | 162 | 180 | * The result of calling a task. | 163 | 181 | */ | @@ -174,6 +192,10 @@ export interface TaskResult { | 174 | 192 | * if `expect.soft()` failed multiple times or `retry` was triggered. | 175 | 193 | */ | 176 | 194 | errors?: TestError[] | | 195 | + /** | | 196 | + * Individual results for every retry and repeat attempt. | | 197 | + */ | | 198 | + attempts?: TaskResultAttempt[] | 177 | 199 | /** | 178 | 200 | * How long in milliseconds the task took to run. | 179 | 201 | */ | |
Collapse file Copy file name to clipboardExpand all lines: test/e2e/test/reported-tasks.test.ts +92Lines changed: 92 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -276,6 +276,39 @@ it('correctly reports flaky tests', ({ testModule }) => { | 276 | 276 | const result = testFlaky.result()! | 277 | 277 | expect(result.state).toBe('passed') | 278 | 278 | expect(result.errors).toHaveLength(2) | | 279 | + expect( | | 280 | + testFlaky.attempts().map((attempt) => ({ | | 281 | + state: attempt.state, | | 282 | + errors: attempt.errors?.map((error) => error.message), | | 283 | + retryIndex: attempt.retryIndex, | | 284 | + repeatIndex: attempt.repeatIndex, | | 285 | + })), | | 286 | + ).toMatchInlineSnapshot(` | | 287 | + [ | | 288 | + { | | 289 | + "errors": [ | | 290 | + "expected +0 to be 2 // Object.is equality", | | 291 | + ], | | 292 | + "repeatIndex": 0, | | 293 | + "retryIndex": 0, | | 294 | + "state": "failed", | | 295 | + }, | | 296 | + { | | 297 | + "errors": [ | | 298 | + "expected 1 to be 2 // Object.is equality", | | 299 | + ], | | 300 | + "repeatIndex": 0, | | 301 | + "retryIndex": 1, | | 302 | + "state": "failed", | | 303 | + }, | | 304 | + { | | 305 | + "errors": undefined, | | 306 | + "repeatIndex": 0, | | 307 | + "retryIndex": 2, | | 308 | + "state": "passed", | | 309 | + }, | | 310 | + ] | | 311 | + `) | 279 | 312 | }) | 280 | 313 |
| 281 | 314 | it('correctly reports repeated tests', ({ testModule }) => { | @@ -287,6 +320,65 @@ it('correctly reports repeated tests', ({ testModule }) => { | 287 | 320 | const result = testRepeated.result()! | 288 | 321 | expect(result.state).toBe('failed') | 289 | 322 | expect(result.errors).toHaveLength(6) | | 323 | + expect( | | 324 | + testRepeated.attempts().map((attempt) => ({ | | 325 | + state: attempt.state, | | 326 | + errors: attempt.errors?.map((error) => error.message), | | 327 | + retryIndex: attempt.retryIndex, | | 328 | + repeatIndex: attempt.repeatIndex, | | 329 | + })), | | 330 | + ).toMatchInlineSnapshot(` | | 331 | + [ | | 332 | + { | | 333 | + "errors": [ | | 334 | + "expected 1 to be 2 // Object.is equality", | | 335 | + ], | | 336 | + "repeatIndex": 0, | | 337 | + "retryIndex": 0, | | 338 | + "state": "failed", | | 339 | + }, | | 340 | + { | | 341 | + "errors": [ | | 342 | + "expected 1 to be 2 // Object.is equality", | | 343 | + ], | | 344 | + "repeatIndex": 1, | | 345 | + "retryIndex": 0, | | 346 | + "state": "failed", | | 347 | + }, | | 348 | + { | | 349 | + "errors": [ | | 350 | + "expected 1 to be 2 // Object.is equality", | | 351 | + ], | | 352 | + "repeatIndex": 2, | | 353 | + "retryIndex": 0, | | 354 | + "state": "failed", | | 355 | + }, | | 356 | + { | | 357 | + "errors": [ | | 358 | + "expected 1 to be 2 // Object.is equality", | | 359 | + ], | | 360 | + "repeatIndex": 3, | | 361 | + "retryIndex": 0, | | 362 | + "state": "failed", | | 363 | + }, | | 364 | + { | | 365 | + "errors": [ | | 366 | + "expected 1 to be 2 // Object.is equality", | | 367 | + ], | | 368 | + "repeatIndex": 4, | | 369 | + "retryIndex": 0, | | 370 | + "state": "failed", | | 371 | + }, | | 372 | + { | | 373 | + "errors": [ | | 374 | + "expected 1 to be 2 // Object.is equality", | | 375 | + ], | | 376 | + "repeatIndex": 5, | | 377 | + "retryIndex": 0, | | 378 | + "state": "failed", | | 379 | + }, | | 380 | + ] | | 381 | + `) | 290 | 382 | }) | 291 | 383 |
| 292 | 384 | it('correctly passed down metadata', ({ testModule }) => { | |
|