Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c6f2eab
fix(typecheck): improve error message when tsc outputs help text
Ujjwaljain16 Dec 8, 2025
5ce857b
test(typecheck): add unit tests for help text detection
Ujjwaljain16 Dec 10, 2025
441a6e9
Merge branch 'main' into fix/typecheck-tsconfig-missing-error
Ujjwaljain16 Dec 10, 2025
f8f1165
Merge branch 'main' into fix/typecheck-tsconfig-missing-error
Ujjwaljain16 Dec 12, 2025
26f4b71
fix(typecheck): fix Windows CI failure for non-existing typechecker c…
Ujjwaljain16 Dec 12, 2025
ecc15fd
fix(typecheck): move winTimeout declaration before use to fix linting…
Ujjwaljain16 Dec 12, 2025
0691f0d
test(typecheck): replace mocked tests with integration test
Ujjwaljain16 Dec 12, 2025
46bf95c
fix(typecheck): improve error message when tsconfig is missing\n\nDet…
Ujjwaljain16 Dec 17, 2025
a2d9ba2
Merge branch 'main' into fix/typecheck-tsconfig-missing-error
Ujjwaljain16 Dec 17, 2025
07b408c
style: fix linting errors
Ujjwaljain16 Dec 17, 2025
609930f
fix: use proper typecheck test file pattern
Ujjwaljain16 Dec 17, 2025
cc0a388
Merge branch 'fix/typecheck-tsconfig-missing-error' of https://github…
Ujjwaljain16 Dec 17, 2025
f09c5d6
fix: use test-d.ts pattern to trigger typecheck execution
Ujjwaljain16 Dec 17, 2025
c12e7f4
chore: remove debug log and document test approach
Ujjwaljain16 Dec 17, 2025
dfe2b54
fix lint
Ujjwaljain16 Dec 17, 2025
130d597
refactor(test): use createFile utility and static imports per review …
Ujjwaljain16 Dec 23, 2025
3468560
Merge branch 'main' into fix/typecheck-tsconfig-missing-error
Ujjwaljain16 Dec 23, 2025
7874e85
Merge branch 'main' into fix/typecheck-tsconfig-missing-error
Ujjwaljain16 Dec 23, 2025
a0e8efb
refactor: remove console.error and redundant mkdirSync per review
Ujjwaljain16 Dec 23, 2025
d5ec671
refactor: remove console.error and redundant mkdirSync per review
Ujjwaljain16 Dec 23, 2025
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
34 changes: 33 additions & 1 deletion packages/vitest/src/typecheck/typechecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ export class Typechecker {
sourceErrors: TestError[]
time: number
}> {
// Detect if tsc output is help text instead of error output
// This happens when tsconfig.json is missing and tsc can't find any config
if (output.includes('The TypeScript Compiler - Version') || output.includes('COMMON COMMANDS')) {
const { typecheck } = this.project.config
const tsconfigPath = typecheck.tsconfig || 'tsconfig.json'
throw new Error(
`TypeScript compiler returned help text instead of type checking results.\n`
+ `This usually means the tsconfig file was not found.\n\n`
+ `Possible solutions:\n`
+ ` 1. Ensure '${tsconfigPath}' exists in your project root\n`
+ ` 2. If using a custom tsconfig, verify the path in your Vitest config:\n`
+ ` test: { typecheck: { tsconfig: 'path/to/tsconfig.json' } }\n`
+ ` 3. Check that the tsconfig file is valid JSON`,
)
}

const typeErrors = await this.parseTscLikeOutput(output)
const testFiles = new Set(this.getFiles())

Expand Down Expand Up @@ -319,6 +335,8 @@ export class Typechecker {
return
}

let resolved = false

child.process.stdout.on('data', (chunk) => {
dataReceived = true
this._output += chunk
Expand All @@ -343,13 +361,25 @@ export class Typechecker {
}
})

// Also capture stderr for configuration errors like missing tsconfig
child.process.stderr?.on('data', (chunk) => {
this._output += chunk
})

const timeout = setTimeout(
() => reject(new Error(`${typecheck.checker} spawn timed out`)),
this.project.config.typecheck.spawnTimeout,
)

let winTimeout: NodeJS.Timeout | undefined

function onError(cause: Error) {
if (resolved) {
return
}
clearTimeout(timeout)
clearTimeout(winTimeout)
resolved = true
reject(new Error('Spawning typechecker failed - is typescript installed?', { cause }))
}

Expand All @@ -361,11 +391,13 @@ export class Typechecker {
// on Windows, the process might be spawned but fail to start
// we wait for a potential error here. if "close" event didn't trigger,
// we resolve the promise
setTimeout(() => {
winTimeout = setTimeout(() => {
resolved = true
resolve({ result: child })
}, 200)
}
else {
resolved = true
resolve({ result: child })
}
})
Expand Down
95 changes: 95 additions & 0 deletions test/typescript/test/typechecker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { TestProject } from '../../../packages/vitest/src/node/project'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't actually test the functionality, it mocks everything. Please, add a test following the policy from AGENTS.md:

- **No mocking policy** - You must never mock anything in tests

Create a fixture or use runInlineTests to run tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “help text” scenario cannot be integration-tested because tsc always
searches parent directories for tsconfig.json files. Even with runInlineTests,
tsc still inherits a real working directory and climbs to the Vitest monorepo
root, where tsconfig files exist.

Because of this, tsc will never emit its help text in CI or in Vitest’s test
environment, and automated tests cannot reliably trigger this condition.
@sheremet-va

Copy link
Copy Markdown
Member

@sheremet-va sheremet-va Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you just generate an invalid tsconfig file then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sheremet-va Thank you for the suggestion! I attempted the invalid tsconfig approach but ran into a technical constraint I'd like your input on.

Problem: tsc Help Text Trigger Conditions

After testing, I found that tsc only outputs help text under these conditions:

  • Invoked with zero arguments: tsc
  • Invoked with unrecognized flags: tsc --unknownFlag
  • Invoked with --help

However, Vitest's typechecker always invokes tsc with valid arguments:

// From typechecker.ts spawn()
const args = [
  '--noEmit',
  '--pretty', 'false',
  '--incremental',
  '--tsBuildInfoFile', '...',
  '-p', resolve(root, typecheck.tsconfig)
]
const child = x(typecheck.checker, args, {...})

Invalid tsconfig.json Attempts

Approach runInlineTests Config tsc Output Result
Syntactically invalid JSON 'tsconfig.json': '{ invalid }' N/A — Vite/esbuild fails first ❌ Build error before tsc runs
Empty object 'tsconfig.json': '{}' error TS18003: No inputs were found ❌ Error output, not help text
Missing compilerOptions 'tsconfig.json': '{"files":[]}' error TS18003: No inputs were found ❌ Error output, not help text

Root Cause

When tsc is invoked with -p <file> and the config file is structurally valid JSON (even if semantically incorrect), it enters normal compiler mode. In this mode, tsc emits specific diagnostic errors and never falls back to printing help text.

Help text is only produced when tsc itself is invoked with invalid or missing CLI arguments, which does not occur in Vitest’s invocation path.

Current Solution: Test Executable Stub
I implemented a temporary executable that outputs tsc help text:

// Creates cross-platform executable
const fakeTscPath = path.join(os.tmpdir(), `fake-tsc-${Date.now()}`)
fs.writeFileSync(fakeTscPath, '#!/usr/bin/env node\nconsole.log("Version 5.3.3");\n...')
fs.chmodSync(fakeTscPath, '755')

// Configure Vitest to use it
const config = `export default defineConfig({
  test: { typecheck: { checker: '${fakeTscPath}' } }
})`

Is there a specific tsconfig.json structure or tsc invocation pattern I'm missing that would trigger help text output when called with -p ?
Alternatively, is the test executable approach acceptable for this edge case, or would you prefer:

A different testing strategy you have in mind
Modifying how Vitest invokes tsc for this specific test scenario

import { describe, expect, it } from 'vitest'
import { Typechecker } from '../../../packages/vitest/src/typecheck/typechecker'

describe('Typechecker', () => {
it('detects tsc help text and throws clear error', async () => {
// Create a minimal mock project
const mockProject = {
config: {
root: '/fake/root',
typecheck: {
tsconfig: 'custom-tsconfig.json',
},
},
} as unknown as TestProject

const typechecker = new Typechecker(mockProject)
typechecker.setFiles([])

// Simulate tsc outputting help text (what happens when tsconfig is missing)
const tscHelpOutput = `tsc: The TypeScript Compiler - Version 5.9.3

COMMON COMMANDS

tsc
Compiles the current project (tsconfig.json in the working directory.)

tsc app.ts util.ts
Ignoring tsconfig.json, compiles the specified files with default compiler options.`

// The prepareResults method should detect help text and throw a clear error
await expect(
// @ts-expect-error - accessing protected method for testing
typechecker.prepareResults(tscHelpOutput),
).rejects.toThrow('TypeScript compiler returned help text instead of type checking results')

try {
// @ts-expect-error - accessing protected method for testing
await typechecker.prepareResults(tscHelpOutput)
}
catch (error: any) {
// Verify error message contains helpful information
expect(error.message).toContain('This usually means the tsconfig file was not found')
expect(error.message).toContain('custom-tsconfig.json')
expect(error.message).toContain('Possible solutions:')
}
})

it('detects help text with "COMMON COMMANDS" marker', async () => {
const mockProject = {
config: {
root: '/fake/root',
typecheck: {},
},
} as unknown as TestProject

const typechecker = new Typechecker(mockProject)
typechecker.setFiles([])

// Help text might only contain COMMON COMMANDS without version
const tscHelpOutput = `COMMON COMMANDS

tsc
Compiles the current project (tsconfig.json in the working directory.)`

await expect(
// @ts-expect-error - accessing protected method for testing
typechecker.prepareResults(tscHelpOutput),
).rejects.toThrow('TypeScript compiler returned help text')
})

it('does not throw error for normal tsc output', async () => {
const mockProject = {
config: {
root: '/fake/root',
typecheck: {},
},
} as unknown as TestProject

const typechecker = new Typechecker(mockProject)
typechecker.setFiles([])

// Normal tsc error output (not help text)
const normalTscOutput = `test.ts(5,10): error TS2322: Type 'string' is not assignable to type 'number'.`

// Should not throw for normal tsc errors
// Note: This will process the output normally
const result = await
// @ts-expect-error - accessing protected method for testing
typechecker.prepareResults(normalTscOutput)

expect(result).toBeDefined()
expect(result.files).toBeDefined()
})
})
Loading