From c712b71d9a24f5a4d4211a36ea22db637b308514 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 14:45:59 +0900 Subject: [PATCH] test(tmux): isolate pane close logic tests --- src/shared/tmux/tmux-utils/pane-close.test.ts | 144 ++++++++++-------- src/shared/tmux/tmux-utils/pane-close.ts | 47 ++++-- 2 files changed, 113 insertions(+), 78 deletions(-) diff --git a/src/shared/tmux/tmux-utils/pane-close.test.ts b/src/shared/tmux/tmux-utils/pane-close.test.ts index b2d47c636..6fb593fe9 100644 --- a/src/shared/tmux/tmux-utils/pane-close.test.ts +++ b/src/shared/tmux/tmux-utils/pane-close.test.ts @@ -1,104 +1,114 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { describe, expect, it } from "bun:test" import type { TmuxCommandResult } from "../runner" +import { closeTmuxPaneWithDependencies } from "./pane-close" -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") +type CloseTmuxPaneDependencies = Parameters[1] -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 +type TmuxCommandCall = { + readonly tmux: string + readonly args: string[] } -function registerModuleMocks(): void { - mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) - mock.module(loggerSpecifier, () => ({ log: logMock })) - mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) - mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +type ClosePaneFixture = { + readonly calls: TmuxCommandCall[] + readonly delayCalls: number[] + readonly dependencies: CloseTmuxPaneDependencies +} + +type FixtureOptions = { + readonly insideTmux?: boolean + readonly tmuxPath?: string | undefined + readonly results?: TmuxCommandResult[] +} + +function tmuxResult(overrides: Partial = {}): TmuxCommandResult { + return { + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, + ...overrides, + } +} + +function createFixture(options: FixtureOptions = {}): ClosePaneFixture { + const calls: TmuxCommandCall[] = [] + const delayCalls: number[] = [] + const results = [...(options.results ?? [tmuxResult()])] + const tmuxPath = "tmuxPath" in options ? options.tmuxPath : "tmux" + + return { + calls, + delayCalls, + dependencies: { + isInsideTmux: () => options.insideTmux ?? true, + getTmuxPath: async () => tmuxPath, + runTmuxCommand: async (tmux, args) => { + calls.push({ tmux, args: [...args] }) + return results.shift() ?? tmuxResult() + }, + log: () => undefined, + delay: async (milliseconds) => { + delayCalls.push(milliseconds) + }, + }, + } } describe("closeTmuxPane", () => { - beforeEach(() => { - registerModuleMocks() - runTmuxCommandMock.mockClear() - isInsideTmuxMock.mockClear() - getTmuxPathMock.mockClear() - logMock.mockClear() - - 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() + const fixture = createFixture() // when - const result = await closeTmuxPane("%42") + const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies) // then expect(result).toBe(true) - expect(runTmuxCommandMock).toHaveBeenCalledTimes(2) - expect(runTmuxCommandMock).toHaveBeenNthCalledWith(1, "tmux", ["send-keys", "-t", "%42", "C-c"]) - expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, "tmux", ["kill-pane", "-t", "%42"]) + expect(fixture.calls).toEqual([ + { tmux: "tmux", args: ["send-keys", "-t", "%42", "C-c"] }, + { tmux: "tmux", args: ["kill-pane", "-t", "%42"] }, + ]) + expect(fixture.delayCalls).toEqual([250]) }) it("#given not inside tmux #when closeTmuxPane called #then returns false without runner calls", async () => { // given - const closeTmuxPane = await loadCloseTmuxPane() - isInsideTmuxMock.mockReturnValue(false) + const fixture = createFixture({ insideTmux: false }) // when - const result = await closeTmuxPane("%42") + const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies) // then expect(result).toBe(false) - expect(runTmuxCommandMock).not.toHaveBeenCalled() + expect(fixture.calls).toEqual([]) }) it("#given tmux not found #when closeTmuxPane called #then returns false without runner calls", async () => { // given - const closeTmuxPane = await loadCloseTmuxPane() - getTmuxPathMock.mockResolvedValue(undefined) + const fixture = createFixture({ tmuxPath: undefined }) // when - const result = await closeTmuxPane("%42") + const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies) // then expect(result).toBe(false) - expect(runTmuxCommandMock).not.toHaveBeenCalled() + expect(fixture.calls).toEqual([]) }) it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => { // given - const closeTmuxPane = await loadCloseTmuxPane() - runTmuxCommandMock - .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) - .mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "permission denied", exitCode: 1 }) + const fixture = createFixture({ + results: [ + tmuxResult(), + tmuxResult({ success: false, stderr: "permission denied", exitCode: 1 }), + ], + }) // when - const result = await closeTmuxPane("%42") + const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies) // then expect(result).toBe(false) @@ -106,13 +116,15 @@ describe("closeTmuxPane", () => { 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() - runTmuxCommandMock - .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) - .mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "can't find pane: %42", exitCode: 1 }) + const fixture = createFixture({ + results: [ + tmuxResult(), + tmuxResult({ success: false, stderr: "can't find pane: %42", exitCode: 1 }), + ], + }) // when - const result = await closeTmuxPane("%42") + const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies) // then 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 12125390e..4f3bf602b 100644 --- a/src/shared/tmux/tmux-utils/pane-close.ts +++ b/src/shared/tmux/tmux-utils/pane-close.ts @@ -1,7 +1,17 @@ +import type { TmuxCommandResult } from "../runner" + function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +type CloseTmuxPaneDependencies = { + readonly isInsideTmux: () => boolean + readonly getTmuxPath: () => Promise + readonly runTmuxCommand: (tmuxPath: string, args: string[]) => Promise + readonly log: (message: string, data?: unknown) => void + readonly delay: (milliseconds: number) => Promise +} + export async function closeTmuxPane(paneId: string): Promise { const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([ import("../../logger"), @@ -10,38 +20,51 @@ export async function closeTmuxPane(paneId: string): Promise { import("../runner"), ]) - if (!isInsideTmux()) { - log("[closeTmuxPane] SKIP: not inside tmux") + return closeTmuxPaneWithDependencies(paneId, { + isInsideTmux, + getTmuxPath, + runTmuxCommand, + log, + delay, + }) +} + +export async function closeTmuxPaneWithDependencies( + paneId: string, + dependencies: CloseTmuxPaneDependencies, +): Promise { + if (!dependencies.isInsideTmux()) { + dependencies.log("[closeTmuxPane] SKIP: not inside tmux") return false } - const tmux = await getTmuxPath() + const tmux = await dependencies.getTmuxPath() if (!tmux) { - log("[closeTmuxPane] SKIP: tmux not found") + dependencies.log("[closeTmuxPane] SKIP: tmux not found") return false } - log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"]) + dependencies.log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) + await dependencies.runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"]) - await delay(250) + await dependencies.delay(250) - log("[closeTmuxPane] killing pane", { paneId }) + dependencies.log("[closeTmuxPane] killing pane", { paneId }) - const result = await runTmuxCommand(tmux, ["kill-pane", "-t", paneId]) + const result = await dependencies.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 }) + dependencies.log("[closeTmuxPane] SUCCESS (pane already closed by Ctrl+C)", { paneId }) return true } if (result.exitCode !== 0) { - log("[closeTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: trimmedStderr }) + dependencies.log("[closeTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: trimmedStderr }) return false } - log("[closeTmuxPane] SUCCESS", { paneId }) + dependencies.log("[closeTmuxPane] SUCCESS", { paneId }) return true }