|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { |
|
|
vi, |
|
|
describe, |
|
|
it, |
|
|
expect, |
|
|
beforeEach, |
|
|
afterEach, |
|
|
type Mock, |
|
|
} from 'vitest'; |
|
|
import { |
|
|
modifyWithEditor, |
|
|
ModifyContext, |
|
|
ModifiableTool, |
|
|
isModifiableTool, |
|
|
} from './modifiable-tool.js'; |
|
|
import { EditorType } from '../utils/editor.js'; |
|
|
import fs from 'fs'; |
|
|
import os from 'os'; |
|
|
import * as path from 'path'; |
|
|
|
|
|
|
|
|
const mockOpenDiff = vi.hoisted(() => vi.fn()); |
|
|
const mockCreatePatch = vi.hoisted(() => vi.fn()); |
|
|
|
|
|
vi.mock('../utils/editor.js', () => ({ |
|
|
openDiff: mockOpenDiff, |
|
|
})); |
|
|
|
|
|
vi.mock('diff', () => ({ |
|
|
createPatch: mockCreatePatch, |
|
|
})); |
|
|
|
|
|
vi.mock('fs'); |
|
|
vi.mock('os'); |
|
|
|
|
|
interface TestParams { |
|
|
filePath: string; |
|
|
someOtherParam: string; |
|
|
modifiedContent?: string; |
|
|
} |
|
|
|
|
|
describe('modifyWithEditor', () => { |
|
|
let tempDir: string; |
|
|
let mockModifyContext: ModifyContext<TestParams>; |
|
|
let mockParams: TestParams; |
|
|
let currentContent: string; |
|
|
let proposedContent: string; |
|
|
let modifiedContent: string; |
|
|
let abortSignal: AbortSignal; |
|
|
|
|
|
beforeEach(() => { |
|
|
vi.resetAllMocks(); |
|
|
|
|
|
tempDir = '/tmp/test-dir'; |
|
|
abortSignal = new AbortController().signal; |
|
|
|
|
|
currentContent = 'original content\nline 2\nline 3'; |
|
|
proposedContent = 'modified content\nline 2\nline 3'; |
|
|
modifiedContent = 'user modified content\nline 2\nline 3\nnew line'; |
|
|
mockParams = { |
|
|
filePath: path.join(tempDir, 'test.txt'), |
|
|
someOtherParam: 'value', |
|
|
}; |
|
|
|
|
|
mockModifyContext = { |
|
|
getFilePath: vi.fn().mockReturnValue(mockParams.filePath), |
|
|
getCurrentContent: vi.fn().mockResolvedValue(currentContent), |
|
|
getProposedContent: vi.fn().mockResolvedValue(proposedContent), |
|
|
createUpdatedParams: vi |
|
|
.fn() |
|
|
.mockImplementation((oldContent, modifiedContent, originalParams) => ({ |
|
|
...originalParams, |
|
|
modifiedContent, |
|
|
oldContent, |
|
|
})), |
|
|
}; |
|
|
|
|
|
(os.tmpdir as Mock).mockReturnValue(tempDir); |
|
|
|
|
|
(fs.existsSync as Mock).mockReturnValue(true); |
|
|
(fs.mkdirSync as Mock).mockImplementation(() => undefined); |
|
|
(fs.writeFileSync as Mock).mockImplementation(() => {}); |
|
|
(fs.unlinkSync as Mock).mockImplementation(() => {}); |
|
|
|
|
|
(fs.readFileSync as Mock).mockImplementation((filePath: string) => { |
|
|
if (filePath.includes('-new-')) { |
|
|
return modifiedContent; |
|
|
} |
|
|
return currentContent; |
|
|
}); |
|
|
|
|
|
mockCreatePatch.mockReturnValue('mock diff content'); |
|
|
mockOpenDiff.mockResolvedValue(undefined); |
|
|
}); |
|
|
|
|
|
afterEach(() => { |
|
|
vi.restoreAllMocks(); |
|
|
}); |
|
|
|
|
|
describe('successful modification', () => { |
|
|
it('should successfully modify content with VSCode editor', async () => { |
|
|
const result = await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith( |
|
|
mockParams, |
|
|
); |
|
|
expect(mockModifyContext.getProposedContent).toHaveBeenCalledWith( |
|
|
mockParams, |
|
|
); |
|
|
expect(mockModifyContext.getFilePath).toHaveBeenCalledWith(mockParams); |
|
|
|
|
|
expect(fs.writeFileSync).toHaveBeenCalledTimes(2); |
|
|
expect(fs.writeFileSync).toHaveBeenNthCalledWith( |
|
|
1, |
|
|
expect.stringContaining( |
|
|
path.join(tempDir, 'gemini-cli-tool-modify-diffs'), |
|
|
), |
|
|
currentContent, |
|
|
'utf8', |
|
|
); |
|
|
expect(fs.writeFileSync).toHaveBeenNthCalledWith( |
|
|
2, |
|
|
expect.stringContaining( |
|
|
path.join(tempDir, 'gemini-cli-tool-modify-diffs'), |
|
|
), |
|
|
proposedContent, |
|
|
'utf8', |
|
|
); |
|
|
|
|
|
expect(mockOpenDiff).toHaveBeenCalledWith( |
|
|
expect.stringContaining('-old-'), |
|
|
expect.stringContaining('-new-'), |
|
|
'vscode', |
|
|
); |
|
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith( |
|
|
expect.stringContaining('-old-'), |
|
|
'utf8', |
|
|
); |
|
|
expect(fs.readFileSync).toHaveBeenCalledWith( |
|
|
expect.stringContaining('-new-'), |
|
|
'utf8', |
|
|
); |
|
|
|
|
|
expect(mockModifyContext.createUpdatedParams).toHaveBeenCalledWith( |
|
|
currentContent, |
|
|
modifiedContent, |
|
|
mockParams, |
|
|
); |
|
|
|
|
|
expect(mockCreatePatch).toHaveBeenCalledWith( |
|
|
path.basename(mockParams.filePath), |
|
|
currentContent, |
|
|
modifiedContent, |
|
|
'Current', |
|
|
'Proposed', |
|
|
expect.objectContaining({ |
|
|
context: 3, |
|
|
ignoreWhitespace: true, |
|
|
}), |
|
|
); |
|
|
|
|
|
expect(fs.unlinkSync).toHaveBeenCalledTimes(2); |
|
|
expect(fs.unlinkSync).toHaveBeenNthCalledWith( |
|
|
1, |
|
|
expect.stringContaining('-old-'), |
|
|
); |
|
|
expect(fs.unlinkSync).toHaveBeenNthCalledWith( |
|
|
2, |
|
|
expect.stringContaining('-new-'), |
|
|
); |
|
|
|
|
|
expect(result).toEqual({ |
|
|
updatedParams: { |
|
|
...mockParams, |
|
|
modifiedContent, |
|
|
oldContent: currentContent, |
|
|
}, |
|
|
updatedDiff: 'mock diff content', |
|
|
}); |
|
|
}); |
|
|
|
|
|
it('should create temp directory if it does not exist', async () => { |
|
|
(fs.existsSync as Mock).mockReturnValue(false); |
|
|
|
|
|
await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(fs.mkdirSync).toHaveBeenCalledWith( |
|
|
path.join(tempDir, 'gemini-cli-tool-modify-diffs'), |
|
|
{ recursive: true }, |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should not create temp directory if it already exists', async () => { |
|
|
(fs.existsSync as Mock).mockReturnValue(true); |
|
|
|
|
|
await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(fs.mkdirSync).not.toHaveBeenCalled(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
it('should handle missing old temp file gracefully', async () => { |
|
|
(fs.readFileSync as Mock).mockImplementation((filePath: string) => { |
|
|
if (filePath.includes('-old-')) { |
|
|
const error = new Error('ENOENT: no such file or directory'); |
|
|
(error as NodeJS.ErrnoException).code = 'ENOENT'; |
|
|
throw error; |
|
|
} |
|
|
return modifiedContent; |
|
|
}); |
|
|
|
|
|
const result = await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(mockCreatePatch).toHaveBeenCalledWith( |
|
|
path.basename(mockParams.filePath), |
|
|
'', |
|
|
modifiedContent, |
|
|
'Current', |
|
|
'Proposed', |
|
|
expect.objectContaining({ |
|
|
context: 3, |
|
|
ignoreWhitespace: true, |
|
|
}), |
|
|
); |
|
|
|
|
|
expect(result.updatedParams).toBeDefined(); |
|
|
expect(result.updatedDiff).toBe('mock diff content'); |
|
|
}); |
|
|
|
|
|
it('should handle missing new temp file gracefully', async () => { |
|
|
(fs.readFileSync as Mock).mockImplementation((filePath: string) => { |
|
|
if (filePath.includes('-new-')) { |
|
|
const error = new Error('ENOENT: no such file or directory'); |
|
|
(error as NodeJS.ErrnoException).code = 'ENOENT'; |
|
|
throw error; |
|
|
} |
|
|
return currentContent; |
|
|
}); |
|
|
|
|
|
const result = await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(mockCreatePatch).toHaveBeenCalledWith( |
|
|
path.basename(mockParams.filePath), |
|
|
currentContent, |
|
|
'', |
|
|
'Current', |
|
|
'Proposed', |
|
|
expect.objectContaining({ |
|
|
context: 3, |
|
|
ignoreWhitespace: true, |
|
|
}), |
|
|
); |
|
|
|
|
|
expect(result.updatedParams).toBeDefined(); |
|
|
expect(result.updatedDiff).toBe('mock diff content'); |
|
|
}); |
|
|
|
|
|
it('should clean up temp files even if editor fails', async () => { |
|
|
const editorError = new Error('Editor failed to open'); |
|
|
mockOpenDiff.mockRejectedValue(editorError); |
|
|
|
|
|
await expect( |
|
|
modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
), |
|
|
).rejects.toThrow('Editor failed to open'); |
|
|
|
|
|
expect(fs.unlinkSync).toHaveBeenCalledTimes(2); |
|
|
}); |
|
|
|
|
|
it('should handle temp file cleanup errors gracefully', async () => { |
|
|
const consoleErrorSpy = vi |
|
|
.spyOn(console, 'error') |
|
|
.mockImplementation(() => {}); |
|
|
(fs.unlinkSync as Mock).mockImplementation((_filePath: string) => { |
|
|
throw new Error('Failed to delete file'); |
|
|
}); |
|
|
|
|
|
await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(2); |
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith( |
|
|
expect.stringContaining('Error deleting temp diff file:'), |
|
|
); |
|
|
|
|
|
consoleErrorSpy.mockRestore(); |
|
|
}); |
|
|
|
|
|
it('should create temp files with correct naming', async () => { |
|
|
const testFilePath = path.join(tempDir, 'subfolder', 'test-file.txt'); |
|
|
mockModifyContext.getFilePath = vi.fn().mockReturnValue(testFilePath); |
|
|
|
|
|
await modifyWithEditor( |
|
|
mockParams, |
|
|
mockModifyContext, |
|
|
'vscode' as EditorType, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
const writeFileCalls = (fs.writeFileSync as Mock).mock.calls; |
|
|
expect(writeFileCalls).toHaveLength(2); |
|
|
|
|
|
const oldFilePath = writeFileCalls[0][0]; |
|
|
const newFilePath = writeFileCalls[1][0]; |
|
|
|
|
|
expect(oldFilePath).toMatch(/gemini-cli-modify-test-file\.txt-old-\d+$/); |
|
|
expect(newFilePath).toMatch(/gemini-cli-modify-test-file\.txt-new-\d+$/); |
|
|
expect(oldFilePath).toContain(`${tempDir}/gemini-cli-tool-modify-diffs/`); |
|
|
expect(newFilePath).toContain(`${tempDir}/gemini-cli-tool-modify-diffs/`); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('isModifiableTool', () => { |
|
|
it('should return true for objects with getModifyContext method', () => { |
|
|
const mockTool = { |
|
|
name: 'test-tool', |
|
|
getModifyContext: vi.fn(), |
|
|
} as unknown as ModifiableTool<TestParams>; |
|
|
|
|
|
expect(isModifiableTool(mockTool)).toBe(true); |
|
|
}); |
|
|
|
|
|
it('should return false for objects without getModifyContext method', () => { |
|
|
const mockTool = { |
|
|
name: 'test-tool', |
|
|
} as unknown as ModifiableTool<TestParams>; |
|
|
|
|
|
expect(isModifiableTool(mockTool)).toBe(false); |
|
|
}); |
|
|
}); |
|
|
|