import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import * as os from "node:os"; import * as readline from "node:readline"; import { closeMultiModel } from "../src/core/close"; let originalBun$: typeof Bun.$ = (globalThis as any).Bun?.$; let mockCommandResponses: Record = {}; let executedCommands: string[] = []; function setupMockBun$() { const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const parts = values[0] as string[]; const commandSignature = parts.join(" "); executedCommands.push(commandSignature); const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; return { quiet: () => ({ nothrow: async () => ({ exitCode: response.ok ? 0 : 1, stdout: { toString: () => response.stdout }, stderr: { toString: () => response.stderr }, }), }), }; }); (globalThis as any).Bun.$ = mockFn; return mockFn; } function restoreOriginalBun$() { (globalThis as any).Bun.$ = originalBun$; } describe("closeMultiModel", () => { let bunMock: ReturnType; beforeEach(() => { spyOn(os, "homedir").mockReturnValue("/home/user"); bunMock = setupMockBun$(); executedCommands = []; mockCommandResponses = {}; }); afterEach(() => { restoreOriginalBun$(); }); test("kills existing session without cleanup", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["tmux kill-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; // No worktrees mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(false); expect(result.worktreesRemoved).toEqual([]); expect(executedCommands).toContain("tmux has-session -t test-session"); expect(executedCommands).toContain("tmux kill-session -t test-session"); }); test("no session exists, no cleanup", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "", stderr: "" }; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: false, force: false }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(false); expect(result.worktreesRemoved).toEqual([]); expect(executedCommands).toContain("tmux has-session -t test-session"); // No kill-session should be issued expect(executedCommands.some(c => c.startsWith("tmux kill-session"))).toBe(false); }); test("cleanup works with worktrees and force, all succeeds", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; // Tag command will default to ok:true via fallback mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" }; // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; // @ts-ignore global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true }); // Restore Date // @ts-ignore global.Date = OriginalDate; expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(true); expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length).toBe(1); // Ensure removal commands were executed expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); }); test("user cancels cleanup when not forced", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; // Stub readline to return "n" const mockInterface = { question: (q: string, cb: (a: string) => void) => cb("n"), close: () => {}, } as any; spyOn(readline, "createInterface").mockImplementation(() => mockInterface); const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false }); expect(result.success).toBe(false); expect(result.error).toBe("Cleanup cancelled by user"); }); test("user confirms cleanup when not forced", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; // Stub readline to simulate user confirming cleanup (answers "y") const mockInterface = { question: (q: string, cb: (a: string) => void) => cb("y"), close: () => {}, } as any; spyOn(readline, "createInterface").mockImplementation(() => mockInterface); // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; // @ts-ignore global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: false }); // Restore Date after invocation // @ts-ignore global.Date = OriginalDate; // Verify result indicates successful cleanup expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(true); expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length).toBe(1); // Verify the expected git commands were executed expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); expect(executedCommands).toContain(`git branch -D opencode/test-session/model`); const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`; expect(executedCommands).toContain(expectedTagCmd); }); test("warning when tag creation fails", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; // Tag command will fail const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; // @ts-ignore global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; const tagCommand = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`; mockCommandResponses[tagCommand] = { ok: false, stdout: "", stderr: "tag error" }; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true }); // Restore Date // @ts-ignore global.Date = OriginalDate; expect(result.success).toBe(true); // Expect the warning message to be captured in instructions expect(result.instructions.includes('Failed to create tag')).toBe(true); expect(result.tagsCreated).toEqual([]); }); test("cleanup works with unsanitized session name", async () => { const unsanitized = "test session!"; const sanitized = "test-session"; // result of sanitizeName // tmux lookup uses unsanitized name mockCommandResponses[`tmux has-session -t ${unsanitized}`] = { ok: false, stdout: "", stderr: "" }; const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses[`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`] = { ok: true, stdout: "", stderr: "" }; // Freeze timestamp for deterministic tag name const fixedDate = new Date("2023-01-01T00:00:00.000Z"); const OriginalDate = Date; // @ts-ignore global.Date = class extends OriginalDate { constructor() { super(); return fixedDate; } toISOString() { return "2023-01-01T00:00:00.000Z"; } } as any; const result = await closeMultiModel({ sessionName: unsanitized, cleanupWorktrees: true, force: true }); // Restore Date // @ts-ignore global.Date = OriginalDate; expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(true); expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]); expect(result.tagsCreated?.length).toBe(1); // Verify that the commands used the sanitized paths for worktree removal and branch deletion expect(executedCommands).toContain(`git worktree remove -f ${worktreePath}`); expect(executedCommands).toContain(`git branch -D opencode/${sanitized}/model`); }); test("skip archive tags when flag disabled", async () => { mockCommandResponses["tmux has-session -t test-session"] = { ok: false, stdout: "", stderr: "" }; const worktreePath = "/home/user/.local/share/opencode/multi-model/test-session/model"; const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`; mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: worktreeListOutput, stderr: "" }; mockCommandResponses[`git worktree remove -f ${worktreePath}`] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git branch -D opencode/test-session/model"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["rm -rf /home/user/.local/share/opencode/multi-model/test-session"] = { ok: true, stdout: "", stderr: "" }; const result = await closeMultiModel({ sessionName: "test-session", cleanupWorktrees: true, force: true, createArchiveTags: false }); expect(result.success).toBe(true); expect(result.cleanupPerformed).toBe(true); expect(result.worktreesRemoved).toEqual([worktreePath]); expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]); expect(result.tagsCreated?.length ?? 0).toBe(0); // Ensure no tag command was executed expect(executedCommands.some(cmd => cmd.startsWith("git tag"))).toBe(false); }); });