Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/core/checkpoints/__tests__/changeJournal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import fs from "fs/promises"
import os from "os"
import path from "path"

import { afterEach, beforeEach, describe, expect, it } from "vitest"

import { appendChange, journalPath, loadChanges, type ChangeJournalEntry } from "../changeJournal"

describe("changeJournal", () => {
const taskId = "test-task"

let tmpRoot: string

beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-"))
})

afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true })
})

function entry(overrides: Partial<ChangeJournalEntry> = {}): ChangeJournalEntry {
return {
path: "src/foo.ts",
operation: "create",
checkpointId: "abc12345",
...overrides,
}
}

describe("journalPath", () => {
it("places the journal at <root>/tasks/<taskId>/checkpoints/changes.jsonl", () => {
expect(journalPath(tmpRoot, taskId)).toBe(
path.join(tmpRoot, "tasks", taskId, "checkpoints", "changes.jsonl"),
)
})
})

describe("appendChange", () => {
it("writes one JSON line per entry with the documented field shape", async () => {
await appendChange(tmpRoot, taskId, entry({ operation: "create", checkpointId: "aaa" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
const lines = raw.split("\n").filter((line) => line !== "")
expect(lines).toHaveLength(1)
const parsed = JSON.parse(lines[0]) as ChangeJournalEntry
expect(parsed.path).toBe("src/foo.ts")
expect(parsed.operation).toBe("create")
expect(parsed.checkpointId).toBe("aaa")
})

it("appends multiple entries sequentially", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "a" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "b" }))

const raw = await fs.readFile(journalPath(tmpRoot, taskId), "utf8")
expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(2)
})
})

describe("loadChanges", () => {
it("returns [] for an absent journal file", async () => {
expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("returns [] for an empty journal file", async () => {
await fs.mkdir(path.dirname(journalPath(tmpRoot, taskId)), { recursive: true })
await fs.writeFile(journalPath(tmpRoot, taskId), "")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("parses all entries in order with a clean tail", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "x" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "y" }))
await appendChange(tmpRoot, taskId, entry({ checkpointId: "z" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(3)
expect(result[0].checkpointId).toBe("x")
expect(result[1].checkpointId).toBe("y")
expect(result[2].checkpointId).toBe("z")
})

it("parses a journal whose final line has no trailing newline", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Rewrite the file without the trailing newline of the last line.
const filePath = journalPath(tmpRoot, taskId)
const content = (await fs.readFile(filePath, "utf8")).replace(/\n$/, "")
await fs.writeFile(filePath, content)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("discards a torn final line and returns the complete entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Append a second line truncated mid-content, with no trailing newline.
await fs.appendFile(journalPath(tmpRoot, taskId), '{"path":"src/half.ts","operation":"upd')

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("ok")
})

it("skips a corrupt middle line and still loads the later valid entries", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "ok" }))

// Corrupt the first line in place, then append a valid entry after it.
await fs.writeFile(
journalPath(tmpRoot, taskId),
'"{"path":"src/corrupt.ts","operation":"update"\n' +
JSON.stringify(entry({ checkpointId: "after" })) +
"\n",
)

const result = await loadChanges(tmpRoot, taskId)
expect(result).toHaveLength(1)
expect(result[0].checkpointId).toBe("after")
})

it("does not throw when the entire journal is torn", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "first" }))

// Truncate to a single character — definitely invalid JSON.
await fs.writeFile(journalPath(tmpRoot, taskId), "{")

expect(await loadChanges(tmpRoot, taskId)).toEqual([])
})

it("includes diffStats when present", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "s", diffStats: { additions: 5, deletions: 2 } }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toEqual({ additions: 5, deletions: 2 })
})

it("omits diffStats when not provided", async () => {
await appendChange(tmpRoot, taskId, entry({ checkpointId: "n" }))

const result = await loadChanges(tmpRoot, taskId)
expect(result[0].diffStats).toBeUndefined()
})
})
})
225 changes: 225 additions & 0 deletions src/core/checkpoints/__tests__/index.checkpointJournal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import fs from "fs/promises"
import os from "os"
import path from "path"

