mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-06 03:10:42 -07:00
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
276 lines
8.4 KiB
TypeScript
276 lines
8.4 KiB
TypeScript
/**
|
|
* @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<GeminiClient>;
|
|
let mockBaseLlmClientInstance: Mocked<BaseLlmClient>;
|
|
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
|
|
|
|
// 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<GeminiClient>;
|
|
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);
|
|
|
|
mockBaseLlmClientInstance = {
|
|
generateJson: vi.fn(),
|
|
} as unknown as Mocked<BaseLlmClient>;
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|