Skip to content

Commit 0e960aa

Browse files
authored
fix: throw an error if typechecker failed to spawn (#7990)
1 parent e996b41 commit 0e960aa

File tree

6 files changed

+111
-24
lines changed

6 files changed

+111
-24
lines changed

docs/config/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,6 +2258,13 @@ By default, if Vitest finds source error, it will fail test suite.
22582258

22592259
Path to custom tsconfig, relative to the project root.
22602260

2261+
#### typecheck.spawnTimeout
2262+
2263+
- **Type**: `number`
2264+
- **Default**: `10_000`
2265+
2266+
Minimum time in milliseconds it takes to spawn the typechecker.
2267+
22612268
### slowTestThreshold<NonProjectOption />
22622269

22632270
- **Type**: `number`

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ export const cliOptionsConfig: VitestCLIOptions = {
706706
argument: '<path>',
707707
normalize: true,
708708
},
709+
spawnTimeout: {
710+
description: 'Minimum time in milliseconds it takes to spawn the typechecker',
711+
argument: '<time>',
712+
},
709713
include: null,
710714
exclude: null,
711715
},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function createTypecheckPool(vitest: Vitest): ProcessPool {
101101

102102
async function startTypechecker(project: TestProject, files: string[]) {
103103
if (project.typechecker) {
104-
return project.typechecker
104+
return
105105
}
106106
const checker = await createWorkspaceTypechecker(project, files)
107107
await checker.collectTests()
@@ -154,7 +154,7 @@ export function createTypecheckPool(vitest: Vitest): ProcessPool {
154154
}
155155
promises.push(promise)
156156
promisesMap.set(project, promise)
157-
startTypechecker(project, files)
157+
promises.push(startTypechecker(project, files))
158158
}
159159

160160
await Promise.all(promises)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,11 @@ export interface TypecheckConfig {
907907
* Path to tsconfig, relative to the project root.
908908
*/
909909
tsconfig?: string
910+
/**
911+
* Minimum time in milliseconds it takes to spawn the typechecker.
912+
* @default 10_000
913+
*/
914+
spawnTimeout?: number
910915
}
911916

912917
export interface UserConfig extends InlineConfig {

packages/vitest/src/typecheck/typechecker.ts

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { File, Task, TaskEventPack, TaskResultPack, TaskState } from '@vite
33
import type { ParsedStack } from '@vitest/utils'
44
import type { EachMapping } from '@vitest/utils/source-map'
55
import type { ChildProcess } from 'node:child_process'
6+
import type { Result } from 'tinyexec'
67
import type { Vitest } from '../node/core'
78
import type { TestProject } from '../node/project'
89
import type { Awaitable } from '../types/general'
@@ -277,11 +278,7 @@ export class Typechecker {
277278
return this._output
278279
}
279280

280-
public async start(): Promise<void> {
281-
if (this.process) {
282-
return
283-
}
284-
281+
private async spawn() {
285282
const { root, watch, typecheck } = this.project.config
286283

287284
const args = [
@@ -314,31 +311,88 @@ export class Typechecker {
314311
},
315312
throwOnError: false,
316313
})
314+
317315
this.process = child.process
318-
await this._onParseStart?.()
316+
319317
let rerunTriggered = false
320-
child.process?.stdout?.on('data', (chunk) => {
321-
this._output += chunk
322-
if (!watch) {
318+
let dataReceived = false
319+
320+
return new Promise<{ result: Result }>((resolve, reject) => {
321+
if (!child.process || !child.process.stdout) {
322+
reject(new Error(`Failed to initialize ${typecheck.checker}. This is a bug in Vitest - please, open an issue with reproduction.`))
323323
return
324324
}
325-
if (this._output.includes('File change detected') && !rerunTriggered) {
326-
this._onWatcherRerun?.()
327-
this._startTime = performance.now()
328-
this._result.sourceErrors = []
329-
this._result.files = []
330-
this._tests = null // test structure might've changed
331-
rerunTriggered = true
325+
326+
child.process.stdout.on('data', (chunk) => {
327+
dataReceived = true
328+
this._output += chunk
329+
if (!watch) {
330+
return
331+
}
332+
if (this._output.includes('File change detected') && !rerunTriggered) {
333+
this._onWatcherRerun?.()
334+
this._startTime = performance.now()
335+
this._result.sourceErrors = []
336+
this._result.files = []
337+
this._tests = null // test structure might've changed
338+
rerunTriggered = true
339+
}
340+
if (/Found \w+ errors*. Watching for/.test(this._output)) {
341+
rerunTriggered = false
342+
this.prepareResults(this._output).then((result) => {
343+
this._result = result
344+
this._onParseEnd?.(result)
345+
})
346+
this._output = ''
347+
}
348+
})
349+
350+
const timeout = setTimeout(
351+
() => reject(new Error(`${typecheck.checker} spawn timed out`)),
352+
this.project.config.typecheck.spawnTimeout,
353+
)
354+
355+
function onError(cause: Error) {
356+
clearTimeout(timeout)
357+
reject(new Error('Spawning typechecker failed - is typescript installed?', { cause }))
332358
}
333-
if (/Found \w+ errors*. Watching for/.test(this._output)) {
334-
rerunTriggered = false
335-
this.prepareResults(this._output).then((result) => {
336-
this._result = result
337-
this._onParseEnd?.(result)
359+
360+
child.process.once('spawn', () => {
361+
this._onParseStart?.()
362+
child.process?.off('error', onError)
363+
clearTimeout(timeout)
364+
if (process.platform === 'win32') {
365+
// on Windows, the process might be spawned but fail to start
366+
// we wait for a potential error here. if "close" event didn't trigger,
367+
// we resolve the promise
368+
setTimeout(() => {
369+
resolve({ result: child })
370+
}, 200)
371+
}
372+
else {
373+
resolve({ result: child })
374+
}
375+
})
376+
377+
if (process.platform === 'win32') {
378+
child.process.once('close', (code) => {
379+
if (code != null && code !== 0 && !dataReceived) {
380+
onError(new Error(`The ${typecheck.checker} command exited with code ${code}.`))
381+
}
338382
})
339-
this._output = ''
340383
}
384+
child.process.once('error', onError)
341385
})
386+
}
387+
388+
public async start(): Promise<void> {
389+
if (this.process) {
390+
return
391+
}
392+
393+
const { watch } = this.project.config
394+
const { result: child } = await this.spawn()
395+
342396
if (!watch) {
343397
await child
344398
this._result = await this.prepareResults(this._output)

test/typescript/test/runner.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,20 @@ describe('when the title is dynamic', () => {
134134
expect(vitest.stdout).toContain('✓ (() => "some name")()')
135135
})
136136
})
137+
138+
it('throws an error if typechecker process exists', async () => {
139+
const { stderr } = await runVitest({
140+
root: resolve(__dirname, '../fixtures/source-error'),
141+
typecheck: {
142+
enabled: true,
143+
checker: 'non-existing-command',
144+
},
145+
})
146+
expect(stderr).toContain('Error: Spawning typechecker failed - is typescript installed?')
147+
if (process.platform === 'win32') {
148+
expect(stderr).toContain('Error: The non-existing-command command exited with code 1.')
149+
}
150+
else {
151+
expect(stderr).toContain('Error: spawn non-existing-command ENOENT')
152+
}
153+
})

0 commit comments

Comments
 (0)