Skip to content

Commit 550f2c3

Browse files
committed
test: cover multi-root path helpers
1 parent 5b6b41e commit 550f2c3

3 files changed

Lines changed: 365 additions & 2 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import path from "path"
2+
import fs from "fs/promises"
3+
4+
import type { MockedFunction } from "vitest"
5+
6+
import { ApplyPatchTool } from "../ApplyPatchTool"
7+
import type { ToolCallbacks } from "../BaseTool"
8+
import type { Task } from "../../task/Task"
9+
import { fileExistsAtPath } from "../../../utils/fs"
10+
import { isPathOutsideWorkspace, resolvePathInWorkspace, getWorkspaceReadablePath } from "../../../utils/pathUtils"
11+
import { parsePatch, processAllHunks } from "../apply-patch"
12+
13+
vi.mock("fs/promises", () => ({
14+
default: {
15+
readFile: vi.fn(),
16+
writeFile: vi.fn(),
17+
mkdir: vi.fn(),
18+
unlink: vi.fn(),
19+
},
20+
}))
21+
22+
vi.mock("../../../utils/fs", () => ({
23+
fileExistsAtPath: vi.fn(),
24+
}))
25+
26+
vi.mock("../../../utils/pathUtils", async () => {
27+
const actual = await vi.importActual<typeof import("../../../utils/pathUtils")>("../../../utils/pathUtils")
28+
return {
29+
...actual,
30+
isPathOutsideWorkspace: vi.fn(),
31+
resolvePathInWorkspace: vi.fn(),
32+
getWorkspaceReadablePath: vi.fn(),
33+
}
34+
})
35+
36+
vi.mock("../../prompts/responses", () => ({
37+
formatResponse: {
38+
createPrettyPatch: vi.fn((filePath: string, oldContent: string, newContent: string) => {
39+
return `--- ${filePath}\n+++ ${filePath}\n-${oldContent}\n+${newContent}`
40+
}),
41+
rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`),
42+
toolError: vi.fn((message: string) => `Error: ${message}`),
43+
},
44+
}))
45+
46+
vi.mock("../../diff/stats", () => ({
47+
sanitizeUnifiedDiff: vi.fn((diff: string) => diff),
48+
computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })),
49+
}))
50+
51+
vi.mock("../../../shared/experiments", () => ({
52+
EXPERIMENT_IDS: {
53+
PREVENT_FOCUS_DISRUPTION: "prevent-focus-disruption",
54+
},
55+
experiments: {
56+
isEnabled: vi.fn().mockReturnValue(false),
57+
},
58+
}))
59+
60+
vi.mock("../apply-patch", () => ({
61+
parsePatch: vi.fn(),
62+
processAllHunks: vi.fn(),
63+
ParseError: class ParseError extends Error {},
64+
}))
65+
66+
describe("ApplyPatchTool.execute", () => {
67+
const cwd = path.join(path.sep, "workspace", "primary")
68+
const secondaryRoot = path.join(path.sep, "workspace", "secondary")
69+
70+
const mockedReadFile = fs.readFile as MockedFunction<typeof fs.readFile>
71+
const mockedWriteFile = fs.writeFile as MockedFunction<typeof fs.writeFile>
72+
const mockedMkdir = fs.mkdir as MockedFunction<typeof fs.mkdir>
73+
const mockedUnlink = fs.unlink as MockedFunction<typeof fs.unlink>
74+
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
75+
const mockedResolvePathInWorkspace = resolvePathInWorkspace as MockedFunction<typeof resolvePathInWorkspace>
76+
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
77+
const mockedGetWorkspaceReadablePath = getWorkspaceReadablePath as MockedFunction<typeof getWorkspaceReadablePath>
78+
const mockedParsePatch = parsePatch as MockedFunction<typeof parsePatch>
79+
const mockedProcessAllHunks = processAllHunks as MockedFunction<typeof processAllHunks>
80+
81+
let tool: ApplyPatchTool
82+
let task: any
83+
let callbacks: ToolCallbacks
84+
let askApproval: ReturnType<typeof vi.fn>
85+
let pushToolResult: ReturnType<typeof vi.fn>
86+
87+
beforeEach(() => {
88+
vi.clearAllMocks()
89+
90+
tool = new ApplyPatchTool()
91+
askApproval = vi.fn().mockResolvedValue(true)
92+
pushToolResult = vi.fn()
93+
94+
callbacks = {
95+
askApproval,
96+
pushToolResult,
97+
handleError: vi.fn(),
98+
}
99+
100+
task = {
101+
cwd,
102+
consecutiveMistakeCount: 0,
103+
didEditFile: false,
104+
diffViewProvider: {
105+
editType: undefined,
106+
originalContent: undefined,
107+
open: vi.fn().mockResolvedValue(undefined),
108+
update: vi.fn().mockResolvedValue(undefined),
109+
scrollToFirstDiff: vi.fn(),
110+
saveChanges: vi.fn().mockResolvedValue(undefined),
111+
saveDirectly: vi.fn().mockResolvedValue(undefined),
112+
pushToolWriteResult: vi.fn().mockResolvedValue("Tool result"),
113+
reset: vi.fn().mockResolvedValue(undefined),
114+
revertChanges: vi.fn().mockResolvedValue(undefined),
115+
},
116+
fileContextTracker: {
117+
trackFileContext: vi.fn().mockResolvedValue(undefined),
118+
},
119+
providerRef: {
120+
deref: vi.fn().mockReturnValue({
121+
getState: vi.fn().mockResolvedValue({
122+
diagnosticsEnabled: true,
123+
writeDelayMs: 25,
124+
experiments: {},
125+
}),
126+
}),
127+
},
128+
rooIgnoreController: {
129+
validateAccess: vi.fn().mockReturnValue(true),
130+
},
131+
rooProtectedController: {
132+
isWriteProtected: vi.fn().mockReturnValue(false),
133+
},
134+
processQueuedMessages: vi.fn(),
135+
recordToolError: vi.fn(),
136+
recordToolUsage: vi.fn(),
137+
say: vi.fn().mockResolvedValue(undefined),
138+
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing param"),
139+
}
140+
141+
mockedReadFile.mockResolvedValue("")
142+
mockedWriteFile.mockResolvedValue(undefined)
143+
mockedMkdir.mockResolvedValue(undefined)
144+
mockedUnlink.mockResolvedValue(undefined)
145+
mockedFileExistsAtPath.mockResolvedValue(false)
146+
mockedResolvePathInWorkspace.mockImplementation(async (_cwd, filePath) => path.join(cwd, filePath))
147+
mockedIsPathOutsideWorkspace.mockReturnValue(false)
148+
mockedGetWorkspaceReadablePath.mockImplementation((_cwd, _absolutePath, fallbackPath) => fallbackPath ?? "file")
149+
mockedParsePatch.mockReturnValue({ hunks: [{}] } as any)
150+
mockedProcessAllHunks.mockResolvedValue([])
151+
})
152+
153+
it("opens add-file diffs against the resolved secondary-root absolute path", async () => {
154+
const relPath = "nested/new-file.ts"
155+
const absolutePath = path.join(secondaryRoot, relPath)
156+
157+
mockedResolvePathInWorkspace.mockResolvedValue(absolutePath)
158+
mockedGetWorkspaceReadablePath.mockReturnValue(`secondary/${relPath}`)
159+
mockedProcessAllHunks.mockResolvedValue([
160+
{
161+
type: "add",
162+
path: relPath,
163+
newContent: "export const value = 1\n",
164+
},
165+
] as any)
166+
167+
await tool.execute(
168+
{ patch: "*** Begin Patch\n*** Add File: nested/new-file.ts\n*** End Patch" },
169+
task as Task,
170+
callbacks,
171+
)
172+
173+
expect(task.rooIgnoreController.validateAccess).toHaveBeenCalledWith(absolutePath)
174+
expect(task.diffViewProvider.open).toHaveBeenCalledWith(absolutePath)
175+
expect(task.diffViewProvider.update).toHaveBeenCalledWith("export const value = 1\n", true)
176+
expect(askApproval).toHaveBeenCalledWith(
177+
"tool",
178+
expect.stringContaining(`"path":"secondary/${relPath}"`),
179+
undefined,
180+
false,
181+
)
182+
expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(relPath, "roo_edited")
183+
expect(pushToolResult).toHaveBeenCalledWith("Tool result")
184+
})
185+
186+
it("writes moved files to the resolved secondary-root destination", async () => {
187+
const relPath = "src/original.ts"
188+
const absolutePath = path.join(cwd, relPath)
189+
const movePath = "secondary/moved.ts"
190+
const moveAbsolutePath = path.join(secondaryRoot, "moved.ts")
191+
192+
mockedFileExistsAtPath.mockResolvedValue(true)
193+
mockedResolvePathInWorkspace.mockImplementation(async (_cwd, filePath) => {
194+
if (filePath === relPath) {
195+
return absolutePath
196+
}
197+
if (filePath === movePath) {
198+
return moveAbsolutePath
199+
}
200+
return path.join(cwd, filePath)
201+
})
202+
mockedGetWorkspaceReadablePath.mockImplementation((_cwd, absolute, fallbackPath) => {
203+
if (absolute === absolutePath) {
204+
return relPath
205+
}
206+
if (absolute === moveAbsolutePath) {
207+
return "secondary/moved.ts"
208+
}
209+
return fallbackPath ?? "file"
210+
})
211+
mockedProcessAllHunks.mockResolvedValue([
212+
{
213+
type: "update",
214+
path: relPath,
215+
originalContent: "old\n",
216+
newContent: "new\n",
217+
movePath,
218+
},
219+
] as any)
220+
221+
await tool.execute(
222+
{ patch: "*** Begin Patch\n*** Update File: src/original.ts\n*** End Patch" },
223+
task as Task,
224+
callbacks,
225+
)
226+
227+
expect(task.rooIgnoreController.validateAccess).toHaveBeenNthCalledWith(1, absolutePath)
228+
expect(task.rooIgnoreController.validateAccess).toHaveBeenNthCalledWith(2, moveAbsolutePath)
229+
expect(task.diffViewProvider.open).toHaveBeenCalledWith(relPath)
230+
expect(mockedMkdir).toHaveBeenCalledWith(path.dirname(moveAbsolutePath), { recursive: true })
231+
expect(mockedWriteFile).toHaveBeenCalledWith(moveAbsolutePath, "new\n", "utf8")
232+
expect(mockedUnlink).toHaveBeenCalledWith(absolutePath)
233+
expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(movePath, "roo_edited")
234+
expect(pushToolResult).toHaveBeenCalledWith("Tool result")
235+
})
236+
})

src/core/tools/__tests__/writeToFileTool.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "path"
33
import type { MockedFunction } from "vitest"
44

55
import { fileExistsAtPath, createDirectoriesForFile } from "../../../utils/fs"
6-
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
6+
import { isPathOutsideWorkspace, resolvePathInWorkspace } from "../../../utils/pathUtils"
77
import { getReadablePath } from "../../../utils/path"
88
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
99
import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
@@ -101,13 +101,16 @@ describe("writeToFileTool", () => {
101101
// Test data
102102
const testFilePath = "test/file.txt"
103103
const absoluteFilePath = process.platform === "win32" ? "C:\\test\\file.txt" : "/test/file.txt"
104+
const outsideRootAbsolutePath =
105+
process.platform === "win32" ? "C:\\secondary\\test\\file.txt" : "/secondary/test/file.txt"
104106
const testContent = "Line 1\nLine 2\nLine 3"
105107
const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```"
106108

107109
// Mocked functions with correct types
108110
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
109111
const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction<typeof createDirectoriesForFile>
110112
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
113+
const mockedResolvePathInWorkspace = resolvePathInWorkspace as MockedFunction<typeof resolvePathInWorkspace>
111114
const mockedGetReadablePath = getReadablePath as MockedFunction<typeof getReadablePath>
112115
const mockedUnescapeHtmlEntities = unescapeHtmlEntities as MockedFunction<typeof unescapeHtmlEntities>
113116
const mockedEveryLineHasLineNumbers = everyLineHasLineNumbers as MockedFunction<typeof everyLineHasLineNumbers>
@@ -125,6 +128,9 @@ describe("writeToFileTool", () => {
125128
writeToFileTool.resetPartialState()
126129

127130
mockedPathResolve.mockReturnValue(absoluteFilePath)
131+
mockedResolvePathInWorkspace.mockImplementation(async (_cwd: string, filePath: string) =>
132+
path.resolve("/", filePath),
133+
)
128134
mockedFileExistsAtPath.mockResolvedValue(false)
129135
mockedIsPathOutsideWorkspace.mockReturnValue(false)
130136
mockedGetReadablePath.mockReturnValue("test/path.txt")
@@ -254,6 +260,15 @@ describe("writeToFileTool", () => {
254260
expect(mockCline.rooIgnoreController.validateAccess).toHaveBeenCalledWith(absoluteFilePath)
255261
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
256262
})
263+
264+
it("opens the absolute diff path when the resolver lands outside task.cwd", async () => {
265+
mockedResolvePathInWorkspace.mockResolvedValue(outsideRootAbsolutePath)
266+
267+
await executeWriteFileTool({}, { accessAllowed: true })
268+
269+
expect(mockCline.rooIgnoreController.validateAccess).toHaveBeenCalledWith(outsideRootAbsolutePath)
270+
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(outsideRootAbsolutePath)
271+
})
257272
})
258273

259274
describe("file existence detection", () => {

0 commit comments

Comments
 (0)