Roo-Code/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts
2025-03-06 11:31:08 -08:00

731 lines
27 KiB
TypeScript

// npx jest src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts
import fs from "fs/promises"
import path from "path"
import os from "os"
import { EventEmitter } from "events"
import { simpleGit, SimpleGit } from "simple-git"
import { fileExistsAtPath } from "../../../utils/fs"
import { ShadowCheckpointService } from "../ShadowCheckpointService"
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
import { RepoPerWorkspaceCheckpointService } from "../RepoPerWorkspaceCheckpointService"
jest.mock("globby", () => ({
globby: jest.fn().mockResolvedValue([]),
}))
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
const initWorkspaceRepo = async ({
workspaceDir,
userName = "Roo Code",
userEmail = "support@roocode.com",
testFileName = "test.txt",
textFileContent = "Hello, world!",
}: {
workspaceDir: string
userName?: string
userEmail?: string
testFileName?: string
textFileContent?: string
}) => {
// Create a temporary directory for testing.
await fs.mkdir(workspaceDir, { recursive: true })
// Initialize git repo.
const git = simpleGit(workspaceDir)
await git.init()
await git.addConfig("user.name", userName)
await git.addConfig("user.email", userEmail)
// Create test file.
const testFile = path.join(workspaceDir, testFileName)
await fs.writeFile(testFile, textFileContent)
// Create initial commit.
await git.add(".")
await git.commit("Initial commit")!
return { git, testFile }
}
describe.each([
[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"],
[RepoPerWorkspaceCheckpointService, "RepoPerWorkspaceCheckpointService"],
])("CheckpointService", (klass, prefix) => {
const taskId = "test-task"
let workspaceGit: SimpleGit
let testFile: string
let service: RepoPerTaskCheckpointService | RepoPerWorkspaceCheckpointService
beforeEach(async () => {
jest.mocked(require("globby").globby).mockClear().mockResolvedValue([])
const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`)
const repo = await initWorkspaceRepo({ workspaceDir })
workspaceGit = repo.git
testFile = repo.testFile
service = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await service.initShadowGit()
})
afterEach(async () => {
jest.restoreAllMocks()
})
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe(`${klass.name}#getDiff`, () => {
it("returns the correct diff between commits", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("Ahoy, world!")
expect(commit1?.commit).toBeTruthy()
await fs.writeFile(testFile, "Goodbye, world!")
const commit2 = await service.saveCheckpoint("Goodbye, world!")
expect(commit2?.commit).toBeTruthy()
const diff1 = await service.getDiff({ to: commit1!.commit })
expect(diff1).toHaveLength(1)
expect(diff1[0].paths.relative).toBe("test.txt")
expect(diff1[0].paths.absolute).toBe(testFile)
expect(diff1[0].content.before).toBe("Hello, world!")
expect(diff1[0].content.after).toBe("Ahoy, world!")
const diff2 = await service.getDiff({ from: service.baseHash, to: commit2!.commit })
expect(diff2).toHaveLength(1)
expect(diff2[0].paths.relative).toBe("test.txt")
expect(diff2[0].paths.absolute).toBe(testFile)
expect(diff2[0].content.before).toBe("Hello, world!")
expect(diff2[0].content.after).toBe("Goodbye, world!")
const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
expect(diff12).toHaveLength(1)
expect(diff12[0].paths.relative).toBe("test.txt")
expect(diff12[0].paths.absolute).toBe(testFile)
expect(diff12[0].content.before).toBe("Ahoy, world!")
expect(diff12[0].content.after).toBe("Goodbye, world!")
})
it("handles new files in diff", async () => {
const newFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(newFile, "New file content")
const commit = await service.saveCheckpoint("Add new file")
expect(commit?.commit).toBeTruthy()
const changes = await service.getDiff({ to: commit!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change?.content.before).toBe("")
expect(change?.content.after).toBe("New file content")
})
it("handles deleted files in diff", async () => {
const fileToDelete = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(fileToDelete, "New file content")
const commit1 = await service.saveCheckpoint("Add file")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(fileToDelete)
const commit2 = await service.saveCheckpoint("Delete file")
expect(commit2?.commit).toBeTruthy()
const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
const change = changes.find((c) => c.paths.relative === "new.txt")
expect(change).toBeDefined()
expect(change!.content.before).toBe("New file content")
expect(change!.content.after).toBe("")
})
})
describe(`${klass.name}#saveCheckpoint`, () => {
it("creates a checkpoint if there are pending changes", async () => {
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const details1 = await service.getDiff({ to: commit1!.commit })
expect(details1[0].content.before).toContain("Hello, world!")
expect(details1[0].content.after).toContain("Ahoy, world!")
await fs.writeFile(testFile, "Hola, world!")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit })
expect(details2[0].content.before).toContain("Ahoy, world!")
expect(details2[0].content.after).toContain("Hola, world!")
// Switch to checkpoint 1.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!")
// Switch to checkpoint 2.
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!")
// Switch back to initial commit.
expect(service.baseHash).toBeTruthy()
await service.restoreCheckpoint(service.baseHash!)
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
})
it("preserves workspace and index state after saving checkpoint", async () => {
// Create three files with different states: staged, unstaged, and mixed.
const unstagedFile = path.join(service.workspaceDir, "unstaged.txt")
const stagedFile = path.join(service.workspaceDir, "staged.txt")
const mixedFile = path.join(service.workspaceDir, "mixed.txt")
await fs.writeFile(unstagedFile, "Initial unstaged")
await fs.writeFile(stagedFile, "Initial staged")
await fs.writeFile(mixedFile, "Initial mixed")
await workspaceGit.add(["."])
const result = await workspaceGit.commit("Add initial files")
expect(result?.commit).toBeTruthy()
await fs.writeFile(unstagedFile, "Modified unstaged")
await fs.writeFile(stagedFile, "Modified staged")
await workspaceGit.add([stagedFile])
await fs.writeFile(mixedFile, "Modified mixed - staged")
await workspaceGit.add([mixedFile])
await fs.writeFile(mixedFile, "Modified mixed - unstaged")
// Save checkpoint.
const commit = await service.saveCheckpoint("Test checkpoint")
expect(commit?.commit).toBeTruthy()
// Verify workspace state is preserved.
const status = await workspaceGit.status()
// All files should be modified.
expect(status.modified).toContain("unstaged.txt")
expect(status.modified).toContain("staged.txt")
expect(status.modified).toContain("mixed.txt")
// Only staged and mixed files should be staged.
expect(status.staged).not.toContain("unstaged.txt")
expect(status.staged).toContain("staged.txt")
expect(status.staged).toContain("mixed.txt")
// Verify file contents.
expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged")
expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged")
expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged")
// Verify staged changes (--cached shows only staged changes).
const stagedDiff = await workspaceGit.diff(["--cached", "mixed.txt"])
expect(stagedDiff).toContain("-Initial mixed")
expect(stagedDiff).toContain("+Modified mixed - staged")
// Verify unstaged changes (shows working directory changes).
const unstagedDiff = await workspaceGit.diff(["mixed.txt"])
expect(unstagedDiff).toContain("-Modified mixed - staged")
expect(unstagedDiff).toContain("+Modified mixed - unstaged")
})
it("does not create a checkpoint if there are no pending changes", async () => {
const commit0 = await service.saveCheckpoint("Zeroth checkpoint")
expect(commit0?.commit).toBeFalsy()
await fs.writeFile(testFile, "Ahoy, world!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeFalsy()
})
it("includes untracked files in checkpoints", async () => {
// Create an untracked file.
const untrackedFile = path.join(service.workspaceDir, "untracked.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
// Save a checkpoint with the untracked file.
const commit1 = await service.saveCheckpoint("Checkpoint with untracked file")
expect(commit1?.commit).toBeTruthy()
// Verify the untracked file was included in the checkpoint.
const details = await service.getDiff({ to: commit1!.commit })
expect(details[0].content.before).toContain("")
expect(details[0].content.after).toContain("I am untracked!")
// Create another checkpoint with a different state.
await fs.writeFile(testFile, "Changed tracked file")
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Restore first checkpoint and verify untracked file is preserved.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!")
// Restore second checkpoint and verify untracked file remains (since
// restore preserves untracked files)
await service.restoreCheckpoint(commit2!.commit)
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file")
})
it("handles file deletions correctly", async () => {
await fs.writeFile(testFile, "I am tracked!")
const untrackedFile = path.join(service.workspaceDir, "new.txt")
await fs.writeFile(untrackedFile, "I am untracked!")
const commit1 = await service.saveCheckpoint("First checkpoint")
expect(commit1?.commit).toBeTruthy()
await fs.unlink(testFile)
await fs.unlink(untrackedFile)
const commit2 = await service.saveCheckpoint("Second checkpoint")
expect(commit2?.commit).toBeTruthy()
// Verify files are gone.
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
// Restore first checkpoint.
await service.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!")
expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!")
// Restore second checkpoint.
await service.restoreCheckpoint(commit2!.commit)
await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow()
await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow()
})
it("does not create a checkpoint for ignored files", async () => {
// Create a file that matches an ignored pattern (e.g., .log file).
const ignoredFile = path.join(service.workspaceDir, "ignored.log")
await fs.writeFile(ignoredFile, "Initial ignored content")
const commit = await service.saveCheckpoint("Ignored file checkpoint")
expect(commit?.commit).toBeFalsy()
await fs.writeFile(ignoredFile, "Modified ignored content")
const commit2 = await service.saveCheckpoint("Ignored file modified checkpoint")
expect(commit2?.commit).toBeFalsy()
expect(await fs.readFile(ignoredFile, "utf-8")).toBe("Modified ignored content")
})
it("does not create a checkpoint for LFS files", async () => {
// Create a .gitattributes file with LFS patterns.
const gitattributesPath = path.join(service.workspaceDir, ".gitattributes")
await fs.writeFile(gitattributesPath, "*.lfs filter=lfs diff=lfs merge=lfs -text")
// Re-initialize the service to trigger a write to .git/info/exclude.
service = new klass(service.taskId, service.checkpointsDir, service.workspaceDir, () => {})
const excludesPath = path.join(service.checkpointsDir, ".git", "info", "exclude")
expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).not.toContain("*.lfs")
await service.initShadowGit()
expect((await fs.readFile(excludesPath, "utf-8")).split("\n")).toContain("*.lfs")
const commit0 = await service.saveCheckpoint("Add gitattributes")
expect(commit0?.commit).toBeTruthy()
// Create a file that matches an LFS pattern.
const lfsFile = path.join(service.workspaceDir, "foo.lfs")
await fs.writeFile(lfsFile, "Binary file content simulation")
const commit = await service.saveCheckpoint("LFS file checkpoint")
expect(commit?.commit).toBeFalsy()
await fs.writeFile(lfsFile, "Modified binary content")
const commit2 = await service.saveCheckpoint("LFS file modified checkpoint")
expect(commit2?.commit).toBeFalsy()
expect(await fs.readFile(lfsFile, "utf-8")).toBe("Modified binary content")
})
})
describe(`${klass.name}#create`, () => {
it("initializes a git repository if one does not already exist", async () => {
const shadowDir = path.join(tmpDir, `${prefix}2-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace2-${Date.now()}`)
await fs.mkdir(workspaceDir)
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Hello, world!")
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Ensure the git repository was initialized.
const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
const { created } = await newService.initShadowGit()
expect(created).toBeTruthy()
const gitDir = path.join(newService.checkpointsDir, ".git")
expect(await fs.stat(gitDir)).toBeTruthy()
// Save a new checkpoint: Ahoy, world!
await fs.writeFile(newTestFile, "Ahoy, world!")
const commit1 = await newService.saveCheckpoint("Ahoy, world!")
expect(commit1?.commit).toBeTruthy()
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
// Restore "Hello, world!"
await newService.restoreCheckpoint(newService.baseHash!)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!")
// Restore "Ahoy, world!"
await newService.restoreCheckpoint(commit1!.commit)
expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!")
await fs.rm(newService.checkpointsDir, { recursive: true, force: true })
await fs.rm(newService.workspaceDir, { recursive: true, force: true })
})
})
describe(`${klass.name}#renameNestedGitRepos`, () => {
it("handles nested git repositories during initialization", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace-nested-git-${Date.now()}`)
// Create a primary workspace repo.
await fs.mkdir(workspaceDir, { recursive: true })
const mainGit = simpleGit(workspaceDir)
await mainGit.init()
await mainGit.addConfig("user.name", "Roo Code")
await mainGit.addConfig("user.email", "support@roocode.com")
// Create a nested repo inside the workspace.
const nestedRepoPath = path.join(workspaceDir, "nested-project")
await fs.mkdir(nestedRepoPath, { recursive: true })
const nestedGit = simpleGit(nestedRepoPath)
await nestedGit.init()
await nestedGit.addConfig("user.name", "Roo Code")
await nestedGit.addConfig("user.email", "support@roocode.com")
// Add a file to the nested repo.
const nestedFile = path.join(nestedRepoPath, "nested-file.txt")
await fs.writeFile(nestedFile, "Content in nested repo")
await nestedGit.add(".")
await nestedGit.commit("Initial commit in nested repo")
// Create a test file in the main workspace.
const mainFile = path.join(workspaceDir, "main-file.txt")
await fs.writeFile(mainFile, "Content in main repo")
await mainGit.add(".")
await mainGit.commit("Initial commit in main repo")
// Confirm nested git directory exists before initialization.
const nestedGitDir = path.join(nestedRepoPath, ".git")
const nestedGitDisabledDir = `${nestedGitDir}_disabled`
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
// Configure globby mock to return our nested git repository.
const relativeGitPath = path.relative(workspaceDir, nestedGitDir)
jest.mocked(require("globby").globby).mockImplementation((pattern: string | string[]) => {
if (pattern === "**/.git") {
return Promise.resolve([relativeGitPath])
} else if (pattern === "**/.git_disabled") {
return Promise.resolve([`${relativeGitPath}_disabled`])
}
return Promise.resolve([])
})
// Create a spy on fs.rename to track when it's called.
const renameSpy = jest.spyOn(fs, "rename")
// Initialize the shadow git service.
const service = new klass(taskId, shadowDir, workspaceDir, () => {})
// Override renameNestedGitRepos to track calls.
const originalRenameMethod = service["renameNestedGitRepos"].bind(service)
let disableCall = false
let enableCall = false
service["renameNestedGitRepos"] = async (disable: boolean) => {
if (disable) {
disableCall = true
} else {
enableCall = true
}
return originalRenameMethod(disable)
}
// Initialize the shadow git repo.
await service.initShadowGit()
// Verify both disable and enable were called.
expect(disableCall).toBe(true)
expect(enableCall).toBe(true)
// Verify rename was called with correct paths.
const renameCallsArgs = renameSpy.mock.calls.map((call) => call[0] + " -> " + call[1])
expect(
renameCallsArgs.some((args) => args.includes(nestedGitDir) && args.includes(nestedGitDisabledDir)),
).toBe(true)
expect(
renameCallsArgs.some((args) => args.includes(nestedGitDisabledDir) && args.includes(nestedGitDir)),
).toBe(true)
// Verify the nested git directory is back to normal after initialization.
expect(await fileExistsAtPath(nestedGitDir)).toBe(true)
expect(await fileExistsAtPath(nestedGitDisabledDir)).toBe(false)
// Clean up.
renameSpy.mockRestore()
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
})
describe(`${klass.name}#events`, () => {
it("emits initialize event when service is created", async () => {
const shadowDir = path.join(tmpDir, `${prefix}3-${Date.now()}`)
const workspaceDir = path.join(tmpDir, `workspace3-${Date.now()}`)
await fs.mkdir(workspaceDir, { recursive: true })
const newTestFile = path.join(workspaceDir, "test.txt")
await fs.writeFile(newTestFile, "Testing events!")
// Create a mock implementation of emit to track events.
const emitSpy = jest.spyOn(EventEmitter.prototype, "emit")
// Create the service - this will trigger the initialize event.
const newService = await klass.create({ taskId, shadowDir, workspaceDir, log: () => {} })
await newService.initShadowGit()
// Find the initialize event in the emit calls.
let initializeEvent = null
for (let i = 0; i < emitSpy.mock.calls.length; i++) {
const call = emitSpy.mock.calls[i]
if (call[0] === "initialize") {
initializeEvent = call[1]
break
}
}
// Restore the spy.
emitSpy.mockRestore()
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Verify the event was emitted with the correct data.
expect(initializeEvent).not.toBeNull()
expect(initializeEvent.type).toBe("initialize")
expect(initializeEvent.workspaceDir).toBe(workspaceDir)
expect(initializeEvent.baseHash).toBeTruthy()
expect(typeof initializeEvent.created).toBe("boolean")
expect(typeof initializeEvent.duration).toBe("number")
// Clean up.
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
it("emits checkpoint event when saving checkpoint", async () => {
const checkpointHandler = jest.fn()
service.on("checkpoint", checkpointHandler)
await fs.writeFile(testFile, "Changed content for checkpoint event test")
const result = await service.saveCheckpoint("Test checkpoint event")
expect(result?.commit).toBeDefined()
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.toHash).toBeDefined()
expect(eventData.toHash).toBe(result!.commit)
expect(typeof eventData.duration).toBe("number")
})
it("emits restore event when restoring checkpoint", async () => {
// First create a checkpoint to restore.
await fs.writeFile(testFile, "Content for restore test")
const commit = await service.saveCheckpoint("Checkpoint for restore test")
expect(commit?.commit).toBeTruthy()
// Change the file again.
await fs.writeFile(testFile, "Changed after checkpoint")
// Setup restore event listener.
const restoreHandler = jest.fn()
service.on("restore", restoreHandler)
// Restore the checkpoint.
await service.restoreCheckpoint(commit!.commit)
// Verify the event was emitted.
expect(restoreHandler).toHaveBeenCalledTimes(1)
const eventData = restoreHandler.mock.calls[0][0]
expect(eventData.type).toBe("restore")
expect(eventData.commitHash).toBe(commit!.commit)
expect(typeof eventData.duration).toBe("number")
// Verify the file was actually restored.
expect(await fs.readFile(testFile, "utf-8")).toBe("Content for restore test")
})
it("emits error event when an error occurs", async () => {
const errorHandler = jest.fn()
service.on("error", errorHandler)
// Force an error by providing an invalid commit hash.
const invalidCommitHash = "invalid-commit-hash"
// Try to restore an invalid checkpoint.
try {
await service.restoreCheckpoint(invalidCommitHash)
} catch (error) {
// Expected to throw, we're testing the event emission.
}
// Verify the error event was emitted.
expect(errorHandler).toHaveBeenCalledTimes(1)
const eventData = errorHandler.mock.calls[0][0]
expect(eventData.type).toBe("error")
expect(eventData.error).toBeInstanceOf(Error)
})
it("supports multiple event listeners for the same event", async () => {
const checkpointHandler1 = jest.fn()
const checkpointHandler2 = jest.fn()
service.on("checkpoint", checkpointHandler1)
service.on("checkpoint", checkpointHandler2)
await fs.writeFile(testFile, "Content for multiple listeners test")
const result = await service.saveCheckpoint("Testing multiple listeners")
// Verify both handlers were called with the same event data.
expect(checkpointHandler1).toHaveBeenCalledTimes(1)
expect(checkpointHandler2).toHaveBeenCalledTimes(1)
const eventData1 = checkpointHandler1.mock.calls[0][0]
const eventData2 = checkpointHandler2.mock.calls[0][0]
expect(eventData1).toEqual(eventData2)
expect(eventData1.type).toBe("checkpoint")
expect(eventData1.toHash).toBe(result?.commit)
})
it("allows removing event listeners", async () => {
const checkpointHandler = jest.fn()
// Add the listener.
service.on("checkpoint", checkpointHandler)
// Make a change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 1")
await service.saveCheckpoint("Testing listener - part 1")
// Verify handler was called.
expect(checkpointHandler).toHaveBeenCalledTimes(1)
checkpointHandler.mockClear()
// Remove the listener.
service.off("checkpoint", checkpointHandler)
// Make another change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 2")
await service.saveCheckpoint("Testing listener - part 2")
// Verify handler was not called after being removed.
expect(checkpointHandler).not.toHaveBeenCalled()
})
})
})
describe("ShadowCheckpointService", () => {
const taskId = "test-task-storage"
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
const globalStorageDir = path.join(tmpDir, "global-storage-dir")
const workspaceDir = path.join(tmpDir, "workspace-dir")
const workspaceHash = ShadowCheckpointService.hashWorkspaceDir(workspaceDir)
beforeEach(async () => {
await fs.mkdir(globalStorageDir, { recursive: true })
await fs.mkdir(workspaceDir, { recursive: true })
})
afterEach(async () => {
await fs.rm(globalStorageDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
describe("getTaskStorage", () => {
it("returns 'task' when task repo exists", async () => {
const service = RepoPerTaskCheckpointService.create({
taskId,
shadowDir: globalStorageDir,
workspaceDir,
log: () => {},
})
await service.initShadowGit()
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
expect(storage).toBe("task")
})
it("returns 'workspace' when workspace repo exists with task branch", async () => {
const service = RepoPerWorkspaceCheckpointService.create({
taskId,
shadowDir: globalStorageDir,
workspaceDir,
log: () => {},
})
await service.initShadowGit()
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
expect(storage).toBe("workspace")
})
it("returns undefined when no repos exist", async () => {
const storage = await ShadowCheckpointService.getTaskStorage({ taskId, globalStorageDir, workspaceDir })
expect(storage).toBeUndefined()
})
it("returns undefined when workspace repo exists but has no task branch", async () => {
// Setup: Create workspace repo without the task branch
const workspaceRepoDir = path.join(globalStorageDir, "checkpoints", workspaceHash)
await fs.mkdir(workspaceRepoDir, { recursive: true })
// Create git repo without adding the specific branch
const git = simpleGit(workspaceRepoDir)
await git.init()
await git.addConfig("user.name", "Roo Code")
await git.addConfig("user.email", "noreply@example.com")
// We need to create a commit, but we won't create the specific branch
const testFile = path.join(workspaceRepoDir, "test.txt")
await fs.writeFile(testFile, "Test content")
await git.add(".")
await git.commit("Initial commit")
const storage = await ShadowCheckpointService.getTaskStorage({
taskId,
globalStorageDir,
workspaceDir,
})
expect(storage).toBeUndefined()
})
})
})