Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export function resolveTestConfig(
resolved.coverage = globalConfig.coverage
resolved.attachmentsDir = globalConfig.attachmentsDir
resolved.mergeReportsLabel = globalConfig.mergeReportsLabel
resolved.watch = globalConfig.watch
}

const rootStats = statSync(resolved.root, { throwIfNoEntry: false })
Expand Down
41 changes: 40 additions & 1 deletion packages/vitest/src/typecheck/typechecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,17 @@ export class Typechecker {
}

public async stop(): Promise<void> {
this.process?.kill()
const child = this.process
this.process = undefined
if (!child) {
return
}

// the open pipes keep the main process alive even after the checker is gone
child.stdout?.destroy()
child.stderr?.destroy()

await killProcessTree(child)
}

protected async ensurePackageInstalled(ctx: Vitest, checker: string): Promise<void> {
Expand Down Expand Up @@ -343,6 +352,7 @@ export class Typechecker {
const child = x(typecheck.checker, args, {
nodeOptions: {
cwd: root,
detached: process.platform !== 'win32',
stdio: 'pipe',
},
throwOnError: false,
Expand Down Expand Up @@ -523,3 +533,32 @@ function findGeneratedPosition(traceMap: TraceMap, { line, column, source }: { l
}
return { line: null, column: null }
}

async function killProcessTree(child: ChildProcess): Promise<void> {
if (child.pid == null || child.exitCode !== null || child.signalCode !== null) {
child.kill()
return
}

// Windows has no process groups, so `taskkill` walks the tree instead
if (process.platform === 'win32') {
const killed = await x('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
nodeOptions: { stdio: 'ignore' },
throwOnError: false,
timeout: 5000,
}).then(result => result.exitCode === 0, () => false)

if (!killed) {
child.kill()
}
return
}

// `prepare` spawns detached, which makes the child the leader of its own group
try {
process.kill(-child.pid)
}
catch {
child.kill()
}
}
25 changes: 25 additions & 0 deletions test/e2e/test/config/root.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { relative, resolve } from 'pathe'
import { expect, test } from 'vitest'
import { configDefaults } from 'vitest/config'
import { resolveConfig } from 'vitest/node'
import { runVitest, ts, useFS, useTmpFS } from '#test-utils'

Expand Down Expand Up @@ -60,6 +61,30 @@ test('watch mode re-resolves `test.root` when the config changes', async () => {
await expect.poll(() => ctx?.config.root, { timeout: 5000 }).toBe(resolve(fs.root, 'nested2'))
})

test('projects inherit the root watch mode', async () => {
const fs = useTmpFS({
'./vitest.config.ts': ts`
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
projects: [{ test: { name: 'project' } }],
},
})
`,
})

const config = await resolveConfig({
config: fs.resolveFile('./vitest.config.ts'),
watch: !configDefaults.watch,
})

expect(config.test.watch).toBe(!configDefaults.watch)
expect(config.test.resolvedProjects.map(project => project.projectConfig.watch)).toEqual([
config.test.watch,
])
})

test('`--root` overrides `test.root` from the config file', async () => {
const fs = useTmpFS({
'./vitest.config.ts': testRootConfig,
Expand Down
106 changes: 104 additions & 2 deletions test/typescript/test/typechecker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'node:fs'
import { resolve } from 'pathe'
import { describe, expect, it } from 'vitest'
import { runVitest } from '../../test-utils'
import { describe, expect, it, onTestFinished } from 'vitest'
import { runInlineTests, runVitest, ts } from '../../test-utils'

describe('Typechecker', () => {
it('handles non-existing typechecker command gracefully', async () => {
Expand Down Expand Up @@ -40,4 +41,105 @@ describe('Typechecker', () => {
expect(stderr).toContain('before type checking finished')
expect(stderr).toContain('ran out of memory')
})

it('stops the typechecker process tree', async () => {
const { ctx, root } = await runInlineTests({
'vitest.config.mjs': ts`
import { chmodSync } from 'node:fs'
import { resolve } from 'node:path'

const checker = resolve(
import.meta.dirname,
process.platform === 'win32' ? 'fake-checker.cmd' : 'fake-checker.mjs',
)
if (process.platform !== 'win32') {
chmodSync(checker, 0o755)
}

export default {
test: {
typecheck: {
enabled: true,
only: true,
checker,
},
},
}
`,
'fake-checker.cmd': '@node "%~dp0fake-checker.mjs" %*',
'fake-checker.mjs': ts`#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'

const child = spawn(
process.execPath,
['-e', 'setInterval(() => {}, 1_000)'],
{ stdio: 'inherit' },
)
writeFileSync(
resolve(process.cwd(), 'checker-pids.json'),
JSON.stringify([process.pid, child.pid]),
)
process.stdout.write('Found 0 errors. Watching for file changes.\n')
setInterval(() => {}, 1_000)
`,
'test/foo.test-d.ts': '',
}, {
watch: true,
})

const pidFile = resolve(root, 'checker-pids.json')
let pids: number[] = []
onTestFinished(() => {
for (const pid of pids.reverse()) {
Comment thread
christopher-buss marked this conversation as resolved.
if (isProcessAlive(pid)) {
process.kill(pid)
}
}
})

await expect.poll(() => {
pids = readPids(pidFile)
return pids
}, { timeout: 5000 }).toHaveLength(2)

expect(pids.map(isProcessAlive)).toEqual([true, true])
await ctx!.close()
await expect.poll(
() => pids.map(isProcessAlive),
{ timeout: 5000 },
).toEqual([false, false])
})
})

function isProcessAlive(pid: number): boolean {
try {
// Sending signal 0 checks if the process exists without actually killing it:
// https://nodejs.org/api/process.html#processkillpid-signal
process.kill(pid, 0)
return true
}
catch (error) {
// ESRCH means the process is gone. Treat anything else (e.g. EPERM) as alive
// so we never reclaim a lock from a process that is still running.
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}

function readPids(pidFile: string) {
try {
const pids = JSON.parse(fs.readFileSync(pidFile, 'utf8'))
if (
Array.isArray(pids)
&& pids.length === 2
&& pids.every((pid): pid is number => Number.isInteger(pid) && pid > 0)
) {
return pids
}
}
catch {
// The checker may still be writing the file.
}
return []
}
Loading