diff --git a/src/shared/tmux/tmux-utils/session-kill-runner.test.ts b/src/shared/tmux/tmux-utils/session-kill-runner.test.ts new file mode 100644 index 000000000..2168c00d2 --- /dev/null +++ b/src/shared/tmux/tmux-utils/session-kill-runner.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxCommandResult } from "../runner" + +const sessionKillSpecifier = import.meta.resolve("./session-kill") +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 loadKillTmuxSessionIfExists(): Promise { + const module = await import(`${sessionKillSpecifier}?test=${crypto.randomUUID()}`) + return module.killTmuxSessionIfExists +} + +function registerModuleMocks(): void { + mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) + mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +} + +describe("killTmuxSessionIfExists runner integration", () => { + beforeEach(() => { + registerModuleMocks() + runTmuxCommandMock.mockClear() + isInsideTmuxMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + runTmuxCommandMock + .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) + isInsideTmuxMock.mockReturnValue(true) + getTmuxPathMock.mockResolvedValue("sh") + }) + + it("#given session exists #when killTmuxSessionIfExists called #then delegates has-session and kill-session to shared runner", async () => { + // given + const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() + + // when + const result = await killTmuxSessionIfExists("omo-agents") + + // then + expect(result).toBe(true) + expect(runTmuxCommandMock.mock.calls).toEqual([ + ["sh", ["has-session", "-t", "omo-agents"]], + ["sh", ["kill-session", "-t", "omo-agents"]], + ]) + }) +}) diff --git a/src/shared/tmux/tmux-utils/session-kill.test.ts b/src/shared/tmux/tmux-utils/session-kill.test.ts index ca185d980..a9f54106b 100644 --- a/src/shared/tmux/tmux-utils/session-kill.test.ts +++ b/src/shared/tmux/tmux-utils/session-kill.test.ts @@ -1,134 +1,84 @@ import { beforeEach, describe, expect, it, mock } from "bun:test" -type KillTmuxSessionIfExists = typeof import("./session-kill").killTmuxSessionIfExists - -type SpawnCall = { - command: string[] - options: { - stdout?: string - stderr?: string - } -} - -type FakeSubprocess = { - exited: Promise - stdout: ReadableStream - stderr: ReadableStream -} - -const spawnCalls: SpawnCall[] = [] -const queuedProcesses: FakeSubprocess[] = [] - -function createStream(chunks: string[] = []): ReadableStream { - const textEncoder = new TextEncoder() - - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(textEncoder.encode(chunk)) - } - - controller.close() - }, - }) -} - -function createProcess(exitCode: number, output: { stdout?: string[]; stderr?: string[] } = {}): FakeSubprocess { - return { - exited: Promise.resolve(exitCode), - stdout: createStream(output.stdout), - stderr: createStream(output.stderr), - } -} - -const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}) => { - 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 sessionKillSpecifier = import.meta.resolve("./session-kill") 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 loadKillTmuxSessionIfExists(): 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 loadKillTmuxSessionIfExists(): Promise { const module = await import(`${sessionKillSpecifier}?test=${crypto.randomUUID()}`) return module.killTmuxSessionIfExists } function registerModuleMocks(): void { - mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock })) mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) - mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) } describe("killTmuxSessionIfExists", () => { 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 omo-agents session exists #when killTmuxSessionIfExists called #then kill-session invoked and returns true", async () => { // given const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() - queuedProcesses.push(createProcess(0), createProcess(0, { stdout: ["killed"], stderr: [] })) // when const result = await killTmuxSessionIfExists("omo-agents") // then expect(result).toBe(true) - expect(spawnCalls).toEqual([ - { - command: ["tmux", "has-session", "-t", "omo-agents"], - options: { stdout: "ignore", stderr: "ignore" }, - }, - { - command: ["tmux", "kill-session", "-t", "omo-agents"], - options: { stdout: "pipe", stderr: "pipe" }, - }, + expect(runTmuxCommandMock.mock.calls).toEqual([ + ["tmux", ["has-session", "-t", "omo-agents"]], + ["tmux", ["kill-session", "-t", "omo-agents"]], ]) }) - it("#given omo-agents session does NOT exist (has-session exits non-zero) #when killTmuxSessionIfExists called #then NO kill-session invocation and returns false", async () => { + it("#given omo-agents session does NOT exist #when killTmuxSessionIfExists called #then NO kill-session invocation and returns false", async () => { // given const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() - queuedProcesses.push(createProcess(1)) + runTmuxCommandMock.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "", exitCode: 1 }) // when const result = await killTmuxSessionIfExists("omo-agents") // then expect(result).toBe(false) - expect(spawnCalls).toEqual([ - { - command: ["tmux", "has-session", "-t", "omo-agents"], - options: { stdout: "ignore", stderr: "ignore" }, - }, - ]) + expect(runTmuxCommandMock.mock.calls).toEqual([["tmux", ["has-session", "-t", "omo-agents"]]]) }) - it("#given not inside tmux #when killTmuxSessionIfExists called #then returns false without any spawn", async () => { + it("#given not inside tmux #when killTmuxSessionIfExists called #then returns false without runner calls", async () => { // given const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() isInsideTmuxMock.mockReturnValue(false) @@ -138,11 +88,10 @@ describe("killTmuxSessionIfExists", () => { // then expect(result).toBe(false) - expect(spawnCalls).toHaveLength(0) - expect(getTmuxPathMock).toHaveBeenCalledTimes(0) + expect(runTmuxCommandMock).not.toHaveBeenCalled() }) - it("#given tmux not found #when killTmuxSessionIfExists called #then returns false without spawn", async () => { + it("#given tmux not found #when killTmuxSessionIfExists called #then returns false without runner calls", async () => { // given const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() getTmuxPathMock.mockResolvedValue(undefined) @@ -152,22 +101,21 @@ describe("killTmuxSessionIfExists", () => { // then expect(result).toBe(false) - expect(spawnCalls).toHaveLength(0) + expect(runTmuxCommandMock).not.toHaveBeenCalled() }) - it("#given kill-session itself fails (e.g., race between has-session and kill) #when killTmuxSessionIfExists called #then returns false but does not throw", async () => { + it("#given kill-session itself fails #when killTmuxSessionIfExists called #then returns false but does not throw", async () => { // given const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists() - queuedProcesses.push( - createProcess(0), - createProcess(1, { stdout: [], stderr: ["no session"] }), - ) + runTmuxCommandMock + .mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 }) + .mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "no session", exitCode: 1 }) // when const result = await killTmuxSessionIfExists("omo-agents") // then expect(result).toBe(false) - expect(spawnCalls).toHaveLength(2) + expect(runTmuxCommandMock).toHaveBeenCalledTimes(2) }) }) diff --git a/src/shared/tmux/tmux-utils/session-kill.ts b/src/shared/tmux/tmux-utils/session-kill.ts index fc5f765df..49291acc1 100644 --- a/src/shared/tmux/tmux-utils/session-kill.ts +++ b/src/shared/tmux/tmux-utils/session-kill.ts @@ -1,13 +1,9 @@ -async function readStream(stream: ReadableStream | null | undefined): Promise { - return stream ? new Response(stream).text() : "" -} - export async function killTmuxSessionIfExists(sessionName: 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()) { @@ -21,28 +17,21 @@ export async function killTmuxSessionIfExists(sessionName: string): Promise