mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
mock,
|
|
spyOn,
|
|
test,
|
|
} from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import {
|
|
createWindowBaseName,
|
|
createWindowPlans,
|
|
findDuplicates,
|
|
formatInvalidModelError,
|
|
getBinaryName,
|
|
getSessionPath,
|
|
getWorktreePath,
|
|
getWorktreesForSession,
|
|
launchModelInWindow,
|
|
levenshtein,
|
|
normalizeModels,
|
|
runCommand,
|
|
sanitizeName,
|
|
shellQuote,
|
|
suggestModels,
|
|
undoWorktree,
|
|
} from "../src/core/utils";
|
|
|
|
const 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("core utils", () => {
|
|
let bunMock: ReturnType<typeof setupMockBun$>;
|
|
|
|
beforeEach(() => {
|
|
spyOn(os, "homedir").mockReturnValue("/mock/home");
|
|
bunMock = setupMockBun$();
|
|
executedCommands = [];
|
|
mockCommandResponses = {};
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreOriginalBun$();
|
|
});
|
|
|
|
describe("shellQuote", () => {
|
|
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("''");
|
|
});
|
|
});
|
|
|
|
describe("normalizeModels", () => {
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
describe("findDuplicates", () => {
|
|
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",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("createWindowBaseName", () => {
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("createWindowPlans", () => {
|
|
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" },
|
|
]);
|
|
});
|
|
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"]);
|
|
expect(result[1]?.windowName.length).toBeLessThanOrEqual(24);
|
|
});
|
|
});
|
|
|
|
describe("levenshtein", () => {
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("suggestModels", () => {
|
|
test("exact match", () => {
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
describe("formatInvalidModelError", () => {
|
|
test("single invalid model with available suggestions", () => {
|
|
const error = formatInvalidModelError(["gpt5.4"], ["gpt-5.4", "gpt-5.2"]);
|
|
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");
|
|
expect(error).toContain("'xyz'");
|
|
expect(error).toContain("'abc'");
|
|
});
|
|
});
|
|
|
|
describe("sanitizeName", () => {
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("runCommand", () => {
|
|
test("returns ok true for successful command", async () => {
|
|
mockCommandResponses["echo hello"] = {
|
|
ok: true,
|
|
stdout: "hello",
|
|
stderr: "",
|
|
};
|
|
const result = await runCommand(["echo", "hello"]);
|
|
expect(result.ok).toBe(true);
|
|
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"]);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.stderr).toBe("error");
|
|
});
|
|
});
|
|
|
|
describe("getWorktreePath", () => {
|
|
test("returns correct worktree path", () => {
|
|
spyOn(os, "homedir").mockReturnValue("/home/user");
|
|
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");
|
|
expect(result).toBe(
|
|
"/home/user/.local/share/opencode/multi-model/parent/child/window",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getSessionPath", () => {
|
|
test("returns correct session path", () => {
|
|
spyOn(os, "homedir").mockReturnValue("/home/user");
|
|
const result = getSessionPath("my-session");
|
|
expect(result).toBe(
|
|
"/home/user/.local/share/opencode/multi-model/my-session",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("undoWorktree", () => {
|
|
test("returns empty array on success", async () => {
|
|
mockCommandResponses["git worktree remove -f /path/to/worktree"] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses["git branch -D opencode/session/window"] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
|
|
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: "",
|
|
};
|
|
|
|
const errors = await undoWorktree(
|
|
"/path/to/worktree",
|
|
"opencode/session/window",
|
|
);
|
|
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",
|
|
};
|
|
|
|
const errors = await undoWorktree(
|
|
"/path/to/worktree",
|
|
"opencode/session/window",
|
|
);
|
|
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",
|
|
};
|
|
|
|
const errors = await undoWorktree(
|
|
"/path/to/worktree",
|
|
"opencode/session/window",
|
|
);
|
|
expect(errors.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("launchModelInWindow", () => {
|
|
test("sends correct tmux command", async () => {
|
|
mockCommandResponses[
|
|
"tmux send-keys -t session:window opencode --model 'openai/gpt-5.2' C-m"
|
|
] = { ok: true, stdout: "", stderr: "" };
|
|
|
|
const result = await launchModelInWindow("session", {
|
|
model: "openai/gpt-5.2",
|
|
windowName: "window",
|
|
});
|
|
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: "" };
|
|
|
|
const result = await launchModelInWindow(
|
|
"session",
|
|
{ model: "openai/gpt-5.2", windowName: "window" },
|
|
"kilo",
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(executedCommands).toContain(
|
|
"tmux send-keys -t session:window kilo --model 'openai/gpt-5.2' C-m",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getWorktreesForSession", () => {
|
|
test("returns session-specific worktrees", async () => {
|
|
spyOn(os, "homedir").mockReturnValue("/home/user");
|
|
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\n\nworktree /home/user/other-repo\nbranch refs/heads/main",
|
|
stderr: "",
|
|
};
|
|
|
|
const worktrees = await getWorktreesForSession("my-session");
|
|
expect(worktrees.length).toBe(1);
|
|
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");
|
|
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: "",
|
|
};
|
|
|
|
const worktrees = await getWorktreesForSession("my session!");
|
|
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"] = {
|
|
ok: true,
|
|
stdout: "worktree /home/user/other-repo\nbranch refs/heads/main",
|
|
stderr: "",
|
|
};
|
|
|
|
const worktrees = await getWorktreesForSession("my-session");
|
|
expect(worktrees).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("getBinaryName", () => {
|
|
test("returns default 'opencode' when env var not set", () => {
|
|
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");
|
|
delete process.env.OPENCODE_MULTI_MODEL_BINARY;
|
|
});
|
|
});
|
|
});
|