/**
* Tests for RewindManager
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { RewindManager, type FileChange } from "./rewind.js";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
// Mock fs operations
vi.mock("fs", () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
unlinkSync: vi.fn(),
}));
const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
const mockReadFileSync = readFileSync as ReturnType<typeof vi.fn>;
const mockWriteFileSync = writeFileSync as ReturnType<typeof vi.fn>;
const mockUnlinkSync = unlinkSync as ReturnType<typeof vi.fn>;
describe("RewindManager", () => {
let manager: RewindManager;
beforeEach(() => {
manager = new RewindManager();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Checkpoint tracking ──
describe("addCheckpoint", () => {
it("should add a checkpoint with the correct data", () => {
manager.addCheckpoint(0, 2, "Let me read the file", []);
const cps = manager.getCheckpoints();
expect(cps).toHaveLength(1);
expect(cps[0].turnIndex).toBe(0);
expect(cps[0].messageCount).toBe(2);
expect(cps[0].summary).toBe("Let me read the file");
expect(cps[0].fileChanges).toEqual([]);
expect(cps[0].timestamp).toBeGreaterThan(0);
});
it("should truncate summary to 80 chars", () => {
const longSummary = "a".repeat(120);
manager.addCheckpoint(0, 2, longSummary, []);
expect(manager.getCheckpoints()[0].summary).toHaveLength(80);
});
it("should accumulate multiple checkpoints", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", []);
manager.addCheckpoint(2, 8, "Turn 3", []);
expect(manager.getCheckpoints()).toHaveLength(3);
expect(manager.getCheckpointCount()).toBe(3);
});
it("should include file changes in checkpoint", () => {
const changes: FileChange[] = [
{ filePath: "/src/foo.ts", backupPath: "/backup/123-foo.ts", operation: "edit", isNewFile: false },
{ filePath: "/src/bar.ts", backupPath: "", operation: "write", isNewFile: true },
];
manager.addCheckpoint(0, 3, "Modified files", changes);
expect(manager.getCheckpoints()[0].fileChanges).toHaveLength(2);
expect(manager.getCheckpoints()[0].fileChanges[1].isNewFile).toBe(true);
});
});
describe("getCheckpoints", () => {
it("should return a copy (not the internal array)", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
const cps = manager.getCheckpoints();
cps.push({} as any);
expect(manager.getCheckpoints()).toHaveLength(1);
});
});
// ── File change tracking ──
describe("trackFileChange / getCurrentFileChanges / commitTurn", () => {
it("should track pending file changes", () => {
const change: FileChange = {
filePath: "/src/test.ts",
backupPath: "/backup/123-test.ts",
operation: "edit",
isNewFile: false,
};
manager.trackFileChange(change);
expect(manager.getCurrentFileChanges()).toHaveLength(1);
expect(manager.getCurrentFileChanges()[0].filePath).toBe("/src/test.ts");
});
it("should clear pending changes on commitTurn", () => {
manager.trackFileChange({
filePath: "/src/test.ts",
backupPath: "/backup/123-test.ts",
operation: "edit",
isNewFile: false,
});
expect(manager.getCurrentFileChanges()).toHaveLength(1);
manager.commitTurn();
expect(manager.getCurrentFileChanges()).toHaveLength(0);
});
it("should return a copy of current file changes", () => {
manager.trackFileChange({
filePath: "/src/test.ts",
backupPath: "/backup/123-test.ts",
operation: "edit",
isNewFile: false,
});
const changes = manager.getCurrentFileChanges();
changes.push({} as any);
expect(manager.getCurrentFileChanges()).toHaveLength(1);
});
});
// ── Rewind ──
describe("rewindTo", () => {
it("should return error for invalid index", () => {
const result = manager.rewindTo(5);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("Invalid checkpoint index");
});
it("should truncate checkpoints and return correct messageCount", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", []);
manager.addCheckpoint(2, 8, "Turn 3", []);
const result = manager.rewindTo(0);
expect(result.messageCount).toBe(2);
expect(manager.getCheckpoints()).toHaveLength(1);
});
it("should restore edited files from backup", () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(Buffer.from("original content"));
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{
filePath: "/src/foo.ts",
backupPath: "/backup/123-foo.ts",
operation: "edit",
isNewFile: false,
},
]);
const result = manager.rewindTo(0);
expect(result.filesReverted).toContain("/src/foo.ts");
expect(mockReadFileSync).toHaveBeenCalledWith("/backup/123-foo.ts");
expect(mockWriteFileSync).toHaveBeenCalledWith("/src/foo.ts", Buffer.from("original content"));
});
it("should delete files that were created during reverted turns", () => {
mockExistsSync.mockReturnValue(true);
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{
filePath: "/src/new-file.ts",
backupPath: "",
operation: "write",
isNewFile: true,
},
]);
const result = manager.rewindTo(0);
expect(result.filesDeleted).toContain("/src/new-file.ts");
expect(mockUnlinkSync).toHaveBeenCalledWith("/src/new-file.ts");
});
it("should handle multiple checkpoints with file changes", () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(Buffer.from("original"));
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/a.ts", backupPath: "/backup/a.ts", operation: "edit", isNewFile: false },
]);
manager.addCheckpoint(2, 8, "Turn 3", [
{ filePath: "/src/b.ts", backupPath: "", operation: "write", isNewFile: true },
{ filePath: "/src/c.ts", backupPath: "/backup/c.ts", operation: "multi_edit", isNewFile: false },
]);
const result = manager.rewindTo(0);
expect(result.filesReverted).toHaveLength(2); // a.ts and c.ts
expect(result.filesDeleted).toHaveLength(1); // b.ts
expect(manager.getCheckpoints()).toHaveLength(1);
});
it("should report error when backup file is missing", () => {
mockExistsSync.mockImplementation((path: string) => {
// File exists but backup does not
return path === "/src/foo.ts";
});
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/foo.ts", backupPath: "/backup/missing.ts", operation: "edit", isNewFile: false },
]);
const result = manager.rewindTo(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("No backup found");
});
it("should handle fs errors gracefully", () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation(() => { throw new Error("Permission denied"); });
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/foo.ts", backupPath: "/backup/foo.ts", operation: "edit", isNewFile: false },
]);
const result = manager.rewindTo(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("Permission denied");
});
it("should clear pending file changes on rewind", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.trackFileChange({
filePath: "/src/test.ts",
backupPath: "/backup/test.ts",
operation: "edit",
isNewFile: false,
});
manager.rewindTo(0);
expect(manager.getCurrentFileChanges()).toHaveLength(0);
});
it("should deduplicate reverted file paths", () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(Buffer.from("original"));
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/foo.ts", backupPath: "/backup/1-foo.ts", operation: "edit", isNewFile: false },
]);
manager.addCheckpoint(2, 8, "Turn 3", [
{ filePath: "/src/foo.ts", backupPath: "/backup/2-foo.ts", operation: "edit", isNewFile: false },
]);
const result = manager.rewindTo(0);
expect(result.filesReverted).toHaveLength(1); // deduplicated
});
it("should process checkpoints in reverse chronological order", () => {
const callOrder: string[] = [];
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: string) => {
callOrder.push(path as string);
return Buffer.from("content");
});
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/a.ts", backupPath: "/backup/turn2.ts", operation: "edit", isNewFile: false },
]);
manager.addCheckpoint(2, 8, "Turn 3", [
{ filePath: "/src/b.ts", backupPath: "/backup/turn3.ts", operation: "edit", isNewFile: false },
]);
manager.rewindTo(0);
// Turn 3 should be processed before Turn 2 (reverse chronological)
expect(callOrder[0]).toBe("/backup/turn3.ts");
expect(callOrder[1]).toBe("/backup/turn2.ts");
});
});
describe("revertFilesFrom", () => {
it("should revert files without truncating checkpoints", () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(Buffer.from("original"));
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/foo.ts", backupPath: "/backup/foo.ts", operation: "edit", isNewFile: false },
]);
const result = manager.revertFilesFrom(0);
expect(result.filesReverted).toContain("/src/foo.ts");
// Checkpoints should NOT be truncated
expect(manager.getCheckpoints()).toHaveLength(2);
});
});
// ── Utility methods ──
describe("hasFileChanges", () => {
it("should return false when no file changes exist", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
expect(manager.hasFileChanges()).toBe(false);
});
it("should return true when file changes exist", () => {
manager.addCheckpoint(0, 2, "Turn 1", [
{ filePath: "/src/foo.ts", backupPath: "/backup/foo.ts", operation: "edit", isNewFile: false },
]);
expect(manager.hasFileChanges()).toBe(true);
});
});
describe("getFileChangeCountAfter", () => {
it("should count file changes after the given checkpoint", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.addCheckpoint(1, 5, "Turn 2", [
{ filePath: "/src/a.ts", backupPath: "/backup/a.ts", operation: "edit", isNewFile: false },
]);
manager.addCheckpoint(2, 8, "Turn 3", [
{ filePath: "/src/b.ts", backupPath: "", operation: "write", isNewFile: true },
{ filePath: "/src/c.ts", backupPath: "/backup/c.ts", operation: "edit", isNewFile: false },
]);
expect(manager.getFileChangeCountAfter(0)).toBe(3); // Turn 2 (1) + Turn 3 (2)
expect(manager.getFileChangeCountAfter(1)).toBe(2); // Turn 3 (2)
expect(manager.getFileChangeCountAfter(2)).toBe(0); // Nothing after Turn 3
});
});
describe("reset", () => {
it("should clear all state", () => {
manager.addCheckpoint(0, 2, "Turn 1", []);
manager.trackFileChange({
filePath: "/src/test.ts",
backupPath: "/backup/test.ts",
operation: "edit",
isNewFile: false,
});
manager.reset();
expect(manager.getCheckpoints()).toHaveLength(0);
expect(manager.getCurrentFileChanges()).toHaveLength(0);
});
});
});