diff --git a/src/core/terminal.ts b/src/core/terminal.ts index 11870961..5e99a143 100644 --- a/src/core/terminal.ts +++ b/src/core/terminal.ts @@ -29,13 +29,26 @@ export interface ControllingTerminal { close: () => void; } +/** Minimal terminal construction hooks so tests can cover `/dev/tty` attach behavior. */ +export interface ControllingTerminalDeps { + openSync: typeof fs.openSync; + createReadStream: (fd: number) => tty.ReadStream; + createWriteStream: (fd: number) => tty.WriteStream; +} + /** Open the controlling terminal so the UI can stay interactive while stdin carries patch data. */ -export function openControllingTerminal(): ControllingTerminal | null { +export function openControllingTerminal( + deps: ControllingTerminalDeps = { + openSync: fs.openSync, + createReadStream: (fd) => new tty.ReadStream(fd), + createWriteStream: (fd) => new tty.WriteStream(fd), + }, +): ControllingTerminal | null { try { - const stdinFd = fs.openSync("/dev/tty", "r"); - const stdoutFd = fs.openSync("/dev/tty", "w"); - const stdin = new tty.ReadStream(stdinFd); - const stdout = new tty.WriteStream(stdoutFd); + const stdinFd = deps.openSync("/dev/tty", "r"); + const stdoutFd = deps.openSync("/dev/tty", "w"); + const stdin = deps.createReadStream(stdinFd); + const stdout = deps.createWriteStream(stdoutFd); return { stdin, diff --git a/test/terminal.test.ts b/test/terminal.test.ts index 11169a4a..eceb3e91 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { CliInput } from "../src/core/types"; -import { resolveRuntimeCliInput, shouldUsePagerMode, usesPipedPatchInput } from "../src/core/terminal"; +import { openControllingTerminal, resolveRuntimeCliInput, shouldUsePagerMode, usesPipedPatchInput } from "../src/core/terminal"; function createPatchInput(file?: string, pager = false): CliInput { return { @@ -35,3 +35,65 @@ describe("terminal runtime defaults", () => { expect(resolveRuntimeCliInput(input, true).options.pager).toBe(true); }); }); + +describe("controlling terminal attachment", () => { + test("opens /dev/tty for read and write and closes both streams", () => { + const calls: Array<[string, string]> = []; + let stdinDestroyed = false; + let stdoutDestroyed = false; + + const stdin = { + destroy() { + stdinDestroyed = true; + }, + } as never; + const stdout = { + destroy() { + stdoutDestroyed = true; + }, + } as never; + + const controllingTerminal = openControllingTerminal({ + openSync(path, flags) { + calls.push([String(path), String(flags)]); + return flags === "r" ? 11 : 12; + }, + createReadStream(fd) { + expect(fd).toBe(11); + return stdin; + }, + createWriteStream(fd) { + expect(fd).toBe(12); + return stdout; + }, + }); + + expect(controllingTerminal).not.toBeNull(); + expect(calls).toEqual([ + ["/dev/tty", "r"], + ["/dev/tty", "w"], + ]); + expect(controllingTerminal?.stdin).toBe(stdin); + expect(controllingTerminal?.stdout).toBe(stdout); + + controllingTerminal?.close(); + expect(stdinDestroyed).toBe(true); + expect(stdoutDestroyed).toBe(true); + }); + + test("returns null when the controlling terminal cannot be opened", () => { + const controllingTerminal = openControllingTerminal({ + openSync() { + throw new Error("no tty"); + }, + createReadStream() { + throw new Error("unreachable"); + }, + createWriteStream() { + throw new Error("unreachable"); + }, + }); + + expect(controllingTerminal).toBeNull(); + }); +});