diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 8e826edd88ce..cd20f1359932 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -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 }) diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index 9355db5b1fa7..23a510a9d519 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -273,8 +273,17 @@ export class Typechecker { } public async stop(): Promise { - 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 { @@ -343,6 +352,7 @@ export class Typechecker { const child = x(typecheck.checker, args, { nodeOptions: { cwd: root, + detached: process.platform !== 'win32', stdio: 'pipe', }, throwOnError: false, @@ -523,3 +533,32 @@ function findGeneratedPosition(traceMap: TraceMap, { line, column, source }: { l } return { line: null, column: null } } + +async function killProcessTree(child: ChildProcess): Promise { + 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() + } +} diff --git a/test/e2e/test/config/root.test.ts b/test/e2e/test/config/root.test.ts index 93d4b53442aa..41c845c4222a 100644 --- a/test/e2e/test/config/root.test.ts +++ b/test/e2e/test/config/root.test.ts @@ -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' @@ -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, diff --git a/test/typescript/test/typechecker.test.ts b/test/typescript/test/typechecker.test.ts index 9e53a191a3bb..805be1d61241 100644 --- a/test/typescript/test/typechecker.test.ts +++ b/test/typescript/test/typechecker.test.ts @@ -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 () => { @@ -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()) { + 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 [] +}