import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"

import type { Task } from "../../task/Task"
import { loadChanges } from "../changeJournal"
import { checkpointSave, type CheckpointWriteInfo } from "../index"

// Mock the VS Code API surface (index.ts imports vscode at module level).
vi.mock("vscode", () => ({
window: {
showErrorMessage: vi.fn(),
showInformationMessage: vi.fn(),
createTextEditorDecorationType: vi.fn(() => ({})),
},
Uri: {
file: vi.fn((p: string) => ({ fsPath: p })),
parse: vi.fn((uri: string) => ({ with: vi.fn(() => ({})) })),
},
commands: {
executeCommand: vi.fn(),
},
}))

vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureCheckpointCreated: vi.fn(),
captureCheckpointRestored: vi.fn(),
captureCheckpointDiffed: vi.fn(),
},
},
}))

vi.mock("../../../utils/path", () => ({
getWorkspacePath: vi.fn(() => "/test/workspace"),
}))

vi.mock("../../../utils/git", () => ({
checkGitInstalled: vi.fn().mockResolvedValue(true),
}))

vi.mock("../../../i18n", () => ({
t: vi.fn((key: string) => key),
}))

vi.mock("p-wait-for", () => ({
default: vi.fn(),
}))

// The real service would require a git repo; the checkpointSave under test
// only needs the pre-initialized service on the task.
vi.mock("../../../services/checkpoints")

const TASK_ID = "journal-test-task"
const COMMIT = "test-commit-hash"

interface ServiceLike {
isInitialized: boolean
saveCheckpoint: (...args: unknown[]) => Promise<unknown>
}

interface ProviderLike {
context: { globalStorageUri: { fsPath: string } }
log: (...args: unknown[]) => void
postMessageToWebview: (...args: unknown[]) => void
}

interface TaskLike {
taskId: string
enableCheckpoints: boolean
checkpointService: ServiceLike
checkpointServiceInitializing: boolean
providerRef: { deref: () => ProviderLike | undefined }
}

describe("checkpointSave change-journal wiring (B2)", () => {
let tmpStorageDir: string
let saveCheckpointSpy: Mock
let mockProvider: ProviderLike
let mockTask: TaskLike
const write: CheckpointWriteInfo = {
path: "src/foo.ts",
operation: "create",
diffStats: { additions: 3, deletions: 0 },
}

beforeEach(async () => {
tmpStorageDir = await fs.mkdtemp(path.join(os.tmpdir(), "b2-journal-wiring-"))
saveCheckpointSpy = vi.fn().mockResolvedValue({ commit: COMMIT })
mockProvider = {
context: { globalStorageUri: { fsPath: tmpStorageDir } },
log: vi.fn(),
postMessageToWebview: vi.fn(),
}
// Structural test double for Task (the class is not instantiated at
// this unit layer); the cast is safe because the fields checkpointSave
// reads are exactly these.
mockTask = {
taskId: TASK_ID,
enableCheckpoints: true,
checkpointService: { isInitialized: true, saveCheckpoint: saveCheckpointSpy },
checkpointServiceInitializing: false,
providerRef: { deref: () => mockProvider },
}
})

afterEach(async () => {
vi.restoreAllMocks()
await fs.rm(tmpStorageDir, { recursive: true, force: true })
})

it("appends exactly one journal line referencing the B1 checkpoint id for a per-write save", async () => {
await checkpointSave(mockTask as Task, false, true, write)

const entries = await loadChanges(tmpStorageDir, TASK_ID)
expect(entries).toHaveLength(1)
expect(entries[0]).toEqual({
path: "src/foo.ts",
operation: "create",
checkpointId: COMMIT,
diffStats: { additions: 3, deletions: 0 },
})

// The raw file holds exactly one JSON line.
const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl")
const raw = await fs.readFile(journalFile, "utf8")
expect(raw.split("\n").filter((line) => line !== "")).toHaveLength(1)
})

it("omits diffStats in the journal entry when not provided", async () => {
await checkpointSave(mockTask as Task, false, true, { path: "src/bar.ts", operation: "update" })

const entries = await loadChanges(tmpStorageDir, TASK_ID)
expect(entries).toHaveLength(1)
expect(entries[0].path).toBe("src/bar.ts")
expect(entries[0].operation).toBe("update")
expect(entries[0].checkpointId).toBe(COMMIT)
expect(entries[0].diffStats).toBeUndefined()
})

it("does not write a journal entry for non-write checkpoint saves (task-start baseline)", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)

await checkpointSave(mockTask as Task)

// The default save keeps the documented behavior for both flags.
expect(saveCheckpointSpy).toHaveBeenCalledWith(expect.stringContaining(`Task: ${TASK_ID}`), {
allowEmpty: false,
suppressMessage: false,
})
const journalFile = path.join(tmpStorageDir, "tasks", TASK_ID, "checkpoints", "changes.jsonl")
await expect(fs.stat(journalFile)).rejects.toThrow()
expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
// The baseline save must not reach the journal error path.
expect(consoleErrorSpy).not.toHaveBeenCalled()
})

