test(tmux): isolate pane close logic tests
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user