test(tmux): isolate pane close logic tests

This commit is contained in:
YeonGyu-Kim
2026-05-18 14:45:59 +09:00
parent 3b54d587bf
commit c712b71d9a
2 changed files with 113 additions and 78 deletions
+78 -66
View File
@@ -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<typeof closeTmuxPaneWithDependencies>[1]
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
const logMock = mock(() => undefined)
async function loadCloseTmuxPane(): Promise<typeof import("./pane-close").closeTmuxPane> {
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> = {}): 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)
+35 -12
View File
@@ -1,7 +1,17 @@
import type { TmuxCommandResult } from "../runner"
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
type CloseTmuxPaneDependencies = {
readonly isInsideTmux: () => boolean
readonly getTmuxPath: () => Promise<string | null | undefined>
readonly runTmuxCommand: (tmuxPath: string, args: string[]) => Promise<TmuxCommandResult>
readonly log: (message: string, data?: unknown) => void
readonly delay: (milliseconds: number) => Promise<void>
}
export async function closeTmuxPane(paneId: string): Promise<boolean> {
const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
@@ -10,38 +20,51 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
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<boolean> {
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
}