it("appends one entry per file change for a multi-file write (apply-patch shape)", async () => {
await checkpointSave(mockTask as Task, false, true, [
{ path: "src/a.ts", operation: "create" },
{ path: "src/b.ts", operation: "update" },
{ path: "src/c.ts", operation: "delete" },
])

const entries = await loadChanges(tmpStorageDir, TASK_ID)
expect(entries).toHaveLength(3)
// Every entry references the single checkpoint of the whole patch.
expect(entries.map((entry) => entry.checkpointId)).toEqual([COMMIT, COMMIT, COMMIT])
expect(entries.map((entry) => entry.path)).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"])
expect(entries.map((entry) => entry.operation)).toEqual(["create", "update", "delete"])
})

it("keeps the existing error-swallowing behavior and skips the journal on save failure", async () => {
saveCheckpointSpy.mockRejectedValueOnce(new Error("git exploded"))

await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toBeUndefined()
expect(mockTask.enableCheckpoints).toBe(false)
expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
})

it("does not write a journal entry when the checkpoint save is a no-op (empty commit)", async () => {
saveCheckpointSpy.mockResolvedValueOnce(undefined)
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)

await checkpointSave(mockTask as Task, false, true, write)

expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
expect(mockTask.enableCheckpoints).toBe(true)
// A no-op commit is skipped cleanly, not error-swallowed.
expect(consoleErrorSpy).not.toHaveBeenCalled()
})

it("does not crash when the provider has no globalStorageDir", async () => {
mockTask.providerRef = { deref: () => undefined }
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)

await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT })
expect(mockTask.enableCheckpoints).toBe(true)
expect(await loadChanges(tmpStorageDir, TASK_ID)).toEqual([])
// Without a storage dir the journal write is skipped, not error-swallowed.
expect(consoleErrorSpy).not.toHaveBeenCalled()
})

it("logs and continues when the journal cannot be written (checkpoints stay enabled)", async () => {
// Block the per-task checkpoint dir so the journal mkdir/append fails.
const taskDir = path.join(tmpStorageDir, "tasks", TASK_ID)
await fs.mkdir(taskDir, { recursive: true })
await fs.writeFile(path.join(taskDir, "checkpoints"), "blocker")

const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined)

// The journal failure is swallowed (logged, not rethrown), so the
// checkpoint result still resolves exactly as without journaling.
await expect(checkpointSave(mockTask as Task, false, true, write)).resolves.toMatchObject({ commit: COMMIT })

expect(mockTask.enableCheckpoints).toBe(true)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("failed to append change journal entry"),
expect.anything(),
)
})
})
Loading
Loading