Skip to content

Commit ccf8938

Browse files
committed
Add unit tests for writeToFileTool
1 parent 8e384a0 commit ccf8938

File tree

1 file changed

+380
-0
lines changed

1 file changed

+380
-0
lines changed
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import * as path from "path"
2+
3+
import { fileExistsAtPath } from "../../../utils/fs"
4+
import { detectCodeOmission } from "../../../integrations/editor/detect-omission"
5+
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
6+
import { getReadablePath } from "../../../utils/path"
7+
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
8+
import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
9+
import { ToolUse, ToolResponse } from "../../../shared/tools"
10+
import { writeToFileTool } from "../writeToFileTool"
11+
12+
jest.mock("path", () => {
13+
const originalPath = jest.requireActual("path")
14+
return {
15+
...originalPath,
16+
resolve: jest.fn().mockImplementation((...args) => args.join("/")),
17+
}
18+
})
19+
20+
jest.mock("delay", () => jest.fn())
21+
22+
jest.mock("../../../utils/fs", () => ({
23+
fileExistsAtPath: jest.fn().mockResolvedValue(false),
24+
}))
25+
26+
jest.mock("../../prompts/responses", () => ({
27+
formatResponse: {
28+
toolError: jest.fn((msg) => `Error: ${msg}`),
29+
rooIgnoreError: jest.fn((path) => `Access denied: ${path}`),
30+
lineCountTruncationError: jest.fn(
31+
(count, isNew, diffEnabled) => `Line count error: ${count}, new: ${isNew}, diff: ${diffEnabled}`,
32+
),
33+
createPrettyPatch: jest.fn(() => "mock-diff"),
34+
},
35+
}))
36+
37+
jest.mock("../../../integrations/editor/detect-omission", () => ({
38+
detectCodeOmission: jest.fn().mockReturnValue(false),
39+
}))
40+
41+
jest.mock("../../../utils/pathUtils", () => ({
42+
isPathOutsideWorkspace: jest.fn().mockReturnValue(false),
43+
}))
44+
45+
jest.mock("../../../utils/path", () => ({
46+
getReadablePath: jest.fn().mockReturnValue("test/path.txt"),
47+
}))
48+
49+
jest.mock("../../../utils/text-normalization", () => ({
50+
unescapeHtmlEntities: jest.fn().mockImplementation((content) => content),
51+
}))
52+
53+
jest.mock("../../../integrations/misc/extract-text", () => ({
54+
everyLineHasLineNumbers: jest.fn().mockReturnValue(false),
55+
stripLineNumbers: jest.fn().mockImplementation((content) => content),
56+
addLineNumbers: jest.fn().mockImplementation((content: string) =>
57+
content
58+
.split("\n")
59+
.map((line: string, i: number) => `${i + 1} | ${line}`)
60+
.join("\n"),
61+
),
62+
}))
63+
64+
jest.mock("vscode", () => ({
65+
window: {
66+
showWarningMessage: jest.fn().mockResolvedValue(undefined),
67+
},
68+
env: {
69+
openExternal: jest.fn(),
70+
},
71+
Uri: {
72+
parse: jest.fn(),
73+
},
74+
}))
75+
76+
jest.mock("../../ignore/RooIgnoreController", () => ({
77+
RooIgnoreController: class {
78+
initialize() {
79+
return Promise.resolve()
80+
}
81+
validateAccess() {
82+
return true
83+
}
84+
},
85+
}))
86+
87+
describe("writeToFileTool", () => {
88+
// Test data
89+
const testFilePath = "test/file.txt"
90+
const absoluteFilePath = "/test/file.txt"
91+
const testContent = "Line 1\nLine 2\nLine 3"
92+
const testContentWithMarkdown = "```javascript\nLine 1\nLine 2\n```"
93+
94+
// Mocked functions with correct types
95+
const mockedFileExistsAtPath = fileExistsAtPath as jest.MockedFunction<typeof fileExistsAtPath>
96+
const mockedDetectCodeOmission = detectCodeOmission as jest.MockedFunction<typeof detectCodeOmission>
97+
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as jest.MockedFunction<typeof isPathOutsideWorkspace>
98+
const mockedGetReadablePath = getReadablePath as jest.MockedFunction<typeof getReadablePath>
99+
const mockedUnescapeHtmlEntities = unescapeHtmlEntities as jest.MockedFunction<typeof unescapeHtmlEntities>
100+
const mockedEveryLineHasLineNumbers = everyLineHasLineNumbers as jest.MockedFunction<typeof everyLineHasLineNumbers>
101+
const mockedStripLineNumbers = stripLineNumbers as jest.MockedFunction<typeof stripLineNumbers>
102+
const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
103+
104+
const mockCline: any = {}
105+
let mockAskApproval: jest.Mock
106+
let mockHandleError: jest.Mock
107+
let mockPushToolResult: jest.Mock
108+
let mockRemoveClosingTag: jest.Mock
109+
let toolResult: ToolResponse | undefined
110+
111+
beforeEach(() => {
112+
jest.clearAllMocks()
113+
114+
mockedPathResolve.mockReturnValue(absoluteFilePath)
115+
mockedFileExistsAtPath.mockResolvedValue(false)
116+
mockedDetectCodeOmission.mockReturnValue(false)
117+
mockedIsPathOutsideWorkspace.mockReturnValue(false)
118+
mockedGetReadablePath.mockReturnValue("test/path.txt")
119+
mockedUnescapeHtmlEntities.mockImplementation((content) => content)
120+
mockedEveryLineHasLineNumbers.mockReturnValue(false)
121+
mockedStripLineNumbers.mockImplementation((content) => content)
122+
123+
mockCline.cwd = "/"
124+
mockCline.consecutiveMistakeCount = 0
125+
mockCline.didEditFile = false
126+
mockCline.diffStrategy = undefined
127+
mockCline.rooIgnoreController = {
128+
validateAccess: jest.fn().mockReturnValue(true),
129+
}
130+
mockCline.diffViewProvider = {
131+
editType: undefined,
132+
isEditing: false,
133+
originalContent: "",
134+
open: jest.fn().mockResolvedValue(undefined),
135+
update: jest.fn().mockResolvedValue(undefined),
136+
reset: jest.fn().mockResolvedValue(undefined),
137+
revertChanges: jest.fn().mockResolvedValue(undefined),
138+
saveChanges: jest.fn().mockResolvedValue({
139+
newProblemsMessage: "",
140+
userEdits: null,
141+
finalContent: "final content",
142+
}),
143+
scrollToFirstDiff: jest.fn(),
144+
}
145+
mockCline.api = {
146+
getModel: jest.fn().mockReturnValue({ id: "claude-3" }),
147+
}
148+
mockCline.fileContextTracker = {
149+
trackFileContext: jest.fn().mockResolvedValue(undefined),
150+
}
151+
mockCline.say = jest.fn().mockResolvedValue(undefined)
152+
mockCline.ask = jest.fn().mockResolvedValue(undefined)
153+
mockCline.recordToolError = jest.fn()
154+
mockCline.sayAndCreateMissingParamError = jest.fn().mockResolvedValue("Missing param error")
155+
156+
mockAskApproval = jest.fn().mockResolvedValue(true)
157+
mockHandleError = jest.fn().mockResolvedValue(undefined)
158+
mockRemoveClosingTag = jest.fn((tag, content) => content)
159+
160+
toolResult = undefined
161+
})
162+
163+
/**
164+
* Helper function to execute the write file tool with different parameters
165+
*/
166+
async function executeWriteFileTool(
167+
params: Partial<ToolUse["params"]> = {},
168+
options: {
169+
fileExists?: boolean
170+
isPartial?: boolean
171+
accessAllowed?: boolean
172+
} = {},
173+
): Promise<ToolResponse | undefined> {
174+
// Configure mocks based on test scenario
175+
const fileExists = options.fileExists ?? false
176+
const isPartial = options.isPartial ?? false
177+
const accessAllowed = options.accessAllowed ?? true
178+
179+
mockedFileExistsAtPath.mockResolvedValue(fileExists)
180+
mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed)
181+
182+
// Create a tool use object
183+
const toolUse: ToolUse = {
184+
type: "tool_use",
185+
name: "write_to_file",
186+
params: {
187+
path: testFilePath,
188+
content: testContent,
189+
line_count: "3",
190+
...params,
191+
},
192+
partial: isPartial,
193+
}
194+
195+
await writeToFileTool(
196+
mockCline,
197+
toolUse,
198+
mockAskApproval,
199+
mockHandleError,
200+
(result: ToolResponse) => {
201+
toolResult = result
202+
},
203+
mockRemoveClosingTag,
204+
)
205+
206+
return toolResult
207+
}
208+
209+
describe("access control", () => {
210+
it("validates and allows access when rooIgnoreController permits", async () => {
211+
await executeWriteFileTool({}, { accessAllowed: true })
212+
213+
expect(mockCline.rooIgnoreController.validateAccess).toHaveBeenCalledWith(testFilePath)
214+
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
215+
})
216+
})
217+
218+
describe("file existence detection", () => {
219+
it("detects existing file and sets editType to modify", async () => {
220+
await executeWriteFileTool({}, { fileExists: true })
221+
222+
expect(mockedFileExistsAtPath).toHaveBeenCalledWith(absoluteFilePath)
223+
expect(mockCline.diffViewProvider.editType).toBe("modify")
224+
})
225+
226+
it("detects new file and sets editType to create", async () => {
227+
await executeWriteFileTool({}, { fileExists: false })
228+
229+
expect(mockedFileExistsAtPath).toHaveBeenCalledWith(absoluteFilePath)
230+
expect(mockCline.diffViewProvider.editType).toBe("create")
231+
})
232+
233+
it("uses cached editType without filesystem check", async () => {
234+
mockCline.diffViewProvider.editType = "modify"
235+
236+
await executeWriteFileTool({})
237+
238+
expect(mockedFileExistsAtPath).not.toHaveBeenCalled()
239+
})
240+
})
241+
242+
describe("content preprocessing", () => {
243+
it("removes markdown code block markers from content", async () => {
244+
await executeWriteFileTool({ content: testContentWithMarkdown })
245+
246+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("Line 1\nLine 2", true)
247+
})
248+
249+
it("passes through empty content unchanged", async () => {
250+
await executeWriteFileTool({ content: "" })
251+
252+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("", true)
253+
})
254+
255+
it("unescapes HTML entities for non-Claude models", async () => {
256+
mockCline.api.getModel.mockReturnValue({ id: "gpt-4" })
257+
258+
await executeWriteFileTool({ content: "&lt;test&gt;" })
259+
260+
expect(mockedUnescapeHtmlEntities).toHaveBeenCalledWith("&lt;test&gt;")
261+
})
262+
263+
it("skips HTML unescaping for Claude models", async () => {
264+
mockCline.api.getModel.mockReturnValue({ id: "claude-3" })
265+
266+
await executeWriteFileTool({ content: "&lt;test&gt;" })
267+
268+
expect(mockedUnescapeHtmlEntities).not.toHaveBeenCalled()
269+
})
270+
271+
it("strips line numbers from numbered content", async () => {
272+
const contentWithLineNumbers = "1 | line one\n2 | line two"
273+
mockedEveryLineHasLineNumbers.mockReturnValue(true)
274+
mockedStripLineNumbers.mockReturnValue("line one\nline two")
275+
276+
await executeWriteFileTool({ content: contentWithLineNumbers })
277+
278+
expect(mockedEveryLineHasLineNumbers).toHaveBeenCalledWith(contentWithLineNumbers)
279+
expect(mockedStripLineNumbers).toHaveBeenCalledWith(contentWithLineNumbers)
280+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("line one\nline two", true)
281+
})
282+
})
283+
284+
describe("file operations", () => {
285+
it("successfully creates new files with full workflow", async () => {
286+
await executeWriteFileTool({}, { fileExists: false })
287+
288+
expect(mockCline.consecutiveMistakeCount).toBe(0)
289+
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
290+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, true)
291+
expect(mockAskApproval).toHaveBeenCalled()
292+
expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled()
293+
expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited")
294+
expect(mockCline.didEditFile).toBe(true)
295+
})
296+
297+
it("processes files outside workspace boundary", async () => {
298+
mockedIsPathOutsideWorkspace.mockReturnValue(true)
299+
300+
await executeWriteFileTool({})
301+
302+
expect(mockedIsPathOutsideWorkspace).toHaveBeenCalled()
303+
})
304+
305+
it("processes files with very large line counts", async () => {
306+
await executeWriteFileTool({ line_count: "999999" })
307+
308+
// Should process normally without issues
309+
expect(mockCline.consecutiveMistakeCount).toBe(0)
310+
})
311+
})
312+
313+
describe("partial block handling", () => {
314+
it("returns early when path is missing in partial block", async () => {
315+
await executeWriteFileTool({ path: undefined }, { isPartial: true })
316+
317+
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
318+
})
319+
320+
it("returns early when content is undefined in partial block", async () => {
321+
await executeWriteFileTool({ content: undefined }, { isPartial: true })
322+
323+
expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled()
324+
})
325+
326+
it("streams content updates during partial execution", async () => {
327+
await executeWriteFileTool({}, { isPartial: true })
328+
329+
expect(mockCline.ask).toHaveBeenCalled()
330+
expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath)
331+
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false)
332+
})
333+
})
334+
335+
describe("user interaction", () => {
336+
it("reverts changes when user rejects approval", async () => {
337+
mockAskApproval.mockResolvedValue(false)
338+
339+
await executeWriteFileTool({})
340+
341+
expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalled()
342+
expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled()
343+
})
344+
345+
it("reports user edits with diff feedback", async () => {
346+
mockCline.diffViewProvider.saveChanges.mockResolvedValue({
347+
newProblemsMessage: " with warnings",
348+
userEdits: "- old line\n+ new line",
349+
finalContent: "modified content",
350+
})
351+
352+
await executeWriteFileTool({}, { fileExists: true })
353+
354+
expect(mockCline.say).toHaveBeenCalledWith(
355+
"user_feedback_diff",
356+
expect.stringContaining("editedExistingFile"),
357+
)
358+
})
359+
})
360+
361+
describe("error handling", () => {
362+
it("handles general file operation errors", async () => {
363+
mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error"))
364+
365+
await executeWriteFileTool({})
366+
367+
expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error))
368+
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
369+
})
370+
371+
it("handles partial streaming errors", async () => {
372+
mockCline.diffViewProvider.open.mockRejectedValue(new Error("Open failed"))
373+
374+
await executeWriteFileTool({}, { isPartial: true })
375+
376+
expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error))
377+
expect(mockCline.diffViewProvider.reset).toHaveBeenCalled()
378+
})
379+
})
380+
})

0 commit comments

Comments
 (0)