mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
494 lines
15 KiB
TypeScript
494 lines
15 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
mock,
|
|
spyOn,
|
|
test,
|
|
} from "bun:test";
|
|
import * as os from "node:os";
|
|
import * as readline from "node:readline";
|
|
import { closeMultiModel } from "../src/core/close";
|
|
|
|
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);
|
|
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({
|
|
mode: "cli",
|
|
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({
|
|
mode: "cli",
|
|
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;
|
|
global.Date = class extends OriginalDate {
|
|
constructor() {
|
|
super();
|
|
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
|
return fixedDate;
|
|
}
|
|
override toISOString() {
|
|
return "2023-01-01T00:00:00.000Z";
|
|
}
|
|
} as any;
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: "test-session",
|
|
cleanupWorktrees: true,
|
|
force: true,
|
|
});
|
|
// Restore Date
|
|
global.Date = OriginalDate;
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.cleanupPerformed).toBe(true);
|
|
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
|
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
|
expect(result.tagsCreated?.length).toBe(1);
|
|
|
|
// Ensure removal commands were executed
|
|
expect(executedCommands).toContain(
|
|
`git worktree remove -f ${worktreePath}`,
|
|
);
|
|
expect(executedCommands).toContain(
|
|
`git branch -D opencode/test-session/model`,
|
|
);
|
|
});
|
|
|
|
test("user cancels cleanup when not forced", async () => {
|
|
mockCommandResponses["tmux has-session -t test-session"] = {
|
|
ok: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
const worktreePath =
|
|
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
|
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: worktreeListOutput,
|
|
stderr: "",
|
|
};
|
|
|
|
// Stub readline to return "n"
|
|
const mockInterface = {
|
|
question: (_q: string, cb: (a: string) => void) => cb("n"),
|
|
close: () => {},
|
|
} as any;
|
|
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: "test-session",
|
|
cleanupWorktrees: true,
|
|
force: false,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe("Cleanup cancelled by user");
|
|
});
|
|
|
|
test("user confirms cleanup when not forced", async () => {
|
|
mockCommandResponses["tmux has-session -t test-session"] = {
|
|
ok: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
const worktreePath =
|
|
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
|
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: worktreeListOutput,
|
|
stderr: "",
|
|
};
|
|
|
|
// Stub readline to simulate user confirming cleanup (answers "y")
|
|
const mockInterface = {
|
|
question: (_q: string, cb: (a: string) => void) => cb("y"),
|
|
close: () => {},
|
|
} as any;
|
|
spyOn(readline, "createInterface").mockImplementation(() => mockInterface);
|
|
|
|
// Freeze timestamp for deterministic tag name
|
|
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
|
const OriginalDate = Date;
|
|
global.Date = class extends OriginalDate {
|
|
constructor() {
|
|
super();
|
|
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
|
return fixedDate;
|
|
}
|
|
override toISOString() {
|
|
return "2023-01-01T00:00:00.000Z";
|
|
}
|
|
} as any;
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: "test-session",
|
|
cleanupWorktrees: true,
|
|
force: false,
|
|
});
|
|
|
|
// Restore Date after invocation
|
|
global.Date = OriginalDate;
|
|
|
|
// Verify result indicates successful cleanup
|
|
expect(result.success).toBe(true);
|
|
expect(result.cleanupPerformed).toBe(true);
|
|
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
|
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
|
expect(result.tagsCreated?.length).toBe(1);
|
|
|
|
// Verify the expected git commands were executed
|
|
expect(executedCommands).toContain(
|
|
`git worktree remove -f ${worktreePath}`,
|
|
);
|
|
expect(executedCommands).toContain(
|
|
`git branch -D opencode/test-session/model`,
|
|
);
|
|
const expectedTagCmd = `git tag archive/opencode/test-session/model-2023-01-01T00-00-00-000Z opencode/test-session/model`;
|
|
expect(executedCommands).toContain(expectedTagCmd);
|
|
});
|
|
|
|
test("warning when tag creation fails", async () => {
|
|
mockCommandResponses["tmux has-session -t test-session"] = {
|
|
ok: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
const worktreePath =
|
|
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
|
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: worktreeListOutput,
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses["git branch -D opencode/test-session/model"] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
// Tag command will fail
|
|
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
|
const OriginalDate = Date;
|
|
global.Date = class extends OriginalDate {
|
|
constructor() {
|
|
super();
|
|
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
|
return fixedDate;
|
|
}
|
|
override 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({
|
|
mode: "cli",
|
|
sessionName: "test-session",
|
|
cleanupWorktrees: true,
|
|
force: true,
|
|
});
|
|
// Restore Date
|
|
global.Date = OriginalDate;
|
|
|
|
expect(result.success).toBe(true);
|
|
// Expect the warning message to be captured in instructions
|
|
expect(result.instructions.includes("Failed to create tag")).toBe(true);
|
|
expect(result.tagsCreated).toEqual([]);
|
|
});
|
|
|
|
test("cleanup works with unsanitized session name", async () => {
|
|
const unsanitized = "test session!";
|
|
const sanitized = "test-session"; // result of sanitizeName
|
|
// tmux lookup uses unsanitized name
|
|
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
|
ok: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
const worktreePath = `/home/user/.local/share/opencode/multi-model/${sanitized}/model`;
|
|
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/${sanitized}/model`;
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: worktreeListOutput,
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[`git branch -D opencode/${sanitized}/model`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[
|
|
`rm -rf /home/user/.local/share/opencode/multi-model/${sanitized}`
|
|
] = { ok: true, stdout: "", stderr: "" };
|
|
|
|
// Freeze timestamp for deterministic tag name
|
|
const fixedDate = new Date("2023-01-01T00:00:00.000Z");
|
|
const OriginalDate = Date;
|
|
global.Date = class extends OriginalDate {
|
|
constructor() {
|
|
super();
|
|
// biome-ignore lint/correctness/noConstructorReturn: time-mocking required
|
|
return fixedDate;
|
|
}
|
|
override toISOString() {
|
|
return "2023-01-01T00:00:00.000Z";
|
|
}
|
|
} as any;
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: unsanitized,
|
|
cleanupWorktrees: true,
|
|
force: true,
|
|
});
|
|
// Restore Date
|
|
global.Date = OriginalDate;
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.cleanupPerformed).toBe(true);
|
|
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
|
expect(result.branchesDeleted).toEqual([`opencode/${sanitized}/model`]);
|
|
expect(result.tagsCreated?.length).toBe(1);
|
|
// Verify that the commands used the sanitized paths for worktree removal and branch deletion
|
|
expect(executedCommands).toContain(
|
|
`git worktree remove -f ${worktreePath}`,
|
|
);
|
|
expect(executedCommands).toContain(
|
|
`git branch -D opencode/${sanitized}/model`,
|
|
);
|
|
});
|
|
|
|
test("tmux commands use sessionName, not safeSessionName", async () => {
|
|
const unsanitized = "my session!";
|
|
const sanitized = "my-session";
|
|
mockCommandResponses[`tmux has-session -t ${unsanitized}`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[`tmux kill-session -t ${unsanitized}`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
// No worktrees
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: unsanitized,
|
|
cleanupWorktrees: false,
|
|
force: false,
|
|
});
|
|
expect(result.success).toBe(true);
|
|
// Verify tmux commands used unsanitized name
|
|
expect(executedCommands).toContain(`tmux has-session -t ${unsanitized}`);
|
|
expect(executedCommands).toContain(`tmux kill-session -t ${unsanitized}`);
|
|
// Ensure sanitized name not used in tmux commands
|
|
expect(executedCommands.some((c) => c.includes(`-t ${sanitized}`))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("skip archive tags when flag disabled", async () => {
|
|
mockCommandResponses["tmux has-session -t test-session"] = {
|
|
ok: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
const worktreePath =
|
|
"/home/user/.local/share/opencode/multi-model/test-session/model";
|
|
const worktreeListOutput = `worktree ${worktreePath}\nbranch refs/heads/opencode/test-session/model`;
|
|
mockCommandResponses["git worktree list --porcelain"] = {
|
|
ok: true,
|
|
stdout: worktreeListOutput,
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[`git worktree remove -f ${worktreePath}`] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses["git branch -D opencode/test-session/model"] = {
|
|
ok: true,
|
|
stdout: "",
|
|
stderr: "",
|
|
};
|
|
mockCommandResponses[
|
|
"rm -rf /home/user/.local/share/opencode/multi-model/test-session"
|
|
] = { ok: true, stdout: "", stderr: "" };
|
|
|
|
const result = await closeMultiModel({
|
|
mode: "cli",
|
|
sessionName: "test-session",
|
|
cleanupWorktrees: true,
|
|
force: true,
|
|
createArchiveTags: false,
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.cleanupPerformed).toBe(true);
|
|
expect(result.worktreesRemoved).toEqual([worktreePath]);
|
|
expect(result.branchesDeleted).toEqual(["opencode/test-session/model"]);
|
|
expect(result.tagsCreated?.length ?? 0).toBe(0);
|
|
// Ensure no tag command was executed
|
|
expect(executedCommands.some((cmd) => cmd.startsWith("git tag"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|