summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/modifiable-tool.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/tools/modifiable-tool.test.ts')
-rw-r--r--packages/core/src/tools/modifiable-tool.test.ts367
1 files changed, 367 insertions, 0 deletions
diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts
new file mode 100644
index 00000000..56c27fe0
--- /dev/null
+++ b/packages/core/src/tools/modifiable-tool.test.ts
@@ -0,0 +1,367 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+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';
+
+// Mock dependencies
+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((modifiedContent, originalParams) => ({
+ ...originalParams,
+ modifiedContent,
+ })),
+ };
+
+ (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(
+ 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,
+ },
+ 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);
+ });
+});