diff --git a/docs/config/index.md b/docs/config/index.md index 88d11a270..3bd4e9627 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -2191,6 +2191,30 @@ export default defineConfig({ }) ``` +### onUnhandledError {#onunhandlederror} + +- **Type:** `(error: (TestError | Error) & { type: string }) => boolean | void` + +A custom handler to filter out unhandled errors that should not be reported. If an error is filtered out, it will no longer affect the test results. + +If you want unhandled errors to be reported without impacting the test outcome, consider using the [`dangerouslyIgnoreUnhandledErrors`](#dangerouslyIgnoreUnhandledErrors) option + +```ts +import type { ParsedStack } from 'vitest' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + onUnhandledError(error): boolean | void { + // Ignore all errors with the name "MySpecialError". + if (error.name === 'MySpecialError') { + return false + } + }, + }, +}) +``` + ### diff - **Type:** `string` diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index afc9a8a1f..33790a0bf 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -221,7 +221,9 @@ export class Vitest { const resolved = resolveConfig(this, options, server.config) this._config = resolved - this._state = new StateManager() + this._state = new StateManager({ + onUnhandledError: resolved.onUnhandledError, + }) this._cache = new VitestCache(this.version) this._snapshot = new SnapshotManager({ ...resolved.snapshotOptions }) this._testRun = new TestRun(this) diff --git a/packages/vitest/src/node/state.ts b/packages/vitest/src/node/state.ts index 6121cc682..ba1be9c1e 100644 --- a/packages/vitest/src/node/state.ts +++ b/packages/vitest/src/node/state.ts @@ -2,6 +2,7 @@ import type { File, Task, TaskResultPack } from '@vitest/runner' import type { UserConsoleLog } from '../types/general' import type { TestProject } from './project' import type { MergedBlobs } from './reporters/blob' +import type { OnUnhandledErrorCallback } from './types/config' import { createFileTask } from '@vitest/runner/utils' import { TestCase, TestModule, TestSuite } from './reporters/reported-tasks' @@ -23,31 +24,43 @@ export class StateManager { reportedTasksMap: WeakMap = new WeakMap() blobs?: MergedBlobs - catchError(err: unknown, type: string): void { - if (isAggregateError(err)) { - return err.errors.forEach(error => this.catchError(error, type)) + onUnhandledError?: OnUnhandledErrorCallback + + constructor( + options: { + onUnhandledError?: OnUnhandledErrorCallback + }, + ) { + this.onUnhandledError = options.onUnhandledError + } + + catchError(error: unknown, type: string): void { + if (isAggregateError(error)) { + return error.errors.forEach(error => this.catchError(error, type)) } - if (err === Object(err)) { - (err as Record).type = type + if (typeof error === 'object' && error !== null) { + (error as Record).type = type } else { - err = { type, message: err } + error = { type, message: error } } - const _err = err as Record - if (_err && typeof _err === 'object' && _err.code === 'VITEST_PENDING') { - const task = this.idMap.get(_err.taskId) + const _error = error as Record + if (_error && typeof _error === 'object' && _error.code === 'VITEST_PENDING') { + const task = this.idMap.get(_error.taskId) if (task) { task.mode = 'skip' task.result ??= { state: 'skip' } task.result.state = 'skip' - task.result.note = _err.note + task.result.note = _error.note } return } - this.errorsSet.add(err) + if (!this.onUnhandledError || this.onUnhandledError(error as any) !== false) { + this.errorsSet.add(error) + } } clearErrors(): void { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 3f70e6e00..7b74dc544 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -638,6 +638,11 @@ export interface InlineConfig { */ onStackTrace?: (error: TestError, frame: ParsedStack) => boolean | void + /** + * A callback that can return `false` to ignore an unhandled error + */ + onUnhandledError?: OnUnhandledErrorCallback + /** * Indicates if CSS files should be processed. * @@ -978,6 +983,8 @@ export interface UserConfig extends InlineConfig { mergeReports?: string } +export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void + export interface ResolvedConfig extends Omit< Required, diff --git a/test/browser/specs/unhandled.test.ts b/test/browser/specs/unhandled.test.ts index 009096da8..97ed5d109 100644 --- a/test/browser/specs/unhandled.test.ts +++ b/test/browser/specs/unhandled.test.ts @@ -14,3 +14,16 @@ test('prints correct unhandled error stack', async () => { expect(stderr).toContain('throw-unhandled-error.test.ts:9:20') } }) + +test('ignores unhandled errors', async () => { + const { stderr } = await runBrowserTests({ + root: './fixtures/unhandled', + onUnhandledError(error) { + if (error.message.includes('custom_unhandled_error')) { + return false + } + }, + }) + + expect(stderr).toBe('') +}) diff --git a/test/browser/specs/utils.ts b/test/browser/specs/utils.ts index aa47f8898..54f34597f 100644 --- a/test/browser/specs/utils.ts +++ b/test/browser/specs/utils.ts @@ -1,5 +1,5 @@ import type { UserConfig as ViteUserConfig } from 'vite' -import type { UserConfig } from 'vitest/node' +import type { TestUserConfig } from 'vitest/node' import type { VitestRunnerCLIOptions } from '../../test-utils' import { runVitest } from '../../test-utils' import { browser } from '../settings' @@ -7,7 +7,7 @@ import { browser } from '../settings' export { browser, instances, provider } from '../settings' export async function runBrowserTests( - config?: Omit & { browser?: Partial }, + config?: Omit & { browser?: Partial }, include?: string[], viteOverrides?: Partial, runnerOptions?: VitestRunnerCLIOptions, @@ -19,6 +19,6 @@ export async function runBrowserTests( browser: { headless: browser !== 'safari', ...config?.browser, - } as UserConfig['browser'], + } as TestUserConfig['browser'], }, include, 'test', viteOverrides, runnerOptions) } diff --git a/test/cli/test/unhandled-ignore.test.ts b/test/cli/test/unhandled-ignore.test.ts new file mode 100644 index 000000000..b99439f86 --- /dev/null +++ b/test/cli/test/unhandled-ignore.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest' +import { runVitest } from '../../test-utils' + +test('run mode does not get stuck when TTY', async () => { + const { vitest } = await runVitest({ + root: './fixtures/fails', + include: ['unhandled.test.ts'], + onUnhandledError(err) { + if (err.message === 'some error') { + return false + } + }, + // jsdom also prints a warning, but we don't care for our use case + onConsoleLog() { + return false + }, + }) + + // Regression #3642 + expect(vitest.stderr).toBe('') +})