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 type { TmuxCommandResult } from "../runner"
|
||||||
|
import { closeTmuxPaneWithDependencies } from "./pane-close"
|
||||||
|
|
||||||
const paneCloseSpecifier = import.meta.resolve("./pane-close")
|
type CloseTmuxPaneDependencies = Parameters<typeof closeTmuxPaneWithDependencies>[1]
|
||||||
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<TmuxCommandResult> => ({
|
type TmuxCommandCall = {
|
||||||
success: true,
|
readonly tmux: string
|
||||||
output: "",
|
readonly args: string[]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerModuleMocks(): void {
|
type ClosePaneFixture = {
|
||||||
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
|
readonly calls: TmuxCommandCall[]
|
||||||
mock.module(loggerSpecifier, () => ({ log: logMock }))
|
readonly delayCalls: number[]
|
||||||
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
|
readonly dependencies: CloseTmuxPaneDependencies
|
||||||
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
|
}
|
||||||
|
|
||||||
|
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", () => {
|
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 () => {
|
it("#given pane exists #when closeTmuxPane called #then returns true and invokes send-keys + kill-pane in order", async () => {
|
||||||
// given
|
// given
|
||||||
const closeTmuxPane = await loadCloseTmuxPane()
|
const fixture = createFixture()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await closeTmuxPane("%42")
|
const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
expect(runTmuxCommandMock).toHaveBeenCalledTimes(2)
|
expect(fixture.calls).toEqual([
|
||||||
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(1, "tmux", ["send-keys", "-t", "%42", "C-c"])
|
{ tmux: "tmux", args: ["send-keys", "-t", "%42", "C-c"] },
|
||||||
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, "tmux", ["kill-pane", "-t", "%42"])
|
{ 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 () => {
|
it("#given not inside tmux #when closeTmuxPane called #then returns false without runner calls", async () => {
|
||||||
// given
|
// given
|
||||||
const closeTmuxPane = await loadCloseTmuxPane()
|
const fixture = createFixture({ insideTmux: false })
|
||||||
isInsideTmuxMock.mockReturnValue(false)
|
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await closeTmuxPane("%42")
|
const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe(false)
|
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 () => {
|
it("#given tmux not found #when closeTmuxPane called #then returns false without runner calls", async () => {
|
||||||
// given
|
// given
|
||||||
const closeTmuxPane = await loadCloseTmuxPane()
|
const fixture = createFixture({ tmuxPath: undefined })
|
||||||
getTmuxPathMock.mockResolvedValue(undefined)
|
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await closeTmuxPane("%42")
|
const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe(false)
|
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 () => {
|
it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => {
|
||||||
// given
|
// given
|
||||||
const closeTmuxPane = await loadCloseTmuxPane()
|
const fixture = createFixture({
|
||||||
runTmuxCommandMock
|
results: [
|
||||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
tmuxResult(),
|
||||||
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "permission denied", exitCode: 1 })
|
tmuxResult({ success: false, stderr: "permission denied", exitCode: 1 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await closeTmuxPane("%42")
|
const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe(false)
|
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 () => {
|
it("#given pane already closed by Ctrl+C #when kill-pane reports can't find pane #then returns true", async () => {
|
||||||
// given
|
// given
|
||||||
const closeTmuxPane = await loadCloseTmuxPane()
|
const fixture = createFixture({
|
||||||
runTmuxCommandMock
|
results: [
|
||||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
tmuxResult(),
|
||||||
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "can't find pane: %42", exitCode: 1 })
|
tmuxResult({ success: false, stderr: "can't find pane: %42", exitCode: 1 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await closeTmuxPane("%42")
|
const result = await closeTmuxPaneWithDependencies("%42", fixture.dependencies)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
import type { TmuxCommandResult } from "../runner"
|
||||||
|
|
||||||
function delay(milliseconds: number): Promise<void> {
|
function delay(milliseconds: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
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> {
|
export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
||||||
const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([
|
const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([
|
||||||
import("../../logger"),
|
import("../../logger"),
|
||||||
@@ -10,38 +20,51 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
|
|||||||
import("../runner"),
|
import("../runner"),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!isInsideTmux()) {
|
return closeTmuxPaneWithDependencies(paneId, {
|
||||||
log("[closeTmuxPane] SKIP: not inside tmux")
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmux = await getTmuxPath()
|
const tmux = await dependencies.getTmuxPath()
|
||||||
if (!tmux) {
|
if (!tmux) {
|
||||||
log("[closeTmuxPane] SKIP: tmux not found")
|
dependencies.log("[closeTmuxPane] SKIP: tmux not found")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
dependencies.log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||||
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
|
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 trimmedStderr = result.stderr.trim()
|
||||||
const paneAlreadyGone = result.exitCode !== 0 && /can't find pane/i.test(trimmedStderr)
|
const paneAlreadyGone = result.exitCode !== 0 && /can't find pane/i.test(trimmedStderr)
|
||||||
|
|
||||||
if (paneAlreadyGone) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[closeTmuxPane] SUCCESS", { paneId })
|
dependencies.log("[closeTmuxPane] SUCCESS", { paneId })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user