import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { killProcessTree } from "./kill-tree.js"; const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), })); vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => spawnMock(...args), })); async function withPlatform(platform: NodeJS.Platform, run: () => Promise | T): Promise { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: platform, configurable: true }); try { return await run(); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } } describe("killProcessTree", () => { let killSpy: ReturnType; beforeEach(() => { spawnMock.mockClear(); killSpy = vi.spyOn(process, "kill"); vi.useFakeTimers(); }); afterEach(() => { killSpy.mockRestore(); vi.useRealTimers(); vi.clearAllMocks(); }); it("on Windows skips delayed force-kill when PID is already gone", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === 4242 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withPlatform("win32", async () => { killProcessTree(4242, { graceMs: 25 }); expect(spawnMock).toHaveBeenCalledTimes(1); expect(spawnMock).toHaveBeenNthCalledWith( 1, "taskkill", ["/T", "/PID", "4242"], expect.objectContaining({ detached: true, stdio: "ignore" }), ); await vi.advanceTimersByTimeAsync(25); expect(spawnMock).toHaveBeenCalledTimes(1); }); }); it("on Windows force-kills after grace period only when PID still exists", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === 5252 && signal === 0) { return true; } return true; }) as typeof process.kill); await withPlatform("win32", async () => { killProcessTree(5252, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); expect(spawnMock).toHaveBeenCalledTimes(2); expect(spawnMock).toHaveBeenNthCalledWith( 1, "taskkill", ["/T", "/PID", "5252"], expect.objectContaining({ detached: true, stdio: "ignore" }), ); expect(spawnMock).toHaveBeenNthCalledWith( 2, "taskkill", ["/F", "/T", "/PID", "5252"], expect.objectContaining({ detached: true, stdio: "ignore" }), ); }); }); it("on Unix sends SIGTERM first and skips SIGKILL when process exits", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -3333 && signal === 0) { throw new Error("ESRCH"); } if (pid === 3333 && signal === 0) { throw new Error("ESRCH"); } return true; }) as typeof process.kill); await withPlatform("linux", async () => { killProcessTree(3333, { graceMs: 10 }); await vi.advanceTimersByTimeAsync(10); expect(killSpy).toHaveBeenCalledWith(-3333, "SIGTERM"); expect(killSpy).not.toHaveBeenCalledWith(-3333, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(3333, "SIGKILL"); }); }); it("on Unix sends SIGKILL after grace period when process is still alive", async () => { killSpy.mockImplementation(((pid: number, signal?: NodeJS.Signals | number) => { if (pid === -4444 && signal === 0) { return true; } return true; }) as typeof process.kill); await withPlatform("linux", async () => { killProcessTree(4444, { graceMs: 5 }); await vi.advanceTimersByTimeAsync(5); expect(killSpy).toHaveBeenCalledWith(-4444, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(-4444, "SIGKILL"); }); }); });