diff --git a/tests/close.test.ts b/tests/close.test.ts new file mode 100644 index 0000000..27d56f3 --- /dev/null +++ b/tests/close.test.ts @@ -0,0 +1,183 @@ +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); + expect(result.warnings).toEqual([]); + // 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("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(result.warnings?.length).toBe(1); + expect(result.warnings?.[0]).toContain("Failed to create tag"); + 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`); + }); +}); diff --git a/tests/open.test.ts b/tests/open.test.ts new file mode 100644 index 0000000..5be14ef --- /dev/null +++ b/tests/open.test.ts @@ -0,0 +1,357 @@ +import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { openMultiModel } from "../src/core/open"; +import { + shellQuote, + normalizeModels, + findDuplicates, + createWindowBaseName, + createWindowPlans, + levenshtein, + suggestModels, + formatInvalidModelError, + sanitizeName, + runCommand, + getWorktreePath, + getSessionPath, + undoWorktree, + launchModelInWindow, + getWorktreesForSession, + getBinaryName, +} from "../src/core/utils"; + +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); + + return { + quiet: () => ({ + nothrow: async () => { + const response = mockCommandResponses[commandSignature] ?? { ok: true, stdout: "", stderr: "" }; + return { + 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("multi-model launch", () => { + let existsSyncMock: ReturnType; + let bunMock: ReturnType; + + beforeEach(() => { + spyOn(os, "homedir").mockReturnValue("/mock/home"); + existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); + bunMock = setupMockBun$(); + executedCommands = []; + + mockCommandResponses = { + "git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, + "command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" }, + "command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" }, + "opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" }, + "tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" }, + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" }, + }; + }); + + afterEach(() => { + restoreOriginalBun$(); + }); + + test("fails if session name is empty", async () => { + const result = await openMultiModel({ + sessionName: "", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `sessionName` must not be empty."); + expect(executedCommands).toEqual([]); + }); + + test("fails if no models are provided", async () => { + const result = await openMultiModel({ + sessionName: "test-session", + models: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Model names must not be empty."); + expect(executedCommands).toEqual([]); + }); + + test("fails if duplicate models are provided", async () => { + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2", "openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Duplicate model names are not allowed"); + expect(executedCommands).toEqual([]); + }); + + test("fails if not inside a git repository", async () => { + mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `multi-model` requires a git repository."); + expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]); + }); + + test("fails if tmux is not installed", async () => { + mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + ]); + }); + + test("fails if opencode is not installed", async () => { + mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + ]); + }); + + test("fails if model name is not in allowlist", async () => { + const result = await openMultiModel({ + sessionName: "test-session", + models: ["invalid/model"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Model name 'invalid/model' not found"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + ]); + }); + + test("fails if session already exists", async () => { + mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Error: Session already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + ]); + }); + + test("successfully plans and executes tmux launch with single model", async () => { + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true); + }); + + test("successfully launches multiple models", async () => { + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true); + }); + + test("sanitizes session name", async () => { + mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test session!", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session"); + expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true); + }); + + test("fails if tmux new-session fails for the first model and triggers undo", async () => { + mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" }; + mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; + mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to create tmux session"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", + "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", + "tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", + "git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", + "git branch -D opencode/test-session/gpt-5-2", + ]); + }); + + test("fails if worktree path exists for first model", async () => { + existsSyncMock.mockImplementation((path: string) => { + return path.includes("test-session/gpt-5-2"); + }); + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Worktree path already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + ]); + }); + + test("fails if git branch exists for first model", async () => { + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", + ]); + }); + + test("fails if git worktree add fails for first model", async () => { + mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2"], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Failed to create worktree"); + expect(executedCommands).toEqual([ + "git rev-parse --is-inside-work-tree", + "command -v tmux", + "command -v opencode", + "opencode models", + "tmux has-session -t test-session", + "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", + "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", + ]); + }); + + test("model name collision creates unique window names", async () => { + mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; + mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: ["openai/gpt-5.2", "anthropic/gpt-5.2"], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); + expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true); + }); + + test("long model name is truncated in window name", async () => { + const longModel = "verylongmodelfrontexampleprovider"; + mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" }; + mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" }; + + const result = await openMultiModel({ + sessionName: "test-session", + models: [longModel], + }); + + expect(result.success).toBe(true); + expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); + const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1]; + expect(windowName?.length).toBeLessThanOrEqual(24); + }); +}); + +// ---------- +// Core utils tests remain in a separate file. diff --git a/tests/multi-model.test.ts b/tests/utils.test.ts similarity index 54% rename from tests/multi-model.test.ts rename to tests/utils.test.ts index 1f5e9b8..4443803 100644 --- a/tests/multi-model.test.ts +++ b/tests/utils.test.ts @@ -1,7 +1,6 @@ import { expect, test, mock, spyOn, beforeEach, afterEach, describe } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; -import { openMultiModel } from "../src/core/open"; import { shellQuote, normalizeModels, @@ -29,9 +28,7 @@ function setupMockBun$() { const mockFn = mock((strings: TemplateStringsArray, ...values: any[]) => { const parts = values[0] as string[]; const commandSignature = parts.join(" "); - executedCommands.push(commandSignature); - return { quiet: () => ({ nothrow: async () => { @@ -45,7 +42,6 @@ function setupMockBun$() { }), }; }); - (globalThis as any).Bun.$ = mockFn; return mockFn; } @@ -54,305 +50,6 @@ function restoreOriginalBun$() { (globalThis as any).Bun.$ = originalBun$; } -describe("multi-model launch", () => { - let existsSyncMock: ReturnType; - let bunMock: ReturnType; - - beforeEach(() => { - spyOn(os, "homedir").mockReturnValue("/mock/home"); - existsSyncMock = spyOn(fs, "existsSync").mockReturnValue(false); - bunMock = setupMockBun$(); - executedCommands = []; - - mockCommandResponses = { - "git rev-parse --is-inside-work-tree": { ok: true, stdout: "true", stderr: "" }, - "command -v tmux": { ok: true, stdout: "/usr/bin/tmux", stderr: "" }, - "command -v opencode": { ok: true, stdout: "/usr/bin/opencode", stderr: "" }, - "opencode models": { ok: true, stdout: "openai/gpt-5.2\nanthropic/claude-3-5-sonnet", stderr: "" }, - "tmux has-session -t test-session": { ok: false, stdout: "", stderr: "session not found" }, - "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2": { ok: false, stdout: "", stderr: "" }, - }; - }); - - afterEach(() => { - restoreOriginalBun$(); - }); - - test("fails if session name is empty", async () => { - const result = await openMultiModel({ - sessionName: "", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: `sessionName` must not be empty."); - expect(executedCommands).toEqual([]); - }); - - test("fails if no models are provided", async () => { - const result = await openMultiModel({ - sessionName: "test-session", - models: [], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: Model names must not be empty."); - expect(executedCommands).toEqual([]); - }); - - test("fails if duplicate models are provided", async () => { - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2", "openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Duplicate model names are not allowed"); - expect(executedCommands).toEqual([]); - }); - - test("fails if not inside a git repository", async () => { - mockCommandResponses["git rev-parse --is-inside-work-tree"] = { ok: false, stdout: "", stderr: "not a git repo" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: `multi-model` requires a git repository."); - expect(executedCommands).toEqual(["git rev-parse --is-inside-work-tree"]); - }); - - test("fails if tmux is not installed", async () => { - mockCommandResponses["command -v tmux"] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: `tmux` is not installed or not on `PATH`."); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux" - ]); - }); - - test("fails if opencode is not installed", async () => { - mockCommandResponses["command -v opencode"] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: `opencode` is not installed or not on `PATH`."); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode" - ]); - }); - - test("fails if model name is not in allowlist", async () => { - const result = await openMultiModel({ - sessionName: "test-session", - models: ["invalid/model"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: Model name 'invalid/model' not found"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models" - ]); - }); - - test("fails if session already exists", async () => { - mockCommandResponses["tmux has-session -t test-session"] = { ok: true, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Error: Session already exists"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models", - "tmux has-session -t test-session" - ]); - }); - - test("successfully plans and executes tmux launch with single model", async () => { - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); - expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); - expect(executedCommands.some(c => c.startsWith("tmux send-keys -t test-session:gpt-5-2"))).toBe(true); - }); - - test("successfully launches multiple models", async () => { - mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/claude-3-5-sonnet"] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2", "anthropic/claude-3-5-sonnet"], - }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); - expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); - expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n claude-3-5-sonnet"))).toBe(true); - }); - - test("sanitizes session name", async () => { - mockCommandResponses["tmux has-session -t test session!"] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test session!", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Use `tmux attach -t test session!` to join session"); - expect(executedCommands.some(c => c.startsWith("git worktree add -b opencode/test-session/gpt-5-2"))).toBe(true); - }); - - test("fails if tmux new-session fails for the first model and triggers undo", async () => { - mockCommandResponses["tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "tmux error" }; - mockCommandResponses["git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; - mockCommandResponses["git branch -D opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Failed to create tmux session"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models", - "tmux has-session -t test-session", - "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", - "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", - "tmux new-session -d -s test-session -n gpt-5-2 -c /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", - "git worktree remove -f /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2", - "git branch -D opencode/test-session/gpt-5-2" - ]); - }); - - test("fails if worktree path exists for first model", async () => { - existsSyncMock.mockImplementation((path: string) => { - return path.includes("test-session/gpt-5-2"); - }); - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Worktree path already exists"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models", - "tmux has-session -t test-session" - ]); - }); - - test("fails if git branch exists for first model", async () => { - mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: true, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Branch 'opencode/test-session/gpt-5-2' already exists"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models", - "tmux has-session -t test-session", - "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2" - ]); - }); - - test("fails if git worktree add fails for first model", async () => { - mockCommandResponses["git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "git error" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2"], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain("Failed to create worktree"); - expect(executedCommands).toEqual([ - "git rev-parse --is-inside-work-tree", - "command -v tmux", - "command -v opencode", - "opencode models", - "tmux has-session -t test-session", - "git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2", - "git worktree add -b opencode/test-session/gpt-5-2 /mock/home/.local/share/opencode/multi-model/test-session/gpt-5-2" - ]); - }); - - test("model name collision creates unique window names", async () => { - mockCommandResponses["opencode models"] = { ok: true, stdout: "openai/gpt-5.2\nanthropic/gpt-5.2", stderr: "" }; - mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2"] = { ok: false, stdout: "", stderr: "" }; - mockCommandResponses["git show-ref --verify --quiet refs/heads/opencode/test-session/gpt-5-2-2"] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: ["openai/gpt-5.2", "anthropic/gpt-5.2"], - }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); - expect(executedCommands.some(c => c.startsWith("tmux new-session -d -s test-session -n gpt-5-2"))).toBe(true); - expect(executedCommands.some(c => c.startsWith("tmux new-window -d -t test-session -n gpt-5-2-2"))).toBe(true); - }); - - test("long model name is truncated in window name", async () => { - const longModel = "verylongmodelfrontexampleprovider"; - mockCommandResponses["opencode models"] = { ok: true, stdout: longModel, stderr: "" }; - mockCommandResponses[`git show-ref --verify --quiet refs/heads/opencode/test-session/${longModel.slice(0, 24)}`] = { ok: false, stdout: "", stderr: "" }; - - const result = await openMultiModel({ - sessionName: "test-session", - models: [longModel], - }); - - expect(result.success).toBe(true); - expect(result.instructions).toContain("Use `tmux attach -t test-session` to join session"); - const windowName = executedCommands.find(c => c.includes("tmux new-session"))?.match(/-n (\S+)/)?.[1]; - expect(windowName?.length).toBeLessThanOrEqual(24); - }); -}); - describe("core utils", () => { let bunMock: ReturnType; @@ -371,19 +68,15 @@ describe("core utils", () => { test("normal string without special characters", () => { expect(shellQuote("test")).toBe("'test'"); }); - test("string with spaces", () => { expect(shellQuote("test value")).toBe("'test value'"); }); - test("string with single quotes", () => { expect(shellQuote("test'value")).toBe("'test'\"'\"'value'"); }); - test("string with multiple single quotes", () => { expect(shellQuote("te's't'v'alue")).toBe("'te'\"'\"'s'\"'\"'t'\"'\"'v'\"'\"'alue'"); }); - test("empty string", () => { expect(shellQuote("")).toBe("''"); }); @@ -393,15 +86,12 @@ describe("core utils", () => { test("empty array", () => { expect(normalizeModels([])).toEqual([]); }); - test("undefined input", () => { expect(normalizeModels(undefined)).toEqual([]); }); - test("array with whitespace-only strings", () => { expect(normalizeModels([" ", " "])).toEqual([]); }); - test("array with mixed valid models, padded models, and empty strings", () => { expect(normalizeModels([" openai/gpt-5.2 ", "", " anthropic/claude "])).toEqual(["openai/gpt-5.2", "anthropic/claude"]); }); @@ -411,19 +101,15 @@ describe("core utils", () => { test("array with no duplicates", () => { expect(findDuplicates(["a", "b", "c"])).toEqual([]); }); - test("array with one duplicate pair", () => { expect(findDuplicates(["a", "b", "a"])).toEqual(["a"]); }); - test("array with multiple different duplicates", () => { expect(findDuplicates(["a", "b", "a", "c", "b"])).toEqual(["a", "b"]); }); - test("array with a single value repeated more than twice", () => { expect(findDuplicates(["a", "a", "a"])).toEqual(["a"]); }); - test("verifying first-repeated-occurrence order", () => { expect(findDuplicates(["x", "a", "b", "a", "x", "b"])).toEqual(["a", "x", "b"]); }); @@ -433,20 +119,16 @@ describe("core utils", () => { test("normal model name without slashes", () => { expect(createWindowBaseName("gpt-5-2")).toBe("gpt-5-2"); }); - test("model with slashes", () => { expect(createWindowBaseName("vendor/namespace/model")).toBe("model"); }); - test("model exceeding WINDOW_NAME_LIMIT", () => { const longName = "a".repeat(30); expect(createWindowBaseName(longName)).toBe("aaaaaaaaaaaaaaaaaaaaaaaa"); }); - test("model with special characters", () => { expect(createWindowBaseName("OpenAI/GPT_4.0!")).toBe("gpt-4-0"); }); - test("model made entirely of special characters", () => { expect(createWindowBaseName("!@#$%^&*()")).toBe("model"); }); @@ -456,21 +138,18 @@ describe("core utils", () => { test("single model", () => { expect(createWindowPlans(["openai/gpt-5-2"])).toEqual([{ model: "openai/gpt-5-2", windowName: "gpt-5-2" }]); }); - test("two models with the same base name", () => { expect(createWindowPlans(["openai/gpt-5-2", "anthropic/gpt-5-2"])).toEqual([ { model: "openai/gpt-5-2", windowName: "gpt-5-2" }, - { model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" } + { model: "anthropic/gpt-5-2", windowName: "gpt-5-2-2" }, ]); }); - test("multiple collisions", () => { const result = createWindowPlans(["a", "b", "c", "a", "b", "a"]); expect(result[0]?.windowName).toBe("a"); expect(result[3]?.windowName).toBe("a-2"); expect(result[5]?.windowName).toBe("a-3"); }); - test("truncation to accommodate suffix", () => { const longBase = "a".repeat(23); const result = createWindowPlans([longBase, "b"]); @@ -482,27 +161,21 @@ describe("core utils", () => { test("identical strings", () => { expect(levenshtein("test", "test")).toBe(0); }); - test("one substitution", () => { expect(levenshtein("test", "tent")).toBe(1); }); - test("one insertion", () => { expect(levenshtein("test", "tests")).toBe(1); }); - test("one deletion", () => { expect(levenshtein("tests", "test")).toBe(1); }); - test("completely different strings", () => { expect(levenshtein("abc", "xyz")).toBe(3); }); - test("one empty string", () => { expect(levenshtein("", "test")).toBe(4); }); - test("both empty strings", () => { expect(levenshtein("", "")).toBe(0); }); @@ -513,21 +186,17 @@ describe("core utils", () => { const result = suggestModels("gpt-5.2", ["gpt-5.2", "gpt-5.2-mini"]); expect(result[0]).toBe("gpt-5.2"); }); - test("fuzzy match/typo", () => { expect(suggestModels("gpt5.4", ["gpt-5.4"])).toEqual(["gpt-5.4"]); }); - test("fuzzy match with contains boost", () => { const suggestions = suggestModels("gpt4o", ["gpt-5.2", "gpt-5.2-mini"]); expect(suggestions[0]).toBe("gpt-5.2"); }); - test("maximum of 3 suggestions", () => { const allowlist = ["gpt-5.2", "gpt-5.2-mini", "gpt-5.2-pro", "gpt-5.2-ultra"]; expect(suggestModels("gpt4o", allowlist).length).toBe(3); }); - test("sorting logic", () => { const suggestions = suggestModels("abc", ["abc", "abd", "abe"]); expect(suggestions).toEqual(["abc", "abd", "abe"]); @@ -540,13 +209,11 @@ describe("core utils", () => { expect(error).toContain("Model name 'gpt5.4' not found"); expect(error).toContain("Did you mean"); }); - test("single invalid model without close matches", () => { const error = formatInvalidModelError(["xyz"], ["abc", "def"]); expect(error).toContain("Model name 'xyz' not found"); expect(error).toContain("Did you mean"); }); - test("multiple invalid models", () => { const error = formatInvalidModelError(["xyz", "abc"], ["def"]); expect(error).toContain("Model names not found"); @@ -559,15 +226,12 @@ describe("core utils", () => { test("normal alphanumeric name", () => { expect(sanitizeName("test-session")).toBe("test-session"); }); - test("name with spaces and special chars", () => { expect(sanitizeName("test session!")).toBe("test-session"); }); - test("name with consecutive hyphens", () => { expect(sanitizeName("test--session")).toBe("test-session"); }); - test("name with leading/trailing hyphens", () => { expect(sanitizeName("-test-session-")).toBe("test-session"); }); @@ -581,7 +245,6 @@ describe("core utils", () => { expect(result.stdout).toBe("hello"); expect(result.stderr).toBe(""); }); - test("returns ok false for failed command", async () => { mockCommandResponses["false"] = { ok: false, stdout: "", stderr: "error" }; const result = await runCommand(["false"]); @@ -596,7 +259,6 @@ describe("core utils", () => { const result = getWorktreePath("my-session", "gpt-5.2"); expect(result).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); }); - test("handles nested session names", () => { spyOn(os, "homedir").mockReturnValue("/home/user"); const result = getWorktreePath("parent/child", "window"); @@ -620,7 +282,6 @@ describe("core utils", () => { const errors = await undoWorktree("/path/to/worktree", "opencode/session/window"); expect(errors).toEqual([]); }); - test("returns error when worktree remove fails", async () => { mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; mockCommandResponses["git branch -D opencode/session/window"] = { ok: true, stdout: "", stderr: "" }; @@ -629,7 +290,6 @@ describe("core utils", () => { expect(errors.length).toBe(1); expect(errors[0]).toContain("Failed to remove worktree"); }); - test("returns error when branch delete fails", async () => { mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: true, stdout: "", stderr: "" }; mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; @@ -638,7 +298,6 @@ describe("core utils", () => { expect(errors.length).toBe(1); expect(errors[0]).toContain("Failed to delete branch"); }); - test("returns both errors when both operations fail", async () => { mockCommandResponses["git worktree remove -f /path/to/worktree"] = { ok: false, stdout: "", stderr: "remove failed" }; mockCommandResponses["git branch -D opencode/session/window"] = { ok: false, stdout: "", stderr: "branch delete failed" }; @@ -656,7 +315,6 @@ describe("core utils", () => { expect(result.ok).toBe(true); expect(executedCommands).toContain("tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"); }); - test("uses custom binary name", async () => { mockCommandResponses["tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m"] = { ok: true, stdout: "", stderr: "" }; @@ -680,23 +338,18 @@ describe("core utils", () => { expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); expect(worktrees[0]?.branch).toBe("opencode/my-session/gpt-5.2"); }); - test("sanitizes session name when filtering worktrees", async () => { spyOn(os, "homedir").mockReturnValue("/home/user"); - // The worktree path uses the sanitized version "my-session" mockCommandResponses["git worktree list --porcelain"] = { ok: true, stdout: "worktree /home/user/.local/share/opencode/multi-model/my-session/gpt-5.2\nbranch refs/heads/opencode/my-session/gpt-5.2", stderr: "", }; - // Pass unsanitized session name with spaces and special chars const worktrees = await getWorktreesForSession("my session!"); - // Should still find the worktree because it sanitizes internally expect(worktrees.length).toBe(1); expect(worktrees[0]?.path).toBe("/home/user/.local/share/opencode/multi-model/my-session/gpt-5.2"); }); - test("returns empty array when no matching worktrees", async () => { spyOn(os, "homedir").mockReturnValue("/home/user"); mockCommandResponses["git worktree list --porcelain"] = { @@ -715,7 +368,6 @@ describe("core utils", () => { delete process.env.OPENCODE_MULTI_MODEL_BINARY; expect(getBinaryName()).toBe("opencode"); }); - test("returns env var value when set", () => { process.env.OPENCODE_MULTI_MODEL_BINARY = "kilo"; expect(getBinaryName()).toBe("kilo");