/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach, vi, type Mocked, } from 'vitest'; import { detectLineEnding } from '../utils/textUtils.js'; import { WriteFileTool } from './write-file.js'; import { EditTool } from './edit.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../policy/types.js'; import { ToolConfirmationOutcome } from './tools.js'; import type { ToolRegistry } from './tool-registry.js'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import { GeminiClient } from '../core/client.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; import { ensureCorrectFileContent } from '../utils/editCorrector.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { createMockMessageBus, getMockMessageBusInstance, } from '../test-utils/mock-message-bus.js'; const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-line-ending-test-root'); // --- MOCKS --- vi.mock('../core/client.js'); vi.mock('../utils/editCorrector.js'); vi.mock('../ide/ide-client.js', () => ({ IdeClient: { getInstance: vi.fn().mockResolvedValue({ openDiff: vi.fn(), isDiffingEnabled: vi.fn().mockReturnValue(false), }), }, })); let mockGeminiClientInstance: Mocked; let mockBaseLlmClientInstance: Mocked; const mockEnsureCorrectFileContent = vi.fn(); // Mock Config const fsService = new StandardFileSystemService(); const mockConfigInternal = { getTargetDir: () => rootDir, getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), getGeminiClient: vi.fn(), getBaseLlmClient: vi.fn(), getFileSystemService: () => fsService, getIdeMode: vi.fn(() => false), getWorkspaceContext: () => new WorkspaceContext(rootDir), getApiKey: () => 'test-key', getModel: () => 'test-model', getSandbox: () => false, getDebugMode: () => false, getQuestion: () => undefined, getToolDiscoveryCommand: () => undefined, getToolCallCommand: () => undefined, getMcpServerCommand: () => undefined, getMcpServers: () => undefined, getUserAgent: () => 'test-agent', getUserMemory: () => '', setUserMemory: vi.fn(), getGeminiMdFileCount: () => 0, setGeminiMdFileCount: vi.fn(), getDisableLLMCorrection: vi.fn(() => false), getActiveModel: () => 'test-model', validatePathAccess: vi.fn().mockReturnValue(null), getToolRegistry: () => ({ registerTool: vi.fn(), discoverTools: vi.fn(), }) as unknown as ToolRegistry, isInteractive: () => false, isPlanMode: () => false, storage: { getPlansDir: () => '/tmp/plans', }, }; const mockConfig = mockConfigInternal as unknown as Config; vi.mock('../telemetry/loggers.js', () => ({ logFileOperation: vi.fn(), logEditStrategy: vi.fn(), logEditCorrectionEvent: vi.fn(), })); // --- END MOCKS --- describe('Line Ending Preservation', () => { let tempDir: string; beforeEach(() => { vi.clearAllMocks(); tempDir = fs.mkdtempSync( path.join(os.tmpdir(), 'line-ending-test-external-'), ); if (!fs.existsSync(rootDir)) { fs.mkdirSync(rootDir, { recursive: true }); } mockGeminiClientInstance = new (vi.mocked(GeminiClient))( mockConfig, ) as Mocked; vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance); mockBaseLlmClientInstance = { generateJson: vi.fn(), } as unknown as Mocked; vi.mocked(ensureCorrectFileContent).mockImplementation( mockEnsureCorrectFileContent, ); mockConfigInternal.getGeminiClient.mockReturnValue( mockGeminiClientInstance, ); mockConfigInternal.getBaseLlmClient.mockReturnValue( mockBaseLlmClientInstance, ); }); afterEach(() => { if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } if (fs.existsSync(rootDir)) { fs.rmSync(rootDir, { recursive: true, force: true }); } vi.restoreAllMocks(); }); describe('detectLineEnding', () => { it('should detect CRLF', () => { expect(detectLineEnding('line1\r\nline2')).toBe('\r\n'); expect(detectLineEnding('line1\r\n')).toBe('\r\n'); }); it('should detect LF', () => { expect(detectLineEnding('line1\nline2')).toBe('\n'); expect(detectLineEnding('line1\n')).toBe('\n'); expect(detectLineEnding('line1')).toBe('\n'); // Default to LF if no newline }); }); describe('WriteFileTool', () => { let tool: WriteFileTool; const abortSignal = new AbortController().signal; beforeEach(() => { const bus = createMockMessageBus(); getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; tool = new WriteFileTool(mockConfig, bus); }); it('should preserve CRLF when overwriting an existing file', async () => { const filePath = path.join(rootDir, 'crlf_file.txt'); const originalContent = 'line1\r\nline2\r\n'; fs.writeFileSync(filePath, originalContent); // Write with CRLF (or however Node writes binary buffer) // Ensure strictly CRLF fs.writeFileSync(filePath, Buffer.from('line1\r\nline2\r\n')); // Proposed content from LLM (usually LF) const proposedContent = 'line1\nline2\nline3\n'; // Mock corrections to return proposed content as-is (but usually normalized) mockEnsureCorrectFileContent.mockResolvedValue(proposedContent); const params = { file_path: filePath, content: proposedContent }; const invocation = tool.build(params); // Force approval const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); if ( confirmDetails && typeof confirmDetails === 'object' && 'onConfirm' in confirmDetails ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } await invocation.execute(abortSignal); const writtenContent = fs.readFileSync(filePath, 'utf8'); // Expect all newlines to be CRLF expect(writtenContent).toBe('line1\r\nline2\r\nline3\r\n'); }); it('should use OS EOL for new files', async () => { const filePath = path.join(rootDir, 'new_os_eol_file.txt'); const proposedContent = 'line1\nline2\n'; mockEnsureCorrectFileContent.mockResolvedValue(proposedContent); const params = { file_path: filePath, content: proposedContent }; const invocation = tool.build(params); const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); if ( confirmDetails && typeof confirmDetails === 'object' && 'onConfirm' in confirmDetails ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } await invocation.execute(abortSignal); const writtenContent = fs.readFileSync(filePath, 'utf8'); if (os.EOL === '\r\n') { expect(writtenContent).toBe('line1\r\nline2\r\n'); } else { expect(writtenContent).toBe('line1\nline2\n'); } }); }); describe('EditTool', () => { let tool: EditTool; const abortSignal = new AbortController().signal; beforeEach(() => { const bus = createMockMessageBus(); getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; tool = new EditTool(mockConfig, bus); }); it('should preserve CRLF when editing a file', async () => { const filePath = path.join(rootDir, 'edit_crlf.txt'); const originalContent = 'line1\r\nline2\r\nline3\r\n'; fs.writeFileSync(filePath, Buffer.from(originalContent)); const oldString = 'line2'; const newString = 'modified'; const params = { file_path: filePath, old_string: oldString, new_string: newString, instruction: 'Change line2 to modified', }; const invocation = tool.build(params); // Force approval const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); if ( confirmDetails && typeof confirmDetails === 'object' && 'onConfirm' in confirmDetails ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } await invocation.execute(abortSignal); const writtenContent = fs.readFileSync(filePath, 'utf8'); expect(writtenContent).toBe('line1\r\nmodified\r\nline3\r\n'); }); }); });