Skip to content

Commit 284d33b

Browse files
committed
feat: add --detect-async-leaks
1 parent 328eb79 commit 284d33b

File tree

19 files changed

+257
-4
lines changed

19 files changed

+257
-4
lines changed

packages/vitest/src/defaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const configDefaults: Readonly<{
8989
}
9090
slowTestThreshold: number
9191
disableConsoleIntercept: boolean
92+
detectAsyncLeaks: boolean
9293
}> = Object.freeze({
9394
allowOnly: !isCI,
9495
isolate: true,
@@ -126,4 +127,5 @@ export const configDefaults: Readonly<{
126127
},
127128
slowTestThreshold: 300,
128129
disableConsoleIntercept: false,
130+
detectAsyncLeaks: false,
129131
})

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
423423
logHeapUsage: {
424424
description: 'Show the size of heap for each test when running in node',
425425
},
426+
detectAsyncLeaks: {
427+
description: 'Detect asynchronous resources leaking from the test file (default: `false`)',
428+
},
426429
allowOnly: {
427430
description:
428431
'Allow tests and suites that are marked as only (default: `!process.env.CI`)',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
1414
maxWorkers: config.maxWorkers,
1515
base: config.base,
1616
logHeapUsage: config.logHeapUsage,
17+
detectAsyncLeaks: config.detectAsyncLeaks,
1718
runner: config.runner,
1819
bail: config.bail,
1920
defines: config.defines,

packages/vitest/src/node/pools/rpc.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ export function createMethodsRPC(project: TestProject, methodsOptions: MethodsOp
143143
onUnhandledError(err, type) {
144144
vitest.state.catchError(err, type)
145145
},
146+
onAsyncLeaks(leaks) {
147+
vitest.state.catchLeaks(leaks)
148+
},
146149
onCancel(reason) {
147150
vitest.cancelCurrentRun(reason)
148151
},

packages/vitest/src/node/printError.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,14 +389,14 @@ function printErrorMessage(error: TestError, logger: ErrorLogger) {
389389
}
390390
}
391391

392-
function printStack(
392+
export function printStack(
393393
logger: ErrorLogger,
394394
project: TestProject,
395395
stack: ParsedStack[],
396396
highlight: ParsedStack | undefined,
397397
errorProperties: Record<string, unknown>,
398398
onStack?: (stack: ParsedStack) => void,
399-
) {
399+
): void {
400400
for (const frame of stack) {
401401
const color = frame === highlight ? c.cyan : c.gray
402402
const path = relative(project.config.root, frame.file)

packages/vitest/src/node/projects/resolveProjects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export async function resolveProjects(
4141
// not all options are allowed to be overridden
4242
const overridesOptions = [
4343
'logHeapUsage',
44+
'detectAsyncLeaks',
4445
'allowOnly',
4546
'sequence',
4647
'testTimeout',

packages/vitest/src/node/reporters/base.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Vitest } from '../core'
55
import type { TestSpecification } from '../test-specification'
66
import type { Reporter, TestRunEndReason } from '../types/reporter'
77
import type { TestCase, TestCollection, TestModule, TestModuleState, TestResult, TestSuite, TestSuiteState } from './reported-tasks'
8+
import { readFileSync } from 'node:fs'
89
import { performance } from 'node:perf_hooks'
910
import { getSuites, getTestName, getTests, hasFailed } from '@vitest/runner/utils'
1011
import { toArray } from '@vitest/utils/helpers'
@@ -14,6 +15,7 @@ import c from 'tinyrainbow'
1415
import { groupBy } from '../../utils/base'
1516
import { isTTY } from '../../utils/env'
1617
import { hasFailedSnapshot } from '../../utils/tasks'
18+
import { generateCodeFrame, printStack } from '../printError'
1719
import { F_CHECK, F_DOWN_RIGHT, F_POINTER } from './renderers/figures'
1820
import {
1921
countTestErrors,
@@ -519,6 +521,7 @@ export abstract class BaseReporter implements Reporter {
519521

520522
reportSummary(files: File[], errors: unknown[]): void {
521523
this.printErrorsSummary(files, errors)
524+
this.printLeaksSummary()
522525

523526
if (this.ctx.config.mode === 'benchmark') {
524527
this.reportBenchmarkSummary(files)
@@ -572,6 +575,12 @@ export abstract class BaseReporter implements Reporter {
572575
)
573576
}
574577

578+
const leaks = this.ctx.state.leakSet.size
579+
580+
if (leaks) {
581+
this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leaks} leak${leaks > 1 ? 's' : ''}`)))
582+
}
583+
575584
this.log(padSummaryTitle('Start at'), this._timeStart)
576585

577586
const collectTime = sum(files, file => file.collectDuration)
@@ -747,6 +756,39 @@ export abstract class BaseReporter implements Reporter {
747756
}
748757
}
749758

759+
private printLeaksSummary() {
760+
const leaks = this.ctx.state.leakSet
761+
762+
if (leaks.size) {
763+
this.error(`\n${errorBanner(`Async Leaks ${leaks.size}`)}\n`)
764+
765+
for (const leak of leaks) {
766+
const filename = this.relative(leak.filename)
767+
768+
this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`))
769+
770+
const stacks = parseStacktrace(leak.stack)
771+
772+
const sourceCode = readFileSync(stacks[0].file, 'utf-8')
773+
this.ctx.logger.error(generateCodeFrame(
774+
sourceCode.length > 100_000
775+
? sourceCode
776+
: this.ctx.logger.highlight(stacks[0].file, sourceCode),
777+
undefined,
778+
stacks[0],
779+
))
780+
781+
printStack(
782+
this.ctx.logger,
783+
this.ctx.getProjectByName(leak.projectName),
784+
stacks,
785+
stacks[0],
786+
{},
787+
)
788+
}
789+
}
790+
}
791+
750792
reportBenchmarkSummary(files: File[]): void {
751793
const benches = getTests(files)
752794
const topBenches = benches.filter(i => i.result?.benchmark?.rank === 1)

packages/vitest/src/node/state.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { File, FileSpecification, Task, TaskResultPack } from '@vitest/runner'
2-
import type { UserConsoleLog } from '../types/general'
2+
import type { AsyncLeak, UserConsoleLog } from '../types/general'
33
import type { TestProject } from './project'
44
import type { MergedBlobs } from './reporters/blob'
55
import type { OnUnhandledErrorCallback } from './types/config'
@@ -22,6 +22,7 @@ export class StateManager {
2222
idMap: Map<string, Task> = new Map()
2323
taskFileMap: WeakMap<Task, File> = new WeakMap()
2424
errorsSet: Set<unknown> = new Set()
25+
leakSet: Set<AsyncLeak> = new Set()
2526
reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
2627
blobs?: MergedBlobs
2728
transformTime = 0
@@ -82,8 +83,13 @@ export class StateManager {
8283
}
8384
}
8485

86+
catchLeaks(leaks: AsyncLeak[]): void {
87+
leaks.forEach(leak => this.leakSet.add(leak))
88+
}
89+
8590
clearErrors(): void {
8691
this.errorsSet.clear()
92+
this.leakSet.clear()
8793
}
8894

8995
getUnhandledErrors(): unknown[] {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,13 @@ export interface InlineConfig {
586586
*/
587587
logHeapUsage?: boolean
588588

589+
/**
590+
* Detect asynchronous resources leaking from the test file.
591+
*
592+
* @default false
593+
*/
594+
detectAsyncLeaks?: boolean
595+
589596
/**
590597
* Custom environment variables assigned to `process.env` before running tests.
591598
*/

packages/vitest/src/runtime/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface SerializedConfig {
112112
}
113113
standalone: boolean
114114
logHeapUsage: boolean | undefined
115+
detectAsyncLeaks: boolean
115116
coverage: SerializedCoverageConfig
116117
benchmark: {
117118
includeSamples: boolean

0 commit comments

Comments
 (0)