| import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
|
|
| const { spawnMock, ptyKillMock, killProcessTreeMock } = vi.hoisted(() => ({ |
| spawnMock: vi.fn(), |
| ptyKillMock: vi.fn(), |
| killProcessTreeMock: vi.fn(), |
| })); |
|
|
| vi.mock("@lydell/node-pty", () => ({ |
| spawn: (...args: unknown[]) => spawnMock(...args), |
| })); |
|
|
| vi.mock("../../kill-tree.js", () => ({ |
| killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), |
| })); |
|
|
| function createStubPty(pid = 1234) { |
| let exitListener: ((event: { exitCode: number; signal?: number }) => void) | null = null; |
| return { |
| pid, |
| write: vi.fn(), |
| onData: vi.fn(() => ({ dispose: vi.fn() })), |
| onExit: vi.fn((listener: (event: { exitCode: number; signal?: number }) => void) => { |
| exitListener = listener; |
| return { dispose: vi.fn() }; |
| }), |
| kill: (signal?: string) => ptyKillMock(signal), |
| emitExit: (event: { exitCode: number; signal?: number }) => { |
| exitListener?.(event); |
| }, |
| }; |
| } |
|
|
| function expectSpawnEnv() { |
| const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: Record<string, string> }; |
| return spawnOptions?.env; |
| } |
|
|
| describe("createPtyAdapter", () => { |
| let createPtyAdapter: typeof import("./pty.js").createPtyAdapter; |
|
|
| beforeAll(async () => { |
| ({ createPtyAdapter } = await import("./pty.js")); |
| }); |
|
|
| beforeEach(() => { |
| spawnMock.mockClear(); |
| ptyKillMock.mockClear(); |
| killProcessTreeMock.mockClear(); |
| vi.useRealTimers(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| vi.clearAllMocks(); |
| }); |
|
|
| it("forwards explicit signals to node-pty kill on non-Windows", async () => { |
| const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); |
| Object.defineProperty(process, "platform", { value: "linux", configurable: true }); |
| try { |
| spawnMock.mockReturnValue(createStubPty()); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "sleep 10"], |
| }); |
|
|
| adapter.kill("SIGTERM"); |
| expect(ptyKillMock).toHaveBeenCalledWith("SIGTERM"); |
| expect(killProcessTreeMock).not.toHaveBeenCalled(); |
| } finally { |
| if (originalPlatform) { |
| Object.defineProperty(process, "platform", originalPlatform); |
| } |
| } |
| }); |
|
|
| it("uses process-tree kill for SIGKILL by default", async () => { |
| spawnMock.mockReturnValue(createStubPty()); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "sleep 10"], |
| }); |
|
|
| adapter.kill(); |
| expect(killProcessTreeMock).toHaveBeenCalledWith(1234); |
| expect(ptyKillMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("wait does not settle immediately on SIGKILL", async () => { |
| vi.useFakeTimers(); |
| spawnMock.mockReturnValue(createStubPty()); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "sleep 10"], |
| }); |
|
|
| const waitPromise = adapter.wait(); |
| const settled = vi.fn(); |
| void waitPromise.then(() => settled()); |
|
|
| adapter.kill(); |
|
|
| await Promise.resolve(); |
| expect(settled).not.toHaveBeenCalled(); |
|
|
| await vi.advanceTimersByTimeAsync(3999); |
| expect(settled).not.toHaveBeenCalled(); |
|
|
| await vi.advanceTimersByTimeAsync(1); |
| await expect(waitPromise).resolves.toEqual({ code: null, signal: "SIGKILL" }); |
| }); |
|
|
| it("prefers real PTY exit over SIGKILL fallback settle", async () => { |
| vi.useFakeTimers(); |
| const stub = createStubPty(); |
| spawnMock.mockReturnValue(stub); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "sleep 10"], |
| }); |
|
|
| const waitPromise = adapter.wait(); |
| adapter.kill(); |
| stub.emitExit({ exitCode: 0, signal: 9 }); |
|
|
| await expect(waitPromise).resolves.toEqual({ code: 0, signal: 9 }); |
|
|
| await vi.advanceTimersByTimeAsync(4_001); |
| await expect(adapter.wait()).resolves.toEqual({ code: 0, signal: 9 }); |
| }); |
|
|
| it("resolves wait when exit fires before wait is called", async () => { |
| const stub = createStubPty(); |
| spawnMock.mockReturnValue(stub); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "exit 3"], |
| }); |
|
|
| expect(stub.onExit).toHaveBeenCalledTimes(1); |
| stub.emitExit({ exitCode: 3, signal: 0 }); |
| await expect(adapter.wait()).resolves.toEqual({ code: 3, signal: null }); |
| }); |
|
|
| it("keeps inherited env when no override env is provided", async () => { |
| const stub = createStubPty(); |
| spawnMock.mockReturnValue(stub); |
|
|
| await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "env"], |
| }); |
|
|
| expect(expectSpawnEnv()).toBeUndefined(); |
| }); |
|
|
| it("passes explicit env overrides as strings", async () => { |
| const stub = createStubPty(); |
| spawnMock.mockReturnValue(stub); |
|
|
| await createPtyAdapter({ |
| shell: "bash", |
| args: ["-lc", "env"], |
| env: { FOO: "bar", COUNT: "12", DROP_ME: undefined }, |
| }); |
|
|
| expect(expectSpawnEnv()).toEqual({ FOO: "bar", COUNT: "12" }); |
| }); |
|
|
| it("does not pass a signal to node-pty on Windows", async () => { |
| const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); |
| Object.defineProperty(process, "platform", { value: "win32", configurable: true }); |
| try { |
| spawnMock.mockReturnValue(createStubPty()); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "powershell.exe", |
| args: ["-NoLogo"], |
| }); |
|
|
| adapter.kill("SIGTERM"); |
| expect(ptyKillMock).toHaveBeenCalledWith(undefined); |
| expect(killProcessTreeMock).not.toHaveBeenCalled(); |
| } finally { |
| if (originalPlatform) { |
| Object.defineProperty(process, "platform", originalPlatform); |
| } |
| } |
| }); |
|
|
| it("uses process-tree kill for SIGKILL on Windows", async () => { |
| const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); |
| Object.defineProperty(process, "platform", { value: "win32", configurable: true }); |
| try { |
| spawnMock.mockReturnValue(createStubPty(4567)); |
|
|
| const adapter = await createPtyAdapter({ |
| shell: "powershell.exe", |
| args: ["-NoLogo"], |
| }); |
|
|
| adapter.kill("SIGKILL"); |
| expect(killProcessTreeMock).toHaveBeenCalledWith(4567); |
| expect(ptyKillMock).not.toHaveBeenCalled(); |
| } finally { |
| if (originalPlatform) { |
| Object.defineProperty(process, "platform", originalPlatform); |
| } |
| } |
| }); |
| }); |
|
|