Skip to content
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
44 changes: 44 additions & 0 deletions docs/config/detectasyncleaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: detectAsyncLeaks | Config
outline: deep
---

# detectAsyncLeaks

- **Type:** `boolean`
- **CLI:** `--detectAsyncLeaks`, `--detect-async-leaks`
- **Default:** `false`

::: warning
Enabling this option will make your tests run much slower. Use only when debugging or developing tests.
:::

Detect asynchronous resources leaking from the test file.
Uses [`node:async_hooks`](https://nodejs.org/api/async_hooks.html) to track creation of async resources. If a resource is not cleaned up, it will be logged after tests have finished.

For example if your code has `setTimeout` calls that execute the callback after tests have finished, you will see following error:

```sh
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Async Leaks 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

Timeout leaking in test/checkout-screen.test.tsx
26|
27| useEffect(() => {
28| setTimeout(() => setWindowWidth(window.innerWidth), 150)
| ^
29| })
30|
```

To fix this, you'll need to make sure your code cleans the timeout properly:

```js
useEffect(() => {
setTimeout(() => setWindowWidth(window.innerWidth), 150) // [!code --]
const timeout = setTimeout(() => setWindowWidth(window.innerWidth), 150) // [!code ++]

return function cleanup() { // [!code ++]
clearTimeout(timeout) // [!code ++]
} // [!code ++]
})
```
7 changes: 7 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,13 @@ Pass when no tests are found

Show the size of heap for each test when running in node

### detectAsyncLeaks

- **CLI:** `--detectAsyncLeaks`
- **Config:** [detectAsyncLeaks](/config/detectasyncleaks)

Detect asynchronous resources leaking from the test file (default: `false`)

### allowOnly

- **CLI:** `--allowOnly`
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ tests/test1.test.ts
tests/test2.test.ts
```

Since Vitest 4.1, you may pass `--static-parse` to [parse test files](/api/advanced/vitest#parsespecifications) instead of running them to collect tests. Vitest parses test files with limited concurrency, defaulting to `os.availableParallelism()`. You can change it via the `--static-parse-concurrency` option.

## Shell Autocompletions

Vitest provides shell autocompletions for commands, options, and option values powered by [`@bomb.sh/tab`](https://github.com/bombshell-dev/tab).
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const configDefaults: Readonly<{
}
slowTestThreshold: number
disableConsoleIntercept: boolean
detectAsyncLeaks: boolean
}> = Object.freeze({
allowOnly: !isCI,
isolate: true,
Expand Down Expand Up @@ -126,4 +127,5 @@ export const configDefaults: Readonly<{
},
slowTestThreshold: 300,
disableConsoleIntercept: false,
detectAsyncLeaks: false,
})
8 changes: 7 additions & 1 deletion packages/vitest/src/node/cli/cac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,13 @@ async function collect(mode: VitestRunMode, cliFilters: string[], options: CliOp
run: true,
}, undefined, undefined, cliFilters)
if (!options.filesOnly) {
const { testModules: tests, unhandledErrors: errors } = await ctx.collect(cliFilters.map(normalize))
const { testModules: tests, unhandledErrors: errors } = await ctx.collect(
cliFilters.map(normalize),
{
staticParse: options.staticParse,
staticParseConcurrency: options.staticParseConcurrency,
},
)

if (errors.length) {
console.error('\nThere were unhandled errors during test collection')
Expand Down
12 changes: 11 additions & 1 deletion packages/vitest/src/node/cli/cli-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,19 @@ export interface CliOptions extends UserConfig {
* Output collected test files only
*/
filesOnly?: boolean
/**
* Parse files statically instead of running them to collect tests
* @experimental
*/
staticParse?: boolean
/**
* How many tests to process at the same time
* @experimental
*/
staticParseConcurrency?: number

/**
* Override vite config's configLoader from cli.
* Override vite config's configLoader from CLI.
* Use `bundle` to bundle the config with esbuild or `runner` (experimental) to process it on the fly (default: `bundle`).
* This is only available with **vite version 6.1.0** and above.
* @experimental
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
logHeapUsage: {
description: 'Show the size of heap for each test when running in node',
},
detectAsyncLeaks: {
description: 'Detect asynchronous resources leaking from the test file (default: `false`)',
},
allowOnly: {
description:
'Allow tests and suites that are marked as only (default: `!process.env.CI`)',
Expand Down Expand Up @@ -912,6 +915,8 @@ export const cliOptionsConfig: VitestCLIOptions = {
json: null,
provide: null,
filesOnly: null,
staticParse: null,
staticParseConcurrency: null,
projects: null,
watchTriggerPatterns: null,
tags: null,
Expand Down Expand Up @@ -940,6 +945,13 @@ export const collectCliOptionsConfig: VitestCLIOptions = {
filesOnly: {
description: 'Print only test files with out the test cases',
},
staticParse: {
description: 'Parse files statically instead of running them to collect tests (default: false)',
},
staticParseConcurrency: {
description: 'How many tests to process at the same time (default: os.availableParallelism())',
argument: '<limit>',
},
changed: {
description: 'Print only tests that are affected by the changed files (default: `false`)',
argument: '[since]',
Expand Down
4 changes: 4 additions & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ export function resolveConfig(
throw new Error(`"Istanbul" coverage provider is not compatible with "experimental.viteModuleRunner: false". Please, enable "viteModuleRunner" or switch to "v8" coverage provider.`)
}

if (browser.enabled && resolved.detectAsyncLeaks) {
logger.console.warn(c.yellow('The option "detectAsyncLeaks" is not supported in browser mode and will be ignored.'))
}

const containsChromium = hasBrowserChromium(vitest, resolved)
const hasOnlyChromium = hasOnlyBrowserChromium(vitest, resolved)

Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/config/serializeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
inspect: globalConfig.inspect,
inspectBrk: globalConfig.inspectBrk,
inspector: globalConfig.inspector,
detectAsyncLeaks: globalConfig.detectAsyncLeaks,
watch: config.watch,
includeTaskLocation:
config.includeTaskLocation
Expand Down
9 changes: 8 additions & 1 deletion packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,7 @@ export class Vitest {
await this._testRun.updated(packs, events).catch(noop)
}

async collect(filters?: string[]): Promise<TestRunResult> {
async collect(filters?: string[], options?: { staticParse?: boolean; staticParseConcurrency?: number }): Promise<TestRunResult> {
return this._traces.$('vitest.collect', async (collectSpan) => {
const filenamePattern = filters && filters?.length > 0 ? filters : []
collectSpan.setAttribute('vitest.collect.filters', filenamePattern)
Expand Down Expand Up @@ -683,6 +683,13 @@ export class Vitest {
return { testModules: [], unhandledErrors: [] }
}

if (options?.staticParse) {
const testModules = await this.experimental_parseSpecifications(files, {
concurrency: options.staticParseConcurrency,
})
return { testModules, unhandledErrors: [] }
}

return this.collectTests(files)
})
}
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/node/pools/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
onUnhandledError(err, type) {
vitest.state.catchError(err, type)
},
onAsyncLeaks(leaks) {
vitest.state.catchLeaks(leaks)
},
onCancel(reason) {
vitest.cancelCurrentRun(reason)
},
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/node/printError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,14 @@ function printErrorMessage(error: TestError, logger: ErrorLogger) {
}
}

function printStack(
export function printStack(
logger: ErrorLogger,
project: TestProject,
stack: ParsedStack[],
highlight: ParsedStack | undefined,
errorProperties: Record<string, unknown>,
onStack?: (stack: ParsedStack) => void,
) {
): void {
for (const frame of stack) {
const color = frame === highlight ? c.cyan : c.gray
const path = relative(project.config.root, frame.file)
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/projects/resolveProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function resolveProjects(
// not all options are allowed to be overridden
const overridesOptions = [
'logHeapUsage',
'detectAsyncLeaks',
'allowOnly',
'sequence',
'testTimeout',
Expand Down
54 changes: 54 additions & 0 deletions packages/vitest/src/node/reporters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Vitest } from '../core'
import type { TestSpecification } from '../test-specification'
import type { Reporter, TestRunEndReason } from '../types/reporter'
import type { TestCase, TestCollection, TestModule, TestModuleState, TestResult, TestSuite, TestSuiteState } from './reported-tasks'
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { getSuites, getTestName, getTests, hasFailed } from '@vitest/runner/utils'
import { toArray } from '@vitest/utils/helpers'
Expand All @@ -14,6 +15,7 @@ import c from 'tinyrainbow'
import { groupBy } from '../../utils/base'
import { isTTY } from '../../utils/env'
import { hasFailedSnapshot } from '../../utils/tasks'
import { generateCodeFrame, printStack } from '../printError'
import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures'
import {
countTestErrors,
Expand Down Expand Up @@ -519,6 +521,7 @@ export abstract class BaseReporter implements Reporter {

reportSummary(files: File[], errors: unknown[]): void {
this.printErrorsSummary(files, errors)
this.printLeaksSummary()

if (this.ctx.config.mode === 'benchmark') {
this.reportBenchmarkSummary(files)
Expand Down Expand Up @@ -572,6 +575,12 @@ export abstract class BaseReporter implements Reporter {
)
}

const leaks = this.ctx.state.leakSet.size

if (leaks) {
this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leaks} leak${leaks > 1 ? 's' : ''}`)))
}

