diff --git a/packages/vitest/src/node/pools/workers/typecheckWorker.ts b/packages/vitest/src/node/pools/workers/typecheckWorker.ts index 9120d4a30..954af48bd 100644 --- a/packages/vitest/src/node/pools/workers/typecheckWorker.ts +++ b/packages/vitest/src/node/pools/workers/typecheckWorker.ts @@ -119,11 +119,44 @@ function createRunner(vitest: Vitest) { ) } - const processError = !hasFailed(files) && !sourceErrors.length && checker.getExitCode() - if (processError) { - const error = new Error(checker.getOutput()) - error.stack = '' - vitest.state.catchError(error, 'Typecheck Error') + // The typechecker child process (tsc/vue-tsc) can terminate without producing + // a complete set of diagnostics: a non-zero exit code, or being killed by a + // signal (e.g. SIGABRT from an out-of-memory abort, which surfaces as exit + // 134). We must not report the run as passing in that case, otherwise real + // type errors slip through as a false green. + if (!hasFailed(files) && !sourceErrors.length) { + const exitCode = checker.getExitCode() + const signal = checker.getSignal() + + if (exitCode || signal) { + const output = checker.getOutput() + const looksLikeOom = signal === 'SIGABRT' + || /JavaScript heap out of memory|Reached heap limit|Allocation failed/i.test(output) + + let message: string + if (signal || looksLikeOom) { + const reason = signal + ? `was terminated by signal ${signal}` + : `exited with code ${exitCode}` + message = `The ${checker.getChecker()} process ${reason} before type checking finished.` + if (looksLikeOom) { + message += ` This usually means it ran out of memory — try increasing the ` + + `limit with NODE_OPTIONS=--max-old-space-size.` + } + if (output) { + message += `\n\n${output}` + } + } + else { + // a plain non-zero exit with diagnostics we couldn't attribute to a + // file (e.g. a tsconfig error) — surface the checker output as-is + message = output + } + + const error = new Error(message) + error.stack = '' + vitest.state.catchError(error, 'Typecheck Error') + } } promisesMap.get(project)?.resolve() diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index 55bda5fb1..efbdf934d 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -286,6 +286,14 @@ export class Typechecker { return this.process?.exitCode != null && this.process.exitCode } + public getSignal(): NodeJS.Signals | null { + return this.process?.signalCode ?? null + } + + public getChecker(): string { + return this.project.config.typecheck.checker + } + public getOutput(): string { return this._output } diff --git a/test/typescript/fixtures/typecheck-crash/fake-tsc.mjs b/test/typescript/fixtures/typecheck-crash/fake-tsc.mjs new file mode 100755 index 000000000..45d6f1801 --- /dev/null +++ b/test/typescript/fixtures/typecheck-crash/fake-tsc.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// A fake `tsc` used to simulate the type checker running out of memory. +// It ignores all arguments, prints a fatal heap-limit message the way V8 does, +// and then aborts (SIGABRT, exit 134) without emitting any diagnostics — exactly +// what happens when `tsc` OOMs. Vitest must not treat this as "no type errors". +import fs from 'node:fs' + +// small delay so the process reliably survives the spawn/grace window before it +// dies — mirrors a real type check that runs for a while and then OOMs +setTimeout(() => { + fs.writeSync( + 2, + '\n<--- Last few GCs --->\n\n' + + 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\n', + ) + process.abort() +}, 500) diff --git a/test/typescript/fixtures/typecheck-crash/test/ok.test-d.ts b/test/typescript/fixtures/typecheck-crash/test/ok.test-d.ts new file mode 100644 index 000000000..fd65644b9 --- /dev/null +++ b/test/typescript/fixtures/typecheck-crash/test/ok.test-d.ts @@ -0,0 +1,5 @@ +import { expectTypeOf, test } from 'vitest' + +test('ok', () => { + expectTypeOf(1).toEqualTypeOf() +}) diff --git a/test/typescript/fixtures/typecheck-crash/tsconfig.json b/test/typescript/fixtures/typecheck-crash/tsconfig.json new file mode 100644 index 000000000..ce6d85859 --- /dev/null +++ b/test/typescript/fixtures/typecheck-crash/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "noEmit": true, + "target": "es2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "test"], + "exclude": ["node_modules"] +} diff --git a/test/typescript/test/typechecker.test.ts b/test/typescript/test/typechecker.test.ts index 1a14beccb..cff2d96ae 100644 --- a/test/typescript/test/typechecker.test.ts +++ b/test/typescript/test/typechecker.test.ts @@ -15,4 +15,26 @@ describe('Typechecker', () => { // Should show proper error when typechecker doesn't exist expect(stderr).toContain('Spawning typechecker failed') }) + + it('fails the run when the typechecker crashes (OOM) instead of reporting a false green', async () => { + const { stderr, exitCode } = await runVitest({ + root: resolve(import.meta.dirname, '../fixtures/typecheck-crash'), + typecheck: { + enabled: true, + checker: resolve( + import.meta.dirname, + '../fixtures/typecheck-crash/fake-tsc.mjs', + ), + }, + }) + + // A checker that aborts (OOM) without producing diagnostics must NOT be + // reported as passing — the run has to fail with a clear error. The abort + // surfaces as a signal (SIGABRT) on POSIX and as exit code 134 on Windows; + // both paths must be treated as an abnormal, failing exit. + expect(exitCode).toBe(1) + expect(stderr).toContain('Typecheck Error') + expect(stderr).toContain('before type checking finished') + expect(stderr).toContain('ran out of memory') + }) })