diff --git a/packages/vitest/src/node/reporters/base.ts b/packages/vitest/src/node/reporters/base.ts index 916c06df2dab..96e1a4c97fea 100644 --- a/packages/vitest/src/node/reporters/base.ts +++ b/packages/vitest/src/node/reporters/base.ts @@ -1,6 +1,6 @@ import type { File, Task, TestAnnotation } from '@vitest/runner' -import type { SerializedError } from '@vitest/utils' -import type { TestError, UserConsoleLog } from '../../types/general' +import type { ParsedStack, SerializedError } from '@vitest/utils' +import type { AsyncLeak, TestError, UserConsoleLog } from '../../types/general' import type { Vitest } from '../core' import type { TestSpecification } from '../test-specification' import type { Reporter, TestRunEndReason } from '../types/reporter' @@ -521,17 +521,18 @@ export abstract class BaseReporter implements Reporter { reportSummary(files: File[], errors: unknown[]): void { this.printErrorsSummary(files, errors) - this.printLeaksSummary() + + const leakCount = this.printLeaksSummary() if (this.ctx.config.mode === 'benchmark') { this.reportBenchmarkSummary(files) } else { - this.reportTestSummary(files, errors) + this.reportTestSummary(files, errors, leakCount) } } - reportTestSummary(files: File[], errors: unknown[]): void { + reportTestSummary(files: File[], errors: unknown[], leakCount: number): void { this.log() const affectedFiles = [ @@ -575,10 +576,8 @@ 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' : ''}`))) + if (leakCount) { + this.log(padSummaryTitle('Leaks'), c.bold(c.red(`${leakCount} leak${leakCount > 1 ? 's' : ''}`))) } this.log(padSummaryTitle('Start at'), this._timeStart) @@ -789,22 +788,35 @@ export abstract class BaseReporter implements Reporter { const leaks = this.ctx.state.leakSet if (leaks.size === 0) { - return + return 0 } - this.error(`\n${errorBanner(`Async Leaks ${leaks.size}`)}\n`) + const leakWithStacks = new Map() + // Leaks can be duplicate, where type and position are identical 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 } + const filename = this.relative(leak.filename) + const key = `${filename}:${stacks[0].line}:${stacks[0].column}:${leak.type}` + + if (leakWithStacks.has(key)) { + continue + } + + leakWithStacks.set(key, { leak, stacks }) + } + + this.error(`\n${errorBanner(`Async Leaks ${leakWithStacks.size}`)}\n`) + + for (const { leak, stacks } of leakWithStacks.values()) { + const filename = this.relative(leak.filename) + this.ctx.logger.error(c.red(`${leak.type} leaking in ${filename}`)) + try { const sourceCode = readFileSync(stacks[0].file, 'utf-8') @@ -828,6 +840,8 @@ export abstract class BaseReporter implements Reporter { {}, ) } + + return leakWithStacks.size } reportBenchmarkSummary(files: File[]): void { diff --git a/packages/vitest/src/runtime/detect-async-leaks.ts b/packages/vitest/src/runtime/detect-async-leaks.ts index af3b71f4086b..edd81af58a81 100644 --- a/packages/vitest/src/runtime/detect-async-leaks.ts +++ b/packages/vitest/src/runtime/detect-async-leaks.ts @@ -28,15 +28,20 @@ export function detectAsyncLeaks(testFile: string, projectName: string): () => P } let stack = '' + const limit = Error.stackTraceLimit // VitestModuleEvaluator's async wrapper of node:vm causes out-of-bound stack traces, simply skip it. // Crash fixed in https://github.com/vitejs/vite/pull/21585 try { + Error.stackTraceLimit = 100 stack = new Error('VITEST_DETECT_ASYNC_LEAKS').stack || '' } catch { return } + finally { + Error.stackTraceLimit = limit + } if (!stack.includes(testFile)) { const trigger = resources.get(triggerAsyncId) diff --git a/test/cli/test/detect-async-leaks.test.ts b/test/cli/test/detect-async-leaks.test.ts index 1e4164af11f4..934ce486e2ef 100644 --- a/test/cli/test/detect-async-leaks.test.ts +++ b/test/cli/test/detect-async-leaks.test.ts @@ -170,11 +170,46 @@ test('fetch', async () => { await fetch('https://vitest.dev').then(response => response.text()) }) `, + + 'packages/example/test/example-2.test.ts': ` + import { createServer } from "node:http"; + + let setConnected = () => {} + let waitConnection = new Promise(resolve => (setConnected = resolve)) + + beforeAll(async () => { + const server = createServer((_, res) => { + setConnected(); + setTimeout(() => res.end("Hello after 10 seconds!"), 10_000).unref(); + }); + await new Promise((resolve) => server.listen(5179, resolve)); + return () => server.close(); + }); + + test("is a leak", async () => { + fetch('http://localhost:5179'); + await waitConnection; + }); + `, }) - expect.soft(stdout).not.toContain('Leak') + expect.soft(stdout).toContain('Leaks 1 leak') - expect(stderr).toBe('') + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯⎯ Async Leaks 1 ⎯⎯⎯⎯⎯⎯⎯⎯ + + PROMISE leaking in packages/example/test/example-2.test.ts + 15| + 16| test("is a leak", async () => { + 17| fetch('http://localhost:5179'); + | ^ + 18| await waitConnection; + 19| }); + ❯ packages/example/test/example-2.test.ts:17:9 + + " + `) }) test('fs handle', async () => {