// PTY adapter tests cover PTY lifecycle and termination behavior. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferredCore } from "../../../shared/deferred.js"; import { expectRealExitWinsOverSigkillFallback, expectWaitStaysPendingUntilSigkillFallback, mockLinuxOomWrapperShell, } from "./test-support.js"; const { spawnMock, ptyKillMock, signalProcessTreeMock, signalPtySessionTreeMock } = vi.hoisted( () => ({ spawnMock: vi.fn(), ptyKillMock: vi.fn(), signalProcessTreeMock: vi.fn(), signalPtySessionTreeMock: vi.fn(), }), ); vi.mock("../../terminal-pty.js", () => ({ spawnTerminalPty: (...args: unknown[]) => spawnMock(...args), })); vi.mock("../../kill-tree.js", () => ({ signalProcessTree: (...args: unknown[]) => signalProcessTreeMock(...args), signalPtySessionTree: (...args: unknown[]) => signalPtySessionTreeMock(...args), })); function createStubPty(pid = 1234) { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | null = null; const disposeData = vi.fn(); const disposeExit = vi.fn(); return { pid, write: vi.fn(), onData: vi.fn(() => ({ dispose: disposeData })), onExit: vi.fn((listener: (event: { exitCode: number; signal?: number }) => void) => { exitListener = listener; return { dispose: disposeExit }; }), kill: (signal?: string) => ptyKillMock(signal), emitExit: (event: { exitCode: number; signal?: number }) => { exitListener?.(event); }, disposeData, disposeExit, }; } function expectSpawnOptions() { const options = firstSpawnCall()[0]; if (typeof options !== "object" || options === null || Array.isArray(options)) { throw new Error("expected spawn options to be an object"); } return options as { file: string; args: string[]; env?: Record; name?: string; }; } function expectSpawnEnv() { return expectSpawnOptions().env; } function expectSpawnCommand() { return expectSpawnOptions().file; } function expectSpawnArgs() { return expectSpawnOptions().args; } function firstSpawnCall(): unknown[] { const [call] = spawnMock.mock.calls; if (!call) { throw new Error("expected spawn call"); } return call; } describe("createPtyAdapter", () => { let createPtyAdapter: typeof import("./pty.js").createPtyAdapter; beforeAll(async () => { ({ createPtyAdapter } = await import("./pty.js")); }); beforeEach(() => { spawnMock.mockClear(); ptyKillMock.mockClear(); signalProcessTreeMock.mockClear(); signalPtySessionTreeMock.mockClear(); vi.useRealTimers(); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); vi.clearAllMocks(); }); it("does not spawn when construction aborts during the module import", async () => { const abort = new AbortController(); spawnMock.mockReturnValue(createStubPty()); const starting = createPtyAdapter({ shell: "bash", args: ["-lc", "echo started"], abortSignal: abort.signal, }); abort.abort(); await expect(starting).rejects.toThrow("PTY construction aborted"); expect(spawnMock).not.toHaveBeenCalled(); }); it("kills a PTY created after construction is aborted", async () => { const abort = new AbortController(); const spawned = createDeferredCore>(); const stub = createStubPty(); spawnMock.mockReturnValue(spawned.promise); const starting = createPtyAdapter({ shell: "bash", args: ["-lc", "echo started"], abortSignal: abort.signal, }); await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); abort.abort(); spawned.resolve(stub); await expect(starting).rejects.toThrow("PTY construction aborted"); expect(ptyKillMock).toHaveBeenCalledOnce(); }); it("uses the default terminal name and child env when Windows TERM is blank", async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { vi.stubEnv("TERM", " "); vi.stubEnv("OPENCLAW_PTY_TEST_SENTINEL", "ambient"); spawnMock.mockReturnValue(createStubPty()); await createPtyAdapter({ shell: "powershell.exe", args: ["-NoLogo"] }); expect(expectSpawnOptions()).toMatchObject({ name: "xterm-256color", env: { OPENCLAW_PTY_TEST_SENTINEL: "ambient", TERM: "xterm-256color" }, }); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } }); it("prefers the explicit child TERM without merging the Windows ambient env", async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "win32", configurable: true }); try { vi.stubEnv("TERM", "ambient-term"); vi.stubEnv("OPENCLAW_PTY_TEST_SENTINEL", "ambient"); spawnMock.mockReturnValue(createStubPty()); await createPtyAdapter({ shell: "powershell.exe", args: ["-NoLogo"], env: { Term: "screen-256color", ONLY_CHILD: "yes" }, }); expect(expectSpawnOptions()).toMatchObject({ name: "screen-256color", env: { TERM: "screen-256color", ONLY_CHILD: "yes" }, }); expect(expectSpawnEnv()).not.toHaveProperty("Term"); expect(expectSpawnEnv()).not.toHaveProperty("OPENCLAW_PTY_TEST_SENTINEL"); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } }); it.each([ { name: "vt100", expected: "vt100" }, { name: " ", expected: "xterm-256color" }, ])("uses explicit terminal name '$name' over the child env", async ({ name, expected }) => { spawnMock.mockReturnValue(createStubPty()); await createPtyAdapter({ shell: "bash", args: ["-lc", "env"], name, env: { TERM: "screen-256color" }, }); expect(expectSpawnOptions()).toMatchObject({ name: expected, env: { TERM: expected }, }); }); it("forwards non-SIGTERM explicit signals to node-pty kill on non-Windows", async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); try { spawnMock.mockReturnValue(createStubPty()); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "sleep 10"], }); adapter.kill("SIGINT"); expect(ptyKillMock).toHaveBeenCalledWith("SIGINT"); expect(signalProcessTreeMock).not.toHaveBeenCalled(); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } }); it("uses process-tree kill for graceful SIGTERM cancellation", async () => { spawnMock.mockReturnValue(createStubPty(1234)); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "sleep 10"], }); adapter.kill("SIGTERM"); expect(signalPtySessionTreeMock).toHaveBeenCalledWith(1234, "SIGTERM"); expect(signalProcessTreeMock).not.toHaveBeenCalled(); expect(ptyKillMock).not.toHaveBeenCalled(); }); 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(signalPtySessionTreeMock).toHaveBeenCalledWith(1234, "SIGKILL"); expect(signalProcessTreeMock).not.toHaveBeenCalled(); expect(ptyKillMock).not.toHaveBeenCalled(); }); it("keeps terminal fallback distinct from unconfirmed PTY cleanup", async () => { vi.useFakeTimers(); spawnMock.mockReturnValue(createStubPty()); const onSpawnCleanup = vi.fn<(cleanup: Promise) => void>(); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "sleep 10"], onSpawnCleanup, }); await expectWaitStaysPendingUntilSigkillFallback(adapter.wait(), () => { adapter.kill(); }); expect(onSpawnCleanup).toHaveBeenCalledOnce(); await expect(onSpawnCleanup.mock.calls[0]![0]).rejects.toThrow( "cleanup could not be confirmed", ); }); 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"], }); await expectRealExitWinsOverSigkillFallback({ waitPromise: adapter.wait(), triggerKill: () => { adapter.kill(); }, emitExit: () => { stub.emitExit({ exitCode: 0, signal: 9 }); }, expected: { code: 0, signal: 9 }, }); }); it.each([true, false])( "preserves the first PTY exit across waits (waitBefore=%s)", async (waitBefore) => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "exit 3"], }); expect(stub.onExit).toHaveBeenCalledTimes(1); const pending = waitBefore ? [adapter.wait(), adapter.wait()] : []; stub.emitExit({ exitCode: 3, signal: 0 }); const result = await adapter.wait(); expect(result).toStrictEqual({ code: 3, signal: null }); expect(adapter.stdin?.destroyed).toBe(true); expect(adapter.stdin?.writable).toBe(false); stub.emitExit({ exitCode: 9, signal: 15 }); adapter.dispose(); for (const wait of [...pending, adapter.wait()]) { await expect(wait).resolves.toBe(result); } }, ); it("reports stdin as non-writable after EOF or dispose", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "cat"], }); expect(adapter.stdin?.writable).toBe(true); expect(adapter.stdin?.writableEnded).toBe(false); adapter.stdin?.end(); expect(stub.write).toHaveBeenCalledWith(process.platform === "win32" ? "\x1a" : "\x04"); expect(adapter.stdin?.writable).toBe(false); expect(adapter.stdin?.writableEnded).toBe(true); adapter.dispose(); expect(adapter.stdin?.destroyed).toBe(true); }); it("disposes PTY listeners", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); const adapter = await createPtyAdapter({ shell: "bash", args: ["-lc", "echo ok"], }); adapter.onStdout(() => undefined); const pending = adapter.wait(); adapter.dispose(); const result = await pending; expect(result).toStrictEqual({ code: null, signal: null }); await expect(adapter.wait()).resolves.toBe(result); expect(stub.disposeData).toHaveBeenCalledTimes(1); expect(stub.disposeExit).toHaveBeenCalledTimes(1); }); it("keeps inherited env when no override env is provided on non-Linux", async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); try { const stub = createStubPty(); spawnMock.mockReturnValue(stub); await createPtyAdapter({ shell: "bash", args: ["-lc", "env"], }); expect(expectSpawnCommand()).toBe("bash"); expect(expectSpawnArgs()).toEqual(["-lc", "env"]); expect(expectSpawnEnv()).toBeUndefined(); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } }); it("wraps Linux PTY spawns so shell children inherit higher OOM score", async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "linux", configurable: true }); const restoreLinuxShell = mockLinuxOomWrapperShell(); vi.resetModules(); try { const { createPtyAdapter: createLinuxPtyAdapter } = await import("./pty.js"); const stub = createStubPty(); spawnMock.mockReturnValue(stub); const adapter = await createLinuxPtyAdapter({ shell: "bash", args: ["-lc", "env"], env: { PATH: "/usr/bin", BASH_ENV: "/tmp/bashenv", TERM: "dumb" }, }); expect(adapter.oomScoreWrapperSelected).toBe(true); } finally { restoreLinuxShell(); if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } expect(expectSpawnCommand()).toBe("/bin/sh"); expect(expectSpawnArgs()).toEqual([ "-c", 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"', "bash", "-lc", "env", ]); expect(expectSpawnEnv()).toEqual({ PATH: "/usr/bin", TERM: "xterm-256color" }); }); it("passes explicit env overrides as strings", async () => { const stub = createStubPty(); spawnMock.mockReturnValue(stub); await createPtyAdapter({ shell: "bash", args: ["-lc", "env"], name: "xterm-256color", env: { FOO: "bar", COUNT: "12", DROP_ME: undefined }, }); expect(expectSpawnEnv()).toEqual({ FOO: "bar", COUNT: "12", TERM: "xterm-256color" }); }); it("delegates non-SIGTERM explicit signals to the terminal owner 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("SIGINT"); expect(ptyKillMock).toHaveBeenCalledWith("SIGINT"); expect(signalProcessTreeMock).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(signalPtySessionTreeMock).toHaveBeenCalledWith(4567, "SIGKILL"); expect(signalProcessTreeMock).not.toHaveBeenCalled(); expect(ptyKillMock).not.toHaveBeenCalled(); } finally { if (originalPlatform) { Object.defineProperty(process, "platform", originalPlatform); } } }); });