import { afterEach, describe, expect, it, type Mock, vi } from "bun:test"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; interface SuspendCtx { ctx: InteractiveModeContext; ui: { start: Mock<() => void>; stop: Mock<() => void>; requestRender: Mock<(force?: boolean) => void>; }; showStatus: Mock<(message: string) => void>; showError: Mock<(message: string) => void>; } function createCtx(): SuspendCtx { const ui = { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn(), }; const showStatus = vi.fn(); const showError = vi.fn(); const ctx = { ui: ui as unknown as InteractiveModeContext["ui"], showStatus, showError, } as unknown as InteractiveModeContext; return { ctx, ui, showStatus, showError }; } const originalPlatform = process.platform; function setPlatform(value: NodeJS.Platform): void { Object.defineProperty(process, "platform", { value, configurable: true, writable: true }); } afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true }); vi.restoreAllMocks(); // Drop any SIGCONT listener a passing test left behind so a later test // (or the next file) doesn't get spurious callbacks. process.removeAllListeners("SIGCONT"); }); describe("InputController.handleCtrlZ", () => { it("no-ops on Windows so the unsupported SIGSTOP signal can't crash the process (#2036)", () => { setPlatform("win32"); const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { throw new Error("process.kill must not be called on win32"); }); const onceSpy = vi.spyOn(process, "once"); const { ctx, ui, showStatus, showError } = createCtx(); const controller = new InputController(ctx); expect(() => controller.handleCtrlZ()).not.toThrow(); expect(killSpy).not.toHaveBeenCalled(); expect(onceSpy).not.toHaveBeenCalledWith("SIGCONT", expect.anything()); expect(ui.stop).not.toHaveBeenCalled(); expect(ui.start).not.toHaveBeenCalled(); expect(showStatus).toHaveBeenCalledTimes(1); expect(showStatus.mock.calls[0]?.[0]).toMatch(/not supported/i); expect(showError).not.toHaveBeenCalled(); }); it("SIGSTOPs the foreground process group and registers a SIGCONT resume hook on POSIX (#3461)", () => { setPlatform("linux"); const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); const onceSpy = vi.spyOn(process, "once"); const { ctx, ui, showError } = createCtx(); const controller = new InputController(ctx); controller.handleCtrlZ(); // Resume hook registered BEFORE the signal is sent so a same-tick // SIGCONT delivery can't race past us. expect(onceSpy).toHaveBeenCalledWith("SIGCONT", expect.any(Function)); const sigcontOrder = onceSpy.mock.invocationCallOrder[0] ?? Infinity; const stopOrder = ui.stop.mock.invocationCallOrder[0] ?? Infinity; const killOrder = killSpy.mock.invocationCallOrder[0] ?? Infinity; expect(sigcontOrder).toBeLessThan(stopOrder); expect(stopOrder).toBeLessThan(killOrder); expect(killSpy).toHaveBeenCalledTimes(1); expect(killSpy).toHaveBeenCalledWith(0, "SIGSTOP"); expect(ui.start).not.toHaveBeenCalled(); expect(showError).not.toHaveBeenCalled(); // Simulating the kernel-delivered SIGCONT drives the TUI back up. const resume = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1] as (() => void) | undefined; expect(resume).toBeDefined(); resume?.(); expect(ui.start).toHaveBeenCalledTimes(1); expect(ui.requestRender).toHaveBeenCalledWith(true); }); it("restores the TUI and drops the SIGCONT listener when process.kill rejects the signal", () => { setPlatform("linux"); const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { throw new Error("Unknown signal: SIGSTOP"); }); const onceSpy = vi.spyOn(process, "once"); const removeSpy = vi.spyOn(process, "removeListener"); const { ctx, ui, showError, showStatus } = createCtx(); const controller = new InputController(ctx); // Critical contract: the failure must not bubble up to the caller — // otherwise the TUI's stdin reader (which invoked us) crashes the // whole process via `[Uncaught Exception]`. expect(() => controller.handleCtrlZ()).not.toThrow(); // The exact listener we registered for SIGCONT is the one we // remove; otherwise a leaked handler would fire on the next // unrelated continue and re-`start()` an already-running TUI. const registered = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1]; expect(registered).toBeDefined(); expect(removeSpy).toHaveBeenCalledWith("SIGCONT", registered); expect(killSpy).toHaveBeenCalledTimes(1); expect(ui.stop).toHaveBeenCalledTimes(1); expect(ui.start).toHaveBeenCalledTimes(1); expect(ui.requestRender).toHaveBeenCalledWith(true); expect(showError).toHaveBeenCalledTimes(1); expect(showError.mock.calls[0]?.[0]).toMatch(/Failed to suspend/); expect(showStatus).not.toHaveBeenCalled(); }); });