refactor: tests and add close.test.ts

This commit is contained in:
2026-03-20 13:51:28 +05:30
parent 8a087275b3
commit ffdc5451ab
3 changed files with 541 additions and 349 deletions
+183
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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<typeof setupMockBun$>;
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`);
});
});
+357
View File
@@ -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<string, { ok: boolean; stdout: string; stderr: string }> = {};
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<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
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.
@@ -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<typeof spyOn>;
let bunMock: ReturnType<typeof setupMockBun$>;
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<typeof setupMockBun$>;
@@ -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");