Download packages/core/src/tools/modifiable-tool.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/modifiable-tool.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/tools/modifiable-tool.test.ts
-
curl -L -o modifiable-tool.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/modifiable-tool.test.ts
12.5 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; | |
| import { | |
| modifyWithEditor, | |
| isModifiableDeclarativeTool, | |
| type ModifyContext, | |
| type ModifiableDeclarativeTool, | |
| } from './modifiable-tool.js'; | |
| import { DEFAULT_GUI_EDITOR } from '../utils/editor.js'; | |
| import fs from 'node:fs'; | |
| import fsp from 'node:fs/promises'; | |
| import os from 'node:os'; | |
| import * as path from 'node:path'; | |
| import { debugLogger } from '../utils/debugLogger.js'; | |
| // Mock dependencies | |
| const mockOpenDiff = vi.hoisted(() => vi.fn()); | |
| const mockCreatePatch = vi.hoisted(() => vi.fn()); | |
| vi.mock('../utils/editor.js', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('../utils/editor.js')>(); | |
| return { | |
| ...actual, | |
| openDiff: mockOpenDiff, | |
| }; | |
| }); | |
| vi.mock('diff', () => ({ | |
| createPatch: mockCreatePatch, | |
| })); | |
| interface TestParams { | |
| filePath: string; | |
| someOtherParam: string; | |
| modifiedContent?: string; | |
| } | |
| describe('modifyWithEditor', () => { | |
| let testProjectDir: string; | |
| let mockModifyContext: ModifyContext<TestParams>; | |
| let mockParams: TestParams; | |
| let currentContent: string; | |
| let proposedContent: string; | |
| let modifiedContent: string; | |
| let abortSignal: AbortSignal; | |
| beforeEach(async () => { | |
| vi.resetAllMocks(); | |
| testProjectDir = await fsp.mkdtemp( | |
| path.join(os.tmpdir(), 'modifiable-tool-test-'), | |
| ); | |
| 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(testProjectDir, '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, | |
| })), | |
| }; | |
| mockOpenDiff.mockImplementation(async (_oldPath, newPath) => { | |
| await fsp.writeFile(newPath, modifiedContent, 'utf8'); | |
| }); | |
| mockCreatePatch.mockReturnValue('mock diff content'); | |
| }); | |
| afterEach(async () => { | |
| vi.restoreAllMocks(); | |
| await fsp.rm(testProjectDir, { recursive: true, force: true }); | |
| }); | |
| describe('successful modification', () => { | |
| const assertMode = (mode: number, expected: number): void => { | |
| if (process.platform === 'win32') { | |
| // Windows reports POSIX modes as 0o666 regardless of requested bits. | |
| // At minimum confirm the owner read/write bits are present. | |
| expect(mode & 0o600).toBe(0o600); | |
| return; | |
| } | |
| expect(mode & 0o777).toBe(expected); | |
| }; | |
| it('should successfully modify content with VSCode editor', async () => { | |
| const result = await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith( | |
| mockParams, | |
| ); | |
| expect(mockModifyContext.getProposedContent).toHaveBeenCalledWith( | |
| mockParams, | |
| ); | |
| expect(mockModifyContext.getFilePath).toHaveBeenCalledWith(mockParams); | |
| expect(mockOpenDiff).toHaveBeenCalledOnce(); | |
| const [oldFilePath, newFilePath] = mockOpenDiff.mock.calls[0]; | |
| expect(mockModifyContext.createUpdatedParams).toHaveBeenCalledWith( | |
| currentContent, | |
| modifiedContent, | |
| mockParams, | |
| ); | |
| expect(mockCreatePatch).toHaveBeenCalledWith( | |
| path.basename(mockParams.filePath), | |
| currentContent, | |
| modifiedContent, | |
| 'Current', | |
| 'Proposed', | |
| expect.objectContaining({ | |
| context: 3, | |
| ignoreWhitespace: false, | |
| }), | |
| ); | |
| // Check that temp files are deleted. | |
| await expect(fsp.access(oldFilePath)).rejects.toThrow(); | |
| await expect(fsp.access(newFilePath)).rejects.toThrow(); | |
| expect(result).toEqual({ | |
| updatedParams: { | |
| ...mockParams, | |
| modifiedContent, | |
| oldContent: currentContent, | |
| }, | |
| updatedDiff: 'mock diff content', | |
| }); | |
| }); | |
| it('should create temp directory and files with restrictive permissions', async () => { | |
| mockOpenDiff.mockImplementation(async (oldPath, newPath) => { | |
| const diffDir = path.dirname(oldPath); | |
| expect(diffDir).toBe(path.dirname(newPath)); | |
| const dirStats = await fsp.stat(diffDir); | |
| const oldStats = await fsp.stat(oldPath); | |
| const newStats = await fsp.stat(newPath); | |
| assertMode(dirStats.mode, 0o700); | |
| assertMode(oldStats.mode, 0o600); | |
| assertMode(newStats.mode, 0o600); | |
| await fsp.writeFile(newPath, modifiedContent, 'utf8'); | |
| }); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| const [oldFilePath] = mockOpenDiff.mock.calls[0]; | |
| const diffDir = path.dirname(oldFilePath); | |
| // Temp directory should be cleaned up after modification | |
| await expect(fsp.stat(diffDir)).rejects.toThrow(); | |
| }); | |
| }); | |
| it('should handle missing old temp file gracefully', async () => { | |
| mockOpenDiff.mockImplementation(async (oldPath, newPath) => { | |
| await fsp.writeFile(newPath, modifiedContent, 'utf8'); | |
| await fsp.unlink(oldPath); | |
| }); | |
| const result = await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(mockCreatePatch).toHaveBeenCalledWith( | |
| path.basename(mockParams.filePath), | |
| '', | |
| modifiedContent, | |
| 'Current', | |
| 'Proposed', | |
| expect.objectContaining({ | |
| context: 3, | |
| ignoreWhitespace: false, | |
| }), | |
| ); | |
| expect(result.updatedParams).toBeDefined(); | |
| expect(result.updatedDiff).toBe('mock diff content'); | |
| }); | |
| it('should handle missing new temp file gracefully', async () => { | |
| mockOpenDiff.mockImplementation(async (_oldPath, newPath) => { | |
| await fsp.unlink(newPath); | |
| }); | |
| const result = await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(mockCreatePatch).toHaveBeenCalledWith( | |
| path.basename(mockParams.filePath), | |
| currentContent, | |
| '', | |
| 'Current', | |
| 'Proposed', | |
| expect.objectContaining({ | |
| context: 3, | |
| ignoreWhitespace: false, | |
| }), | |
| ); | |
| expect(result.updatedParams).toBeDefined(); | |
| expect(result.updatedDiff).toBe('mock diff content'); | |
| }); | |
| it('should honor override content values when provided', async () => { | |
| const overrideCurrent = 'override current content'; | |
| const overrideProposed = 'override proposed content'; | |
| mockModifyContext.getCurrentContent = vi.fn(); | |
| mockModifyContext.getProposedContent = vi.fn(); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| { | |
| currentContent: overrideCurrent, | |
| proposedContent: overrideProposed, | |
| }, | |
| ); | |
| expect(mockModifyContext.getCurrentContent).not.toHaveBeenCalled(); | |
| expect(mockModifyContext.getProposedContent).not.toHaveBeenCalled(); | |
| expect(mockCreatePatch).toHaveBeenCalledWith( | |
| path.basename(mockParams.filePath), | |
| overrideCurrent, | |
| modifiedContent, | |
| 'Current', | |
| 'Proposed', | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('should treat null override as explicit empty content', async () => { | |
| mockModifyContext.getCurrentContent = vi.fn(); | |
| mockModifyContext.getProposedContent = vi.fn(); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| { | |
| currentContent: null, | |
| proposedContent: 'override proposed content', | |
| }, | |
| ); | |
| expect(mockModifyContext.getCurrentContent).not.toHaveBeenCalled(); | |
| expect(mockModifyContext.getProposedContent).not.toHaveBeenCalled(); | |
| expect(mockCreatePatch).toHaveBeenCalledWith( | |
| path.basename(mockParams.filePath), | |
| '', | |
| modifiedContent, | |
| 'Current', | |
| 'Proposed', | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('should clean up temp files even if editor fails', async () => { | |
| const editorError = new Error('Editor failed to open'); | |
| mockOpenDiff.mockRejectedValue(editorError); | |
| const writeSpy = vi.spyOn(fs, 'writeFileSync'); | |
| await expect( | |
| modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ), | |
| ).rejects.toThrow('Editor failed to open'); | |
| expect(writeSpy).toHaveBeenCalledTimes(2); | |
| const oldFilePath = writeSpy.mock.calls[0][0] as string; | |
| const newFilePath = writeSpy.mock.calls[1][0] as string; | |
| await expect(fsp.access(oldFilePath)).rejects.toThrow(); | |
| await expect(fsp.access(newFilePath)).rejects.toThrow(); | |
| writeSpy.mockRestore(); | |
| }); | |
| it('should handle temp file cleanup errors gracefully', async () => { | |
| const consoleErrorSpy = vi | |
| .spyOn(debugLogger, 'error') | |
| .mockImplementation(() => {}); | |
| vi.spyOn(fs, 'unlinkSync').mockImplementation(() => { | |
| throw new Error('Failed to delete file'); | |
| }); | |
| vi.spyOn(fs, 'rmdirSync').mockImplementation(() => { | |
| throw new Error('Failed to delete directory'); | |
| }); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(consoleErrorSpy).toHaveBeenCalledTimes(3); | |
| expect(consoleErrorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining('Error deleting temp diff file:'), | |
| ); | |
| expect(consoleErrorSpy).toHaveBeenCalledWith( | |
| expect.stringContaining('Error deleting temp diff directory:'), | |
| ); | |
| consoleErrorSpy.mockRestore(); | |
| }); | |
| it('should create temp files with correct naming with extension', async () => { | |
| const testFilePath = path.join( | |
| testProjectDir, | |
| 'subfolder', | |
| 'test-file.txt', | |
| ); | |
| mockModifyContext.getFilePath = vi.fn().mockReturnValue(testFilePath); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(mockOpenDiff).toHaveBeenCalledOnce(); | |
| const [oldFilePath, newFilePath] = mockOpenDiff.mock.calls[0]; | |
| expect(oldFilePath).toMatch(/gemini-cli-modify-test-file-old-\d+\.txt$/); | |
| expect(newFilePath).toMatch(/gemini-cli-modify-test-file-new-\d+\.txt$/); | |
| const diffDirPrefix = path.join(os.tmpdir(), 'gemini-cli-tool-modify-'); | |
| expect(path.dirname(oldFilePath).startsWith(diffDirPrefix)).toBe(true); | |
| expect(path.dirname(newFilePath).startsWith(diffDirPrefix)).toBe(true); | |
| }); | |
| it('should create temp files with correct naming without extension', async () => { | |
| const testFilePath = path.join(testProjectDir, 'subfolder', 'test-file'); | |
| mockModifyContext.getFilePath = vi.fn().mockReturnValue(testFilePath); | |
| await modifyWithEditor( | |
| mockParams, | |
| mockModifyContext, | |
| DEFAULT_GUI_EDITOR, | |
| abortSignal, | |
| ); | |
| expect(mockOpenDiff).toHaveBeenCalledOnce(); | |
| const [oldFilePath, newFilePath] = mockOpenDiff.mock.calls[0]; | |
| expect(oldFilePath).toMatch(/gemini-cli-modify-test-file-old-\d+$/); | |
| expect(newFilePath).toMatch(/gemini-cli-modify-test-file-new-\d+$/); | |
| const diffDirPrefix = path.join(os.tmpdir(), 'gemini-cli-tool-modify-'); | |
| expect(path.dirname(oldFilePath).startsWith(diffDirPrefix)).toBe(true); | |
| expect(path.dirname(newFilePath).startsWith(diffDirPrefix)).toBe(true); | |
| }); | |
| }); | |
| describe('isModifiableTool', () => { | |
| it('should return true for objects with getModifyContext method', () => { | |
| const mockTool = { | |
| name: 'test-tool', | |
| getModifyContext: vi.fn(), | |
| } as unknown as ModifiableDeclarativeTool<TestParams>; | |
| expect(isModifiableDeclarativeTool(mockTool)).toBe(true); | |
| }); | |
| it('should return false for objects without getModifyContext method', () => { | |
| const mockTool = { | |
| name: 'test-tool', | |
| } as unknown as ModifiableDeclarativeTool<TestParams>; | |
| expect(isModifiableDeclarativeTool(mockTool)).toBe(false); | |
| }); | |
| }); | |