From cfce0cc31f04b746ea6ef84a3a32f14c9ab8d82c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:02 +0900 Subject: [PATCH] refactor(tmux): migrate pane-close to runner and expand test coverage --- .../tmux/tmux-utils/pane-close-runner.test.ts | 67 +++++++ src/shared/tmux/tmux-utils/pane-close.test.ts | 185 ++++-------------- src/shared/tmux/tmux-utils/pane-close.ts | 33 +--- 3 files changed, 117 insertions(+), 168 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/pane-close-runner.test.ts diff --git a/src/shared/tmux/tmux-utils/pane-close-runner.test.ts b/src/shared/tmux/tmux-utils/pane-close-runner.test.ts new file mode 100644 index 000000000..63a7f53cf --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-close-runner.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxCommandResult } from "../runner" + +const paneCloseSpecifier = import.meta.resolve("./pane-close") +const environmentSpecifier = import.meta.resolve("./environment") +const loggerSpecifier = import.meta.resolve("../../logger") +const runnerSpecifier = import.meta.resolve("../runner") +const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver") + +const runTmuxCommandMock = mock(async (): Promise => ({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, +})) +const isInsideTmuxMock = mock((): boolean => true) +const getTmuxPathMock = mock(async (): Promise => "sh") +const logMock = mock(() => undefined) + +async function loadCloseTmuxPane(): Promise { + const module = await import(`${paneCloseSpecifier}?test=${crypto.randomUUID()}`) + return module.closeTmuxPane +} + +function registerModuleMocks(): void { + mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) + mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +} + +describe("closeTmuxPane runner integration", () => { + beforeEach(() => { + registerModuleMocks() + runTmuxCommandMock.mockClear() + isInsideTmuxMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, + }) + isInsideTmuxMock.mockReturnValue(true) + getTmuxPathMock.mockResolvedValue("sh") + }) + + it("#given pane exists #when closeTmuxPane called #then delegates send-keys and kill-pane to shared runner", async () => { + // given + const closeTmuxPane = await loadCloseTmuxPane() + + // when + const result = await closeTmuxPane("%42") + + // then + expect(result).toBe(true) + expect(runTmuxCommandMock.mock.calls).toEqual([ + ["sh", ["send-keys", "-t", "%42", "C-c"]], + ["sh", ["kill-pane", "-t", "%42"]], + ]) + }) +}) diff --git a/src/shared/tmux/tmux-utils/pane-close.test.ts b/src/shared/tmux/tmux-utils/pane-close.test.ts index b8d5d7887..b2d47c636 100644 --- a/src/shared/tmux/tmux-utils/pane-close.test.ts +++ b/src/shared/tmux/tmux-utils/pane-close.test.ts @@ -1,179 +1,101 @@ import { beforeEach, describe, expect, it, mock } from "bun:test" -type CloseTmuxPane = typeof import("./pane-close").closeTmuxPane - -type SpawnCall = { - command: string[] - options: { - stdout?: string - stderr?: string - } -} - -type FakeSubprocess = { - exited: Promise - stdout: ReadableStream - stderr: ReadableStream -} - -const TIMEOUT = Symbol("timeout") -const spawnCalls: SpawnCall[] = [] -const queuedProcesses: FakeSubprocess[] = [] - -function createClosedStream(): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) -} - -type DrainSignal = { onPull: () => void } - -function createDrainSensitiveStream(byteLength: number, signal: DrainSignal): ReadableStream { - let remainingBytes = byteLength - const chunk = new TextEncoder().encode("x".repeat(16 * 1024)) - - return new ReadableStream({ - pull(controller) { - signal.onPull() - - if (remainingBytes <= 0) { - controller.close() - return - } - - const nextChunkSize = Math.min(remainingBytes, chunk.byteLength) - controller.enqueue(chunk.subarray(0, nextChunkSize)) - remainingBytes -= nextChunkSize - }, - }) -} - -function createProcess(exitCode: number): FakeSubprocess { - return { - exited: Promise.resolve(exitCode), - stdout: createClosedStream(), - stderr: createClosedStream(), - } -} - -function createStdoutSensitiveProcess(exitCode: number, stdoutBytes: number): FakeSubprocess { - let resolveDrained: () => void = () => undefined - const drained = new Promise((resolve) => { - resolveDrained = resolve - }) - const stdout = createDrainSensitiveStream(stdoutBytes, { onPull: () => resolveDrained() }) - - return { - exited: drained.then(() => exitCode), - stdout, - stderr: createClosedStream(), - } -} - -const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}): FakeSubprocess => { - spawnCalls.push({ command, options }) - - const process = queuedProcesses.shift() - if (!process) { - throw new Error(`No fake subprocess configured for ${command.join(" ")}`) - } - - return process -}) - -const isInsideTmuxMock = mock((): boolean => true) -const getTmuxPathMock = mock(async (): Promise => "tmux") -const logMock = mock(() => undefined) +import type { TmuxCommandResult } from "../runner" const paneCloseSpecifier = import.meta.resolve("./pane-close") const environmentSpecifier = import.meta.resolve("./environment") const loggerSpecifier = import.meta.resolve("../../logger") -const spawnProcessSpecifier = import.meta.resolve("./spawn-process") +const runnerSpecifier = import.meta.resolve("../runner") const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver") -async function loadCloseTmuxPane(): Promise { +const runTmuxCommandMock = mock(async (): Promise => ({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, +})) +const isInsideTmuxMock = mock((): boolean => true) +const getTmuxPathMock = mock(async (): Promise => "tmux") +const logMock = mock(() => undefined) + +async function loadCloseTmuxPane(): Promise { const module = await import(`${paneCloseSpecifier}?test=${crypto.randomUUID()}`) return module.closeTmuxPane } function registerModuleMocks(): void { - mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock })) mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) - mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) mock.module(loggerSpecifier, () => ({ log: logMock })) -} - -function resolveWithin(promise: Promise, milliseconds: number): Promise { - return Promise.race([ - promise, - new Promise((resolve) => { - setTimeout(() => resolve(TIMEOUT), milliseconds) - }), - ]) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) } describe("closeTmuxPane", () => { beforeEach(() => { registerModuleMocks() - spawnCalls.length = 0 - queuedProcesses.length = 0 - spawnMock.mockClear() + runTmuxCommandMock.mockClear() isInsideTmuxMock.mockClear() getTmuxPathMock.mockClear() logMock.mockClear() - isInsideTmuxMock.mockImplementation((): boolean => true) - getTmuxPathMock.mockImplementation(async (): Promise => "tmux") + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, + }) + isInsideTmuxMock.mockReturnValue(true) + getTmuxPathMock.mockResolvedValue("tmux") }) it("#given pane exists #when closeTmuxPane called #then returns true and invokes send-keys + kill-pane in order", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() - queuedProcesses.push(createProcess(0), createProcess(0)) // when const result = await closeTmuxPane("%42") // then expect(result).toBe(true) - expect(spawnCalls).toEqual([ - { command: ["tmux", "send-keys", "-t", "%42", "C-c"], options: { stdout: "ignore", stderr: "ignore" } }, - { command: ["tmux", "kill-pane", "-t", "%42"], options: { stdout: "pipe", stderr: "pipe" } }, - ]) + expect(runTmuxCommandMock).toHaveBeenCalledTimes(2) + expect(runTmuxCommandMock).toHaveBeenNthCalledWith(1, "tmux", ["send-keys", "-t", "%42", "C-c"]) + expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, "tmux", ["kill-pane", "-t", "%42"]) }) - it("#given not inside tmux #when closeTmuxPane called #then returns false without spawn", async () => { + it("#given not inside tmux #when closeTmuxPane called #then returns false without runner calls", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() - isInsideTmuxMock.mockImplementation((): boolean => false) + isInsideTmuxMock.mockReturnValue(false) // when const result = await closeTmuxPane("%42") // then expect(result).toBe(false) - expect(spawnCalls).toHaveLength(0) + expect(runTmuxCommandMock).not.toHaveBeenCalled() }) - it("#given tmux not found #when closeTmuxPane called #then returns false without spawn", async () => { + it("#given tmux not found #when closeTmuxPane called #then returns false without runner calls", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() - getTmuxPathMock.mockImplementation(async (): Promise => undefined) + getTmuxPathMock.mockResolvedValue(undefined) // when const result = await closeTmuxPane("%42") // then expect(result).toBe(false) - expect(spawnCalls).toHaveLength(0) + expect(runTmuxCommandMock).not.toHaveBeenCalled() }) it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() - queuedProcesses.push(createProcess(0), createProcess(1)) + runTmuxCommandMock + .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "permission denied", exitCode: 1 }) // when const result = await closeTmuxPane("%42") @@ -182,22 +104,12 @@ describe("closeTmuxPane", () => { expect(result).toBe(false) }) - it("#given pane already closed by Ctrl+C (kill-pane reports 'can't find pane') #when closeTmuxPane called #then returns true", async () => { + it("#given pane already closed by Ctrl+C #when kill-pane reports can't find pane #then returns true", async () => { // given const closeTmuxPane = await loadCloseTmuxPane() - queuedProcesses.push( - createProcess(0), - { - exited: Promise.resolve(1), - stdout: createClosedStream(), - stderr: new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("can't find pane: %42\n")) - controller.close() - }, - }), - }, - ) + runTmuxCommandMock + .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "can't find pane: %42", exitCode: 1 }) // when const result = await closeTmuxPane("%42") @@ -205,17 +117,4 @@ describe("closeTmuxPane", () => { // then expect(result).toBe(true) }) - - it("#given kill-pane stdout stream waits for drain #when closeTmuxPane called #then returns true once drainer consumes stdout", async () => { - // given - const closeTmuxPane = await loadCloseTmuxPane() - queuedProcesses.push(createProcess(0), createStdoutSensitiveProcess(0, 16 * 1024)) - - // when - const result = await resolveWithin(closeTmuxPane("%42"), 2000) - - // then - expect(result).not.toBe(TIMEOUT) - expect(result).toBe(true) - }) }) diff --git a/src/shared/tmux/tmux-utils/pane-close.ts b/src/shared/tmux/tmux-utils/pane-close.ts index e62e46296..12125390e 100644 --- a/src/shared/tmux/tmux-utils/pane-close.ts +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -2,16 +2,12 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -async function readStream(stream: ReadableStream | null | undefined): Promise { - return stream ? new Response(stream).text() : "" -} - export async function closeTmuxPane(paneId: string): Promise { - const [{ log }, { isInsideTmux }, { getTmuxPath }, { spawn }] = await Promise.all([ + const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([ import("../../logger"), import("./environment"), import("../../../tools/interactive-bash/tmux-path-resolver"), - import("./spawn-process"), + import("../runner"), ]) if (!isInsideTmux()) { @@ -26,36 +22,23 @@ export async function closeTmuxPane(paneId: string): Promise { } log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "ignore", - stderr: "ignore", - }) - await ctrlCProc.exited + await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"]) await delay(250) log("[closeTmuxPane] killing pane", { paneId }) - const killPaneProc = spawn([tmux, "kill-pane", "-t", paneId], { - stdout: "pipe", - stderr: "pipe", - }) - const [, stderr, exitCode] = await Promise.all([ - readStream(killPaneProc.stdout), - readStream(killPaneProc.stderr), - killPaneProc.exited, - ]) - - const trimmedStderr = stderr.trim() - const paneAlreadyGone = exitCode !== 0 && /can't find pane/i.test(trimmedStderr) + const result = await runTmuxCommand(tmux, ["kill-pane", "-t", paneId]) + const trimmedStderr = result.stderr.trim() + const paneAlreadyGone = result.exitCode !== 0 && /can't find pane/i.test(trimmedStderr) if (paneAlreadyGone) { log("[closeTmuxPane] SUCCESS (pane already closed by Ctrl+C)", { paneId }) return true } - if (exitCode !== 0) { - log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: trimmedStderr }) + if (result.exitCode !== 0) { + log("[closeTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: trimmedStderr }) return false }