Skip to content

Commit 924cb69

Browse files
authored
feat: add onUnhandledError callback (#8162)
1 parent 2248b06 commit 924cb69

File tree

7 files changed

+95
-15
lines changed

7 files changed

+95
-15
lines changed

docs/config/index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2191,6 +2191,30 @@ export default defineConfig({
21912191
})
21922192
```
21932193

2194+
### onUnhandledError<NonProjectOption /> {#onunhandlederror}
2195+
2196+
- **Type:** `(error: (TestError | Error) & { type: string }) => boolean | void`
2197+
2198+
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.
2199+
2200+
If you want unhandled errors to be reported without impacting the test outcome, consider using the [`dangerouslyIgnoreUnhandledErrors`](#dangerouslyIgnoreUnhandledErrors) option
2201+
2202+
```ts
2203+
import type { ParsedStack } from 'vitest'
2204+
import { defineConfig } from 'vitest/config'
2205+
2206+
export default defineConfig({
2207+
test: {
2208+
onUnhandledError(error): boolean | void {
2209+
// Ignore all errors with the name "MySpecialError".
2210+
if (error.name === 'MySpecialError') {
2211+
return false
2212+
}
2213+
},
2214+
},
2215+
})
2216+
```
2217+
21942218
### diff
21952219

21962220
- **Type:** `string`

packages/vitest/src/node/core.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ export class Vitest {
221221
const resolved = resolveConfig(this, options, server.config)
222222

223223
this._config = resolved
224-
this._state = new StateManager()
224+
this._state = new StateManager({
225+
onUnhandledError: resolved.onUnhandledError,
226+
})
225227
this._cache = new VitestCache(this.version)
226228
this._snapshot = new SnapshotManager({ ...resolved.snapshotOptions })
227229
this._testRun = new TestRun(this)

packages/vitest/src/node/state.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { File, Task, TaskResultPack } from '@vitest/runner'
22
import type { UserConsoleLog } from '../types/general'
33
import type { TestProject } from './project'
44
import type { MergedBlobs } from './reporters/blob'
5+
import type { OnUnhandledErrorCallback } from './types/config'
56
import { createFileTask } from '@vitest/runner/utils'
67
import { TestCase, TestModule, TestSuite } from './reporters/reported-tasks'
78

@@ -23,31 +24,43 @@ export class StateManager {
2324
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
2425
blobs?: MergedBlobs
2526

26-
catchError(err: unknown, type: string): void {
27-
if (isAggregateError(err)) {
28-
return err.errors.forEach(error => this.catchError(error, type))
27+
onUnhandledError?: OnUnhandledErrorCallback
28+
29+
constructor(
30+
options: {
31+
onUnhandledError?: OnUnhandledErrorCallback
32+
},
33+
) {
34+
this.onUnhandledError = options.onUnhandledError
35+
}
36+
37+
catchError(error: unknown, type: string): void {
38+
if (isAggregateError(error)) {
39+
return error.errors.forEach(error => this.catchError(error, type))
2940
}
3041

31-
if (err === Object(err)) {
32-
(err as Record<string, unknown>).type = type
42+
if (typeof error === 'object' && error !== null) {
43+
(error as Record<string, unknown>).type = type
3344
}
3445
else {
35-
err = { type, message: err }
46+
error = { type, message: error }
3647
}
3748

38-
const _err = err as Record<string, any>
39-
if (_err && typeof _err === 'object' && _err.code === 'VITEST_PENDING') {
40-
const task = this.idMap.get(_err.taskId)
49+
const _error = error as Record<string, any>
50+
if (_error && typeof _error === 'object' && _error.code === 'VITEST_PENDING') {
51+
const task = this.idMap.get(_error.taskId)
4152
if (task) {
4253
task.mode = 'skip'
4354
task.result ??= { state: 'skip' }
4455
task.result.state = 'skip'
45-
task.result.note = _err.note
56+
task.result.note = _error.note
4657
}
4758
return
4859
}
4960

50-
this.errorsSet.add(err)
61+
if (!this.onUnhandledError || this.onUnhandledError(error as any) !== false) {
62+
this.errorsSet.add(error)
63+
}
5164
}
5265

5366
clearErrors(): void {

packages/vitest/src/node/types/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,11 @@ export interface InlineConfig {
638638
*/
639639
onStackTrace?: (error: TestError, frame: ParsedStack) => boolean | void
640640

641+
/**
642+
* A callback that can return `false` to ignore an unhandled error
643+
*/
644+
onUnhandledError?: OnUnhandledErrorCallback
645+
641646
/**
642647
* Indicates if CSS files should be processed.
643648
*
@@ -978,6 +983,8 @@ export interface UserConfig extends InlineConfig {
978983
mergeReports?: string
979984
}
980985

986+
export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void
987+
981988
export interface ResolvedConfig
982989
extends Omit<
983990
Required<UserConfig>,

test/browser/specs/unhandled.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,16 @@ test('prints correct unhandled error stack', async () => {
1414
expect(stderr).toContain('throw-unhandled-error.test.ts:9:20')
1515
}
1616
})
17+
18+
test('ignores unhandled errors', async () => {
19+
const { stderr } = await runBrowserTests({
20+
root: './fixtures/unhandled',
21+
onUnhandledError(error) {
22+
if (error.message.includes('custom_unhandled_error')) {
23+
return false
24+
}
25+
},
26+
})
27+
28+
expect(stderr).toBe('')
29+
})

test/browser/specs/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { UserConfig as ViteUserConfig } from 'vite'
2-
import type { UserConfig } from 'vitest/node'
2+
import type { TestUserConfig } from 'vitest/node'
33
import type { VitestRunnerCLIOptions } from '../../test-utils'
44
import { runVitest } from '../../test-utils'
55
import { browser } from '../settings'
66

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

99
export async function runBrowserTests(
10-
config?: Omit<UserConfig, 'browser'> & { browser?: Partial<UserConfig['browser']> },
10+
config?: Omit<TestUserConfig, 'browser'> & { browser?: Partial<TestUserConfig['browser']> },
1111
include?: string[],
1212
viteOverrides?: Partial<ViteUserConfig>,
1313
runnerOptions?: VitestRunnerCLIOptions,
@@ -19,6 +19,6 @@ export async function runBrowserTests(
1919
browser: {
2020
headless: browser !== 'safari',
2121
...config?.browser,
22-
} as UserConfig['browser'],
22+
} as TestUserConfig['browser'],
2323
}, include, 'test', viteOverrides, runnerOptions)
2424
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from 'vitest'
2+
import { runVitest } from '../../test-utils'
3+
4+
test('run mode does not get stuck when TTY', async () => {
5+
const { vitest } = await runVitest({
6+
root: './fixtures/fails',
7+
include: ['unhandled.test.ts'],
8+
onUnhandledError(err) {
9+
if (err.message === 'some error') {
10+
return false
11+
}
12+
},
13+
// jsdom also prints a warning, but we don't care for our use case
14+
onConsoleLog() {
15+
return false
16+
},
17+
})
18+
19+
// Regression #3642
20+
expect(vitest.stderr).toBe('')
21+
})

0 commit comments

Comments
 (0)