this.log(padSummaryTitle('Start at'), this._timeStart)

const collectTime = sum(files, file => file.collectDuration)
Expand Down Expand Up @@ -776,6 +785,51 @@ export abstract class BaseReporter implements Reporter {
}
}

private printLeaksSummary() {
const leaks = this.ctx.state.leakSet

if (leaks.size === 0) {
return
}

this.error(`\n${errorBanner(`Async Leaks ${leaks.size}`)}\n`)

for (const leak of leaks) {
const filename = this.relative(leak.filename)

this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`))

const stacks = parseStacktrace(leak.stack)

if (stacks.length === 0) {
continue
}

try {
const sourceCode = readFileSync(stacks[0].file, 'utf-8')

this.ctx.logger.error(generateCodeFrame(
sourceCode.length > 100_000
? sourceCode
: this.ctx.logger.highlight(stacks[0].file, sourceCode),
undefined,
stacks[0],
))
}
catch {
// ignore error, do not produce more detailed message with code frame.
}

printStack(
this.ctx.logger,
this.ctx.getProjectByName(leak.projectName),
stacks,
stacks[0],
{},
)
}
}

reportBenchmarkSummary(files: File[]): void {
const benches = getTests(files)
const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1)
Expand Down
8 changes: 7 additions & 1 deletion packages/vitest/src/node/state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { File, FileSpecification, Task, TaskResultPack } from '@vitest/runner'
import type { UserConsoleLog } from '../types/general'
import type { AsyncLeak, UserConsoleLog } from '../types/general'
import type { TestProject } from './project'
import type { MergedBlobs } from './reporters/blob'
import type { OnUnhandledErrorCallback } from './types/config'
Expand All @@ -22,6 +22,7 @@ export class StateManager {
idMap: Map<string, Task> = new Map()
taskFileMap: WeakMap<Task, File> = new WeakMap()
errorsSet: Set<unknown> = new Set()
leakSet: Set<AsyncLeak> = new Set()
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
blobs?: MergedBlobs
transformTime = 0
Expand Down Expand Up @@ -82,8 +83,13 @@ export class StateManager {
}
}

catchLeaks(leaks: AsyncLeak[]): void {
leaks.forEach(leak => this.leakSet.add(leak))
}

clearErrors(): void {
this.errorsSet.clear()
this.leakSet.clear()
}

getUnhandledErrors(): unknown[] {
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 @@ -601,6 +601,13 @@ export interface InlineConfig {
*/
logHeapUsage?: boolean

/**
* Detect asynchronous resources leaking from the test file.
*
* @default false
*/
detectAsyncLeaks?: boolean

/**
* Custom environment variables assigned to `process.env` before running tests.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface SerializedConfig {
}
standalone: boolean
logHeapUsage: boolean | undefined
detectAsyncLeaks: boolean
coverage: SerializedCoverageConfig
benchmark: {
includeSamples: boolean
Expand Down
Loading
Loading