Skip to content

feat: add onUnhandledError callback #8162

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 24, 2025
Merged
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
24 changes: 24 additions & 0 deletions docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2191,6 +2191,30 @@ export default defineConfig({
})
```

### onUnhandledError<NonProjectOption /> {#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`
Expand Down
4 changes: 3 additions & 1 deletion packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 24 additions & 11 deletions packages/vitest/src/node/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -23,31 +24,43 @@ export class StateManager {
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = 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<string, unknown>).type = type
if (typeof error === 'object' && error !== null) {
(error as Record<string, unknown>).type = type
}
else {
err = { type, message: err }
error = { type, message: error }
}

const _err = err as Record<string, any>
if (_err && typeof _err === 'object' && _err.code === 'VITEST_PENDING') {
const task = this.idMap.get(_err.taskId)
const _error = error as Record<string, any>
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 {
Expand Down
7 changes: 7 additions & 0 deletions packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<UserConfig>,
Expand Down
13 changes: 13 additions & 0 deletions test/browser/specs/unhandled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
})
6 changes: 3 additions & 3 deletions test/browser/specs/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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'

export { browser, instances, provider } from '../settings'

export async function runBrowserTests(
config?: Omit<UserConfig, 'browser'> & { browser?: Partial<UserConfig['browser']> },
config?: Omit<TestUserConfig, 'browser'> & { browser?: Partial<TestUserConfig['browser']> },
include?: string[],
viteOverrides?: Partial<ViteUserConfig>,
runnerOptions?: VitestRunnerCLIOptions,
Expand All @@ -19,6 +19,6 @@ export async function runBrowserTests(
browser: {
headless: browser !== 'safari',
...config?.browser,
} as UserConfig['browser'],
} as TestUserConfig['browser'],
}, include, 'test', viteOverrides, runnerOptions)
}
21 changes: 21 additions & 0 deletions test/cli/test/unhandled-ignore.test.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
Loading