From b63653f5a24dabe5fee6ee4e2599f20716e892e3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 16 Feb 2026 23:31:16 +0900 Subject: [PATCH 1/3] fix: don't propagate nested `aroundEach/All` errors but aggregate them on runner (#9673) --- packages/runner/src/run.ts | 20 +- test/cli/test/around-each.test.ts | 481 +++++++++++++++++++++++++++++- 2 files changed, 481 insertions(+), 20 deletions(-) diff --git a/packages/runner/src/run.ts b/packages/runner/src/run.ts index 5f0cd7c7bb2e..57688d9ad971 100644 --- a/packages/runner/src/run.ts +++ b/packages/runner/src/run.ts @@ -267,6 +267,8 @@ async function callAroundHooks( return } + const hookErrors: unknown[] = [] + const createTimeoutPromise = ( timeout: number, phase: 'setup' | 'teardown', @@ -352,23 +354,13 @@ async function callAroundHooks( setupTimeout.clear() // Run inner hooks - don't time this against our teardown timeout - let nextError: { value: unknown } | undefined - try { - await runNextHook(index + 1) - } - catch (value) { - nextError = { value } - } + await runNextHook(index + 1).catch(e => hookErrors.push(e)) // Start teardown timer after inner hooks complete - only times this hook's teardown code teardownTimeout = createTimeoutPromise(timeout, 'teardown', stackTraceError) // Signal that use() is returning (teardown phase starting) resolveUseReturned() - - if (nextError) { - throw nextError.value - } } // Start setup timeout @@ -422,7 +414,11 @@ async function callAroundHooks( } } - await runNextHook(0) + await runNextHook(0).catch(e => hookErrors.push(e)) + + if (hookErrors.length > 0) { + throw hookErrors + } } async function callAroundAllHooks( diff --git a/test/cli/test/around-each.test.ts b/test/cli/test/around-each.test.ts index 0c52e77e4902..e57d8e4748eb 100644 --- a/test/cli/test/around-each.test.ts +++ b/test/cli/test/around-each.test.ts @@ -920,7 +920,8 @@ test('multiple aroundEach hooks with different timeouts', async () => { expect(extractLogs(stdout)).toMatchInlineSnapshot(` ">> outer setup - >> inner setup start" + >> inner setup start + >> outer teardown" `) expect(stderr).toMatchInlineSnapshot(` " @@ -982,7 +983,8 @@ test('multiple aroundEach hooks where inner teardown times out', async () => { ">> outer setup >> inner setup >> test - >> inner teardown start" + >> inner teardown start + >> outer teardown" `) expect(stderr).toMatchInlineSnapshot(` " @@ -1893,7 +1895,7 @@ test('tests are skipped when aroundAll setup fails', async () => { `) }) -test('aroundEach teardown timeout works when runTest error is caught', async () => { +test('aroundEach teardown timeout works when inner fails', async () => { const { stderr, errorTree } = await runInlineTests({ 'caught-inner-error-timeout.test.ts': ` import { aroundEach, afterAll, describe, expect, test } from 'vitest' @@ -1901,7 +1903,7 @@ test('aroundEach teardown timeout works when runTest error is caught', async () let errorCaught = false afterAll(() => { - expect(errorCaught).toBe(true) + expect(errorCaught).toBe(false) }) describe('suite', () => { @@ -1932,6 +1934,18 @@ test('aroundEach teardown timeout works when runTest error is caught', async () " ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + FAIL caught-inner-error-timeout.test.ts > suite > test + Error: inner aroundEach teardown failure + ❯ caught-inner-error-timeout.test.ts:24:17 + 22| aroundEach(async (runTest) => { + 23| await runTest() + 24| throw new Error('inner aroundEach teardown failure') + | ^ + 25| }) + 26| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + FAIL caught-inner-error-timeout.test.ts > suite > test AroundHookTeardownError: The teardown phase of "aroundEach" hook timed out after 50ms. ❯ caught-inner-error-timeout.test.ts:11:9 @@ -1942,7 +1956,7 @@ test('aroundEach teardown timeout works when runTest error is caught', async () 12| try { 13| await runTest() - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ " `) @@ -1951,6 +1965,7 @@ test('aroundEach teardown timeout works when runTest error is caught', async () "caught-inner-error-timeout.test.ts": { "suite": { "test": [ + "inner aroundEach teardown failure", "The teardown phase of "aroundEach" hook timed out after 50ms.", ], }, @@ -1959,7 +1974,7 @@ test('aroundEach teardown timeout works when runTest error is caught', async () `) }) -test('aroundAll teardown timeout works when runTest error is caught', async () => { +test('aroundAll teardown timeout works when inner fails', async () => { const { stderr, errorTree } = await runInlineTests({ 'caught-inner-error-timeout.test.ts': ` import { aroundAll, afterAll, describe, expect, test } from 'vitest' @@ -1967,7 +1982,7 @@ test('aroundAll teardown timeout works when runTest error is caught', async () = let errorCaught = false afterAll(() => { - expect(errorCaught).toBe(true) + expect(errorCaught).toBe(false) }) describe('suite', () => { @@ -1998,6 +2013,18 @@ test('aroundAll teardown timeout works when runTest error is caught', async () = " ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + FAIL caught-inner-error-timeout.test.ts > suite + Error: inner aroundAll teardown failure + ❯ caught-inner-error-timeout.test.ts:24:17 + 22| aroundAll(async (runTest) => { + 23| await runTest() + 24| throw new Error('inner aroundAll teardown failure') + | ^ + 25| }) + 26| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + FAIL caught-inner-error-timeout.test.ts > suite AroundHookTeardownError: The teardown phase of "aroundAll" hook timed out after 50ms. ❯ caught-inner-error-timeout.test.ts:11:9 @@ -2008,7 +2035,7 @@ test('aroundAll teardown timeout works when runTest error is caught', async () = 12| try { 13| await runTest() - ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ " `) @@ -2160,3 +2187,441 @@ test('aroundAll aborts late runSuite after setup timeout', async () => { } `) }) + +test('nested aroundEach setup error is not propagated to outer runTest catch', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'nested-around-each-setup-error.test.ts': ` + import { aroundEach, test } from 'vitest' + + aroundEach(async (runTest) => { + console.log('>> outer setup') + try { + await runTest() + } + catch (error) { + console.log('>> outer caught', String(error)) + } + console.log('>> outer teardown') + }) + + aroundEach(async (_runTest) => { + console.log('>> inner setup') + throw new Error('inner aroundEach setup error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL nested-around-each-setup-error.test.ts > repro + Error: inner aroundEach setup error + ❯ nested-around-each-setup-error.test.ts:17:15 + 15| aroundEach(async (_runTest) => { + 16| console.log('>> inner setup') + 17| throw new Error('inner aroundEach setup error') + | ^ + 18| }) + 19| + ❯ nested-around-each-setup-error.test.ts:7:17 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> inner setup + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "nested-around-each-setup-error.test.ts": { + "repro": [ + "inner aroundEach setup error", + ], + }, + } + `) +}) + +test('nested aroundEach teardown error is not propagated to outer runTest catch', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'nested-around-each-teardown-error.test.ts': ` + import { aroundEach, test } from 'vitest' + + aroundEach(async (runTest) => { + console.log('>> outer setup') + try { + await runTest() + } + catch (error) { + console.log('>> outer caught', String(error)) + } + console.log('>> outer teardown') + }) + + aroundEach(async (runTest) => { + console.log('>> inner setup') + await runTest() + console.log('>> inner teardown') + throw new Error('inner aroundEach teardown error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL nested-around-each-teardown-error.test.ts > repro + Error: inner aroundEach teardown error + ❯ nested-around-each-teardown-error.test.ts:19:15 + 17| await runTest() + 18| console.log('>> inner teardown') + 19| throw new Error('inner aroundEach teardown error') + | ^ + 20| }) + 21| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> inner setup + >> test body + >> inner teardown + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "nested-around-each-teardown-error.test.ts": { + "repro": [ + "inner aroundEach teardown error", + ], + }, + } + `) +}) + +test('nested aroundAll setup error is not propagated to outer runSuite catch', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'nested-around-all-setup-error.test.ts': ` + import { aroundAll, test } from 'vitest' + + aroundAll(async (runSuite) => { + console.log('>> outer setup') + try { + await runSuite() + } + catch (error) { + console.log('>> outer caught', String(error)) + } + console.log('>> outer teardown') + }) + + aroundAll(async (_runSuite) => { + console.log('>> inner setup') + throw new Error('inner aroundAll setup error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL nested-around-all-setup-error.test.ts [ nested-around-all-setup-error.test.ts ] + Error: inner aroundAll setup error + ❯ nested-around-all-setup-error.test.ts:17:15 + 15| aroundAll(async (_runSuite) => { + 16| console.log('>> inner setup') + 17| throw new Error('inner aroundAll setup error') + | ^ + 18| }) + 19| + ❯ nested-around-all-setup-error.test.ts:7:17 + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> inner setup + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "nested-around-all-setup-error.test.ts": { + "repro": "skipped", + }, + } + `) +}) + +test('nested aroundAll teardown error is not propagated to outer runSuite catch', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'nested-around-all-teardown-error.test.ts': ` + import { aroundAll, test } from 'vitest' + + aroundAll(async (runSuite) => { + console.log('>> outer setup') + try { + await runSuite() + } + catch (error) { + console.log('>> outer caught', String(error)) + } + console.log('>> outer teardown') + }) + + aroundAll(async (runSuite) => { + console.log('>> inner setup') + await runSuite() + console.log('>> inner teardown') + throw new Error('inner aroundAll teardown error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL nested-around-all-teardown-error.test.ts [ nested-around-all-teardown-error.test.ts ] + Error: inner aroundAll teardown error + ❯ nested-around-all-teardown-error.test.ts:19:15 + 17| await runSuite() + 18| console.log('>> inner teardown') + 19| throw new Error('inner aroundAll teardown error') + | ^ + 20| }) + 21| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> inner setup + >> test body + >> inner teardown + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "nested-around-all-teardown-error.test.ts": { + "repro": "passed", + }, + } + `) +}) + +test('three nested aroundEach teardown errors are all reported', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'triple-around-each-teardown-errors.test.ts': ` + import { aroundEach, test } from 'vitest' + + aroundEach(async (runTest) => { + console.log('>> outer setup') + await runTest() + console.log('>> outer teardown') + throw new Error('outer aroundEach teardown error') + }) + + aroundEach(async (runTest) => { + console.log('>> middle setup') + await runTest() + console.log('>> middle teardown') + throw new Error('middle aroundEach teardown error') + }) + + aroundEach(async (runTest) => { + console.log('>> inner setup') + await runTest() + console.log('>> inner teardown') + throw new Error('inner aroundEach teardown error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL triple-around-each-teardown-errors.test.ts > repro + Error: inner aroundEach teardown error + ❯ triple-around-each-teardown-errors.test.ts:22:15 + 20| await runTest() + 21| console.log('>> inner teardown') + 22| throw new Error('inner aroundEach teardown error') + | ^ + 23| }) + 24| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL triple-around-each-teardown-errors.test.ts > repro + Error: middle aroundEach teardown error + ❯ triple-around-each-teardown-errors.test.ts:15:15 + 13| await runTest() + 14| console.log('>> middle teardown') + 15| throw new Error('middle aroundEach teardown error') + | ^ + 16| }) + 17| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL triple-around-each-teardown-errors.test.ts > repro + Error: outer aroundEach teardown error + ❯ triple-around-each-teardown-errors.test.ts:8:15 + 6| await runTest() + 7| console.log('>> outer teardown') + 8| throw new Error('outer aroundEach teardown error') + | ^ + 9| }) + 10| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> middle setup + >> inner setup + >> test body + >> inner teardown + >> middle teardown + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "triple-around-each-teardown-errors.test.ts": { + "repro": [ + "inner aroundEach teardown error", + "middle aroundEach teardown error", + "outer aroundEach teardown error", + ], + }, + } + `) +}) + +test('three nested aroundAll teardown errors are all reported', async () => { + const { stdout, stderr, errorTree } = await runInlineTests({ + 'triple-around-all-teardown-errors.test.ts': ` + import { aroundAll, test } from 'vitest' + + aroundAll(async (runSuite) => { + console.log('>> outer setup') + await runSuite() + console.log('>> outer teardown') + throw new Error('outer aroundAll teardown error') + }) + + aroundAll(async (runSuite) => { + console.log('>> middle setup') + await runSuite() + console.log('>> middle teardown') + throw new Error('middle aroundAll teardown error') + }) + + aroundAll(async (runSuite) => { + console.log('>> inner setup') + await runSuite() + console.log('>> inner teardown') + throw new Error('inner aroundAll teardown error') + }) + + test('repro', () => { + console.log('>> test body') + }) + `, + }) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL triple-around-all-teardown-errors.test.ts [ triple-around-all-teardown-errors.test.ts ] + Error: inner aroundAll teardown error + ❯ triple-around-all-teardown-errors.test.ts:22:15 + 20| await runSuite() + 21| console.log('>> inner teardown') + 22| throw new Error('inner aroundAll teardown error') + | ^ + 23| }) + 24| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ + + FAIL triple-around-all-teardown-errors.test.ts [ triple-around-all-teardown-errors.test.ts ] + Error: middle aroundAll teardown error + ❯ triple-around-all-teardown-errors.test.ts:15:15 + 13| await runSuite() + 14| console.log('>> middle teardown') + 15| throw new Error('middle aroundAll teardown error') + | ^ + 16| }) + 17| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯ + + FAIL triple-around-all-teardown-errors.test.ts [ triple-around-all-teardown-errors.test.ts ] + Error: outer aroundAll teardown error + ❯ triple-around-all-teardown-errors.test.ts:8:15 + 6| await runSuite() + 7| console.log('>> outer teardown') + 8| throw new Error('outer aroundAll teardown error') + | ^ + 9| }) + 10| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯ + + " + `) + expect(extractLogs(stdout)).toMatchInlineSnapshot(` + ">> outer setup + >> middle setup + >> inner setup + >> test body + >> inner teardown + >> middle teardown + >> outer teardown" + `) + expect(errorTree()).toMatchInlineSnapshot(` + { + "triple-around-all-teardown-errors.test.ts": { + "repro": "passed", + }, + } + `) +}) From 7ef5cf4b78af32c4ac8cd8b68195331ad445a6b0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 16 Feb 2026 15:45:32 +0100 Subject: [PATCH 2/3] fix: show a better error if there is a pending dynamic import (#9676) --- .../vitest/src/integrations/env/loader.ts | 5 ++-- packages/vitest/src/node/logger.ts | 2 +- .../src/runtime/moduleRunner/moduleRunner.ts | 8 ++++- .../runtime/moduleRunner/moduleTransport.ts | 29 ++++++++++++++++--- packages/vitest/src/runtime/utils.ts | 4 +++ packages/vitest/src/runtime/worker.ts | 3 +- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/vitest/src/integrations/env/loader.ts b/packages/vitest/src/integrations/env/loader.ts index 3c761c8e7d29..cd06d913a81e 100644 --- a/packages/vitest/src/integrations/env/loader.ts +++ b/packages/vitest/src/integrations/env/loader.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs' import { isBuiltin } from 'node:module' import { pathToFileURL } from 'node:url' import { resolve } from 'pathe' -import { ModuleRunner } from 'vite/module-runner' +import { EvaluatedModules, ModuleRunner } from 'vite/module-runner' import { VitestTransport } from '../../runtime/moduleRunner/moduleTransport' import { environments } from './index' @@ -24,6 +24,7 @@ export function createEnvironmentLoader(root: string, rpc: WorkerRPC): ModuleRun if (!cachedLoader || cachedLoader.isClosed()) { _loaders.delete(root) + const evaluatedModules = new EvaluatedModules() const moduleRunner = new ModuleRunner({ hmr: false, sourcemapInterceptor: 'prepareStackTrace', @@ -46,7 +47,7 @@ export function createEnvironmentLoader(root: string, rpc: WorkerRPC): ModuleRun async resolveId(id, importer) { return rpc.resolve(id, importer, '__vitest__') }, - }), + }, evaluatedModules, new WeakMap()), }) _loaders.set(root, moduleRunner) } diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index f15635e152b3..c96cb73dc921 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -301,7 +301,7 @@ export class Logger { this.error(errorMessage) errors.forEach((err) => { this.printError(err, { - fullStack: true, + fullStack: (err as any).name !== 'EnvironmentTeardownError', type: (err as any).type || 'Unhandled Error', }) }) diff --git a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts index 6f7eb54ddbd7..af3093baed71 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleRunner.ts @@ -49,11 +49,13 @@ export class VitestModuleRunner public mocker: VitestMocker public moduleExecutionInfo: ModuleExecutionInfo private _otel: Traces + private _callstacks: WeakMap constructor(private vitestOptions: VitestModuleRunnerOptions) { const options = vitestOptions - const transport = new VitestTransport(options.transport) const evaluatedModules = options.evaluatedModules + const callstacks = new WeakMap() + const transport = new VitestTransport(options.transport, evaluatedModules, callstacks) super( { transport, @@ -64,6 +66,7 @@ export class VitestModuleRunner }, options.evaluator, ) + this._callstacks = callstacks this._otel = vitestOptions.traces || new Traces({ enabled: false }) this.moduleExecutionInfo = options.getWorkerState().moduleExecutionInfo this.mocker = options.mocker || new VitestMocker(this, { @@ -153,6 +156,9 @@ export class VitestModuleRunner metadata?: SSRImportMetadata, ignoreMock = false, ): Promise { + // Track for a better error message if dynamic import is not resolved properly + this._callstacks.set(mod, callstack) + if (ignoreMock) { return this._cachedRequest(url, mod, callstack, metadata) } diff --git a/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts b/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts index 90fe7dc728b8..75398656ffb2 100644 --- a/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts +++ b/packages/vitest/src/runtime/moduleRunner/moduleTransport.ts @@ -1,5 +1,6 @@ -import type { FetchFunction, ModuleRunnerTransport } from 'vite/module-runner' +import type { EvaluatedModuleNode, EvaluatedModules, FetchFunction, ModuleRunnerTransport } from 'vite/module-runner' import type { ResolveFunctionResult } from '../../types/general' +import { EnvironmentTeardownError } from '../utils' export interface VitestTransportOptions { fetchModule: FetchFunction @@ -7,7 +8,11 @@ export interface VitestTransportOptions { } export class VitestTransport implements ModuleRunnerTransport { - constructor(private options: VitestTransportOptions) {} + constructor( + private options: VitestTransportOptions, + private evaluatedModules: EvaluatedModules, + private callstacks: WeakMap, + ) {} async invoke(event: any): Promise<{ result: any } | { error: any }> { if (event.type !== 'custom') { @@ -29,8 +34,24 @@ export class VitestTransport implements ModuleRunnerTransport { const result = await this.options.fetchModule(...data as Parameters) return { result } } - catch (error) { - return { error } + catch (cause) { + if (cause instanceof EnvironmentTeardownError) { + const [id, importer] = data as Parameters + let message = `Cannot load '${id}'${importer ? ` imported from ${importer}` : ''} after the environment was torn down. ` + + `This is not a bug in Vitest.` + + const moduleNode = importer ? this.evaluatedModules.getModuleById(importer) : undefined + const callstack = moduleNode ? this.callstacks.get(moduleNode) : undefined + if (callstack) { + message += ` The last recorded callstack:\n- ${[...callstack, importer, id].reverse().join('\n- ')}` + } + const error = new EnvironmentTeardownError(message) + if (cause.stack) { + error.stack = cause.stack.replace(cause.message, error.message) + } + return { error } + } + return { error: cause } } } } diff --git a/packages/vitest/src/runtime/utils.ts b/packages/vitest/src/runtime/utils.ts index fcf660bda1af..e19c86bb9fe5 100644 --- a/packages/vitest/src/runtime/utils.ts +++ b/packages/vitest/src/runtime/utils.ts @@ -4,6 +4,10 @@ import { getSafeTimers } from '@vitest/utils/timers' const NAME_WORKER_STATE = '__vitest_worker__' +export class EnvironmentTeardownError extends Error { + name = 'EnvironmentTeardownError' +} + export function getWorkerState(): WorkerGlobalState { // @ts-expect-error untyped global const workerState = globalThis[NAME_WORKER_STATE] diff --git a/packages/vitest/src/runtime/worker.ts b/packages/vitest/src/runtime/worker.ts index 62888bf6cf92..b08b8e6dc20a 100644 --- a/packages/vitest/src/runtime/worker.ts +++ b/packages/vitest/src/runtime/worker.ts @@ -6,6 +6,7 @@ import { setupInspect } from './inspector' import * as listeners from './listeners' import { VitestEvaluatedModules } from './moduleRunner/evaluatedModules' import { onCancel, rpcDone } from './rpc' +import { EnvironmentTeardownError } from './utils' const resolvingModules = new Set() @@ -21,7 +22,7 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC, worker: Vites // do not close the RPC channel so that we can get the error messages sent to the main thread cleanups.push(async () => { await Promise.all(rpc.$rejectPendingCalls(({ method, reject }) => { - reject(new Error(`[vitest-worker]: Closing rpc while "${method}" was pending`)) + reject(new EnvironmentTeardownError(`[vitest-worker]: Closing rpc while "${method}" was pending`)) })) }) From 619179fb741c1b63a8bb983843ff6758de1333f3 Mon Sep 17 00:00:00 2001 From: Thor Juhasz Date: Mon, 16 Feb 2026 17:18:20 +0100 Subject: [PATCH 3/3] feat: implement `mockThrow` and `mockThrowOnce` (#9512) Co-authored-by: Vladimir Sheremet --- docs/api/mock.md | 34 ++++++++++++ packages/spy/src/index.ts | 14 +++++ packages/spy/src/types.ts | 22 ++++++++ test/core/test/mocking/vi-fn.test.ts | 82 ++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/docs/api/mock.md b/docs/api/mock.md index 4b9e6aefb328..b1fbce45cd47 100644 --- a/docs/api/mock.md +++ b/docs/api/mock.md @@ -418,6 +418,40 @@ const myMockFn = vi console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn()) ``` +## mockThrow 4.1.0 {#mockthrow} + +```ts +function mockThrow(value: unknown): Mock +``` + +Accepts a value that will be thrown whenever the mock function is called. + +```ts +const myMockFn = vi.fn() +myMockFn.mockThrow(new Error('error message')) +myMockFn() // throws Error<'error message'> +``` + +## mockThrowOnce 4.1.0 {#mockthrowonce} + +```ts +function mockThrowOnce(value: unknown): Mock +``` + +Accepts a value that will be thrown during the next function call. If chained, every consecutive call will throw the specified value. + +```ts +const myMockFn = vi + .fn() + .mockReturnValue('default') + .mockThrowOnce(new Error('first call error')) + .mockThrowOnce('second call error') + +expect(() => myMockFn()).toThrow('first call error') +expect(() => myMockFn()).toThrow('second call error') +expect(myMockFn()).toEqual('default') +``` + ## mock.calls ```ts diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts index 0b20accc9803..80a1e48bd6b0 100644 --- a/packages/spy/src/index.ts +++ b/packages/spy/src/index.ts @@ -140,6 +140,20 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock e * console.log(myMockFn(), myMockFn(), myMockFn()) */ mockReturnValueOnce(value: MockReturnType): this + /** + * Accepts a value that will be thrown whenever the mock function is called. + * @see https://vitest.dev/api/mock#mockthrow + * @example + * const myMockFn = vi.fn().mockThrow(new Error('error')) + * myMockFn() // throws 'error' + */ + mockThrow(value: unknown): this + /** + * Accepts a value that will be thrown during the next function call. If chained, every consecutive call will throw the specified value. + * @example + * const myMockFn = vi + * .fn() + * .mockReturnValue('default') + * .mockThrowOnce(new Error('first call error')) + * .mockThrowOnce('second call error') + * + * expect(() => myMockFn()).toThrowError('first call error') + * expect(() => myMockFn()).toThrowError('second call error') + * expect(myMockFn()).toEqual('default') + */ + mockThrowOnce(value: unknown): this /** * Accepts a value that will be resolved when the async function is called. TypeScript will only accept values that match the return type of the original function. * @example diff --git a/test/core/test/mocking/vi-fn.test.ts b/test/core/test/mocking/vi-fn.test.ts index f3269ee3d637..71e3d5e8f4af 100644 --- a/test/core/test/mocking/vi-fn.test.ts +++ b/test/core/test/mocking/vi-fn.test.ts @@ -519,6 +519,88 @@ describe('vi.fn() implementations', () => { expect(mock()).toBe(undefined) }) + test('vi.fn() with mockThrow', async () => { + const mock = vi.fn() + mock.mockThrow(new Error('error')) + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + mock.mockReset() + expect(mock()).toBe(undefined) + }) + + test('vi.fn(class) with mockThrow', async () => { + const Mock = vi.fn(class {}) + Mock.mockThrow(new Error('error')) + expect(() => new Mock()).toThrow('error') + expect(() => new Mock()).toThrow('error') + expect(() => new Mock()).toThrow('error') + Mock.mockReset() + expect(new Mock()).toBeInstanceOf(Mock) + }) + + test('vi.fn() with mockThrow overriding original mock', async () => { + const mock = vi.fn(() => 42) + mock.mockThrow(new Error('error')) + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + mock.mockReset() + expect(mock()).toBe(42) + }) + + test('vi.fn() with mockThrow overriding another mock', async () => { + const mock = vi.fn().mockImplementation(() => 42) + mock.mockThrow(new Error('error')) + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + expect(() => mock()).toThrow('error') + mock.mockReset() + expect(mock()).toBe(undefined) + }) + + test('vi.fn() with mockThrowOnce', async () => { + const mock = vi.fn() + mock.mockThrowOnce(new Error('error')) + expect(() => mock()).toThrow('error') + expect(mock()).toBe(undefined) + expect(mock()).toBe(undefined) + mock.mockThrowOnce(new Error('error')) + mock.mockReset() + expect(mock()).toBe(undefined) + }) + + test('vi.fn(class) with mockThrowOnce', async () => { + const Mock = vi.fn(class {}) + Mock.mockThrowOnce(new Error('error')) + expect(() => new Mock()).toThrow('error') + expect(new Mock()).toBeInstanceOf(Mock) + expect(new Mock()).toBeInstanceOf(Mock) + Mock.mockThrowOnce(new Error('error')) + Mock.mockReset() + expect(new Mock()).toBeInstanceOf(Mock) + }) + + test('vi.fn() with mockThrowOnce overriding original mock', async () => { + const mock = vi.fn(() => 42) + mock.mockThrowOnce(new Error('error')) + expect(() => mock()).toThrow('error') + expect(mock()).toBe(42) + expect(mock()).toBe(42) + mock.mockReset() + expect(mock()).toBe(42) + }) + + test('vi.fn() with mockThrowOnce overriding another mock', async () => { + const mock = vi.fn().mockImplementation(() => 42) + mock.mockThrowOnce(new Error('error')) + expect(() => mock()).toThrow('error') + expect(mock()).toBe(42) + expect(mock()).toBe(42) + mock.mockReset() + expect(mock()).toBe(undefined) + }) + test('vi.fn() with mockResolvedValue', async () => { const mock = vi.fn() mock.mockResolvedValue(42)