/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; // MOCKS let callCount = 0; const mockResponses: any[] = []; let mockGenerateJson: any; let mockStartChat: any; let mockSendMessageStream: any; vi.mock('../core/client.js', () => ({ GeminiClient: vi.fn().mockImplementation(function ( this: any, _config: Config, ) { this.generateJson = (...params: any[]) => mockGenerateJson(...params); // Corrected: use mockGenerateJson this.startChat = (...params: any[]) => mockStartChat(...params); // Corrected: use mockStartChat this.sendMessageStream = (...params: any[]) => mockSendMessageStream(...params); // Corrected: use mockSendMessageStream return this; }), })); // END MOCKS import { countOccurrences, ensureCorrectEdit, unescapeStringForGeminiBug, resetEditCorrectorCaches_TEST_ONLY, } from './editCorrector.js'; import { GeminiClient } from '../core/client.js'; import type { Config } from '../config/config.js'; import { ToolRegistry } from '../tools/tool-registry.js'; vi.mock('../tools/tool-registry.js'); describe('editCorrector', () => { describe('countOccurrences', () => { it('should return 0 for empty string', () => { expect(countOccurrences('', 'a')).toBe(0); }); it('should return 0 for empty substring', () => { expect(countOccurrences('abc', '')).toBe(0); }); it('should return 0 if substring is not found', () => { expect(countOccurrences('abc', 'd')).toBe(0); }); it('should return 1 if substring is found once', () => { expect(countOccurrences('abc', 'b')).toBe(1); }); it('should return correct count for multiple occurrences', () => { expect(countOccurrences('ababa', 'a')).toBe(3); expect(countOccurrences('ababab', 'ab')).toBe(3); }); it('should count non-overlapping occurrences', () => { expect(countOccurrences('aaaaa', 'aa')).toBe(2); expect(countOccurrences('ababab', 'aba')).toBe(1); }); it('should correctly count occurrences when substring is longer', () => { expect(countOccurrences('abc', 'abcdef')).toBe(0); }); it('should be case sensitive', () => { expect(countOccurrences('abcABC', 'a')).toBe(1); expect(countOccurrences('abcABC', 'A')).toBe(1); }); }); describe('unescapeStringForGeminiBug', () => { it('should unescape common sequences', () => { expect(unescapeStringForGeminiBug('\\n')).toBe('\n'); expect(unescapeStringForGeminiBug('\\t')).toBe('\t'); expect(unescapeStringForGeminiBug("\\'")).toBe("'"); expect(unescapeStringForGeminiBug('\\"')).toBe('"'); expect(unescapeStringForGeminiBug('\\`')).toBe('`'); }); it('should handle multiple escaped sequences', () => { expect(unescapeStringForGeminiBug('Hello\\nWorld\\tTest')).toBe( 'Hello\nWorld\tTest', ); }); it('should not alter already correct sequences', () => { expect(unescapeStringForGeminiBug('\n')).toBe('\n'); expect(unescapeStringForGeminiBug('Correct string')).toBe( 'Correct string', ); }); it('should handle mixed correct and incorrect sequences', () => { expect(unescapeStringForGeminiBug('\\nCorrect\t\\`')).toBe( '\nCorrect\t`', ); }); it('should handle backslash followed by actual newline character', () => { expect(unescapeStringForGeminiBug('\\\n')).toBe('\n'); expect(unescapeStringForGeminiBug('First line\\\nSecond line')).toBe( 'First line\nSecond line', ); }); it('should handle multiple backslashes before an escapable character', () => { expect(unescapeStringForGeminiBug('\\\\n')).toBe('\n'); expect(unescapeStringForGeminiBug('\\\\\\t')).toBe('\t'); expect(unescapeStringForGeminiBug('\\\\\\\\`')).toBe('`'); }); it('should return empty string for empty input', () => { expect(unescapeStringForGeminiBug('')).toBe(''); }); it('should not alter strings with no targeted escape sequences', () => { expect(unescapeStringForGeminiBug('abc def')).toBe('abc def'); expect(unescapeStringForGeminiBug('C:\\Folder\\File')).toBe( 'C:\\Folder\\File', ); }); it('should correctly process strings with some targeted escapes', () => { expect(unescapeStringForGeminiBug('C:\\Users\\name')).toBe( 'C:\\Users\name', ); }); it('should handle complex cases with mixed slashes and characters', () => { expect( unescapeStringForGeminiBug('\\\\\\\nLine1\\\nLine2\\tTab\\\\`Tick\\"'), ).toBe('\nLine1\nLine2\tTab`Tick"'); }); }); describe('ensureCorrectEdit', () => { let mockGeminiClientInstance: Mocked; let mockToolRegistry: Mocked; let mockConfigInstance: Config; const abortSignal = new AbortController().signal; beforeEach(() => { mockToolRegistry = new ToolRegistry({} as Config) as Mocked; const configParams = { apiKey: 'test-api-key', model: 'test-model', sandbox: false as boolean | string, targetDir: '/test', debugMode: false, question: undefined as string | undefined, fullContext: false, coreTools: undefined as string[] | undefined, toolDiscoveryCommand: undefined as string | undefined, toolCallCommand: undefined as string | undefined, mcpServerCommand: undefined as string | undefined, mcpServers: undefined as Record | undefined, userAgent: 'test-agent', userMemory: '', geminiMdFileCount: 0, alwaysSkipModificationConfirmation: false, }; mockConfigInstance = { ...configParams, getApiKey: vi.fn(() => configParams.apiKey), getModel: vi.fn(() => configParams.model), getSandbox: vi.fn(() => configParams.sandbox), getTargetDir: vi.fn(() => configParams.targetDir), getToolRegistry: vi.fn(() => mockToolRegistry), getDebugMode: vi.fn(() => configParams.debugMode), getQuestion: vi.fn(() => configParams.question), getFullContext: vi.fn(() => configParams.fullContext), getCoreTools: vi.fn(() => configParams.coreTools), getToolDiscoveryCommand: vi.fn(() => configParams.toolDiscoveryCommand), getToolCallCommand: vi.fn(() => configParams.toolCallCommand), getMcpServerCommand: vi.fn(() => configParams.mcpServerCommand), getMcpServers: vi.fn(() => configParams.mcpServers), getUserAgent: vi.fn(() => configParams.userAgent), getUserMemory: vi.fn(() => configParams.userMemory), setUserMemory: vi.fn((mem: string) => { configParams.userMemory = mem; }), getGeminiMdFileCount: vi.fn(() => configParams.geminiMdFileCount), setGeminiMdFileCount: vi.fn((count: number) => { configParams.geminiMdFileCount = count; }), getAlwaysSkipModificationConfirmation: vi.fn( () => configParams.alwaysSkipModificationConfirmation, ), setAlwaysSkipModificationConfirmation: vi.fn((skip: boolean) => { configParams.alwaysSkipModificationConfirmation = skip; }), } as unknown as Config; callCount = 0; mockResponses.length = 0; mockGenerateJson = vi .fn() .mockImplementation((_contents, _schema, signal) => { // Check if the signal is aborted. If so, throw an error or return a specific response. if (signal && signal.aborted) { return Promise.reject(new Error('Aborted')); // Or some other specific error/response } const response = mockResponses[callCount]; callCount++; if (response === undefined) return Promise.resolve({}); return Promise.resolve(response); }); mockStartChat = vi.fn(); mockSendMessageStream = vi.fn(); mockGeminiClientInstance = new GeminiClient( mockConfigInstance, ) as Mocked; resetEditCorrectorCaches_TEST_ONLY(); }); describe('Scenario Group 1: originalParams.old_string matches currentContent directly', () => { it('Test 1.1: old_string (no literal \\), new_string (escaped by Gemini) -> new_string unescaped', async () => { const currentContent = 'This is a test string to find me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find me', new_string: 'replace with \\"this\\"', }; mockResponses.push({ corrected_new_string_escaping: 'replace with "this"', }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe('replace with "this"'); expect(result.params.old_string).toBe('find me'); expect(result.occurrences).toBe(1); }); it('Test 1.2: old_string (no literal \\), new_string (correctly formatted) -> new_string unchanged', async () => { const currentContent = 'This is a test string to find me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find me', new_string: 'replace with this', }; const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(0); expect(result.params.new_string).toBe('replace with this'); expect(result.params.old_string).toBe('find me'); expect(result.occurrences).toBe(1); }); it('Test 1.3: old_string (with literal \\), new_string (escaped by Gemini) -> new_string unchanged (still escaped)', async () => { const currentContent = 'This is a test string to find\\me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find\\me', new_string: 'replace with \\"this\\"', }; mockResponses.push({ corrected_new_string_escaping: 'replace with "this"', }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe('replace with "this"'); expect(result.params.old_string).toBe('find\\me'); expect(result.occurrences).toBe(1); }); it('Test 1.4: old_string (with literal \\), new_string (correctly formatted) -> new_string unchanged', async () => { const currentContent = 'This is a test string to find\\me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find\\me', new_string: 'replace with this', }; const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(0); expect(result.params.new_string).toBe('replace with this'); expect(result.params.old_string).toBe('find\\me'); expect(result.occurrences).toBe(1); }); }); describe('Scenario Group 2: originalParams.old_string does NOT match, but unescapeStringForGeminiBug(originalParams.old_string) DOES match', () => { it('Test 2.1: old_string (over-escaped, no intended literal \\), new_string (escaped by Gemini) -> new_string unescaped', async () => { const currentContent = 'This is a test string to find "me".'; const originalParams = { file_path: '/test/file.txt', old_string: 'find \\"me\\"', new_string: 'replace with \\"this\\"', }; mockResponses.push({ corrected_new_string: 'replace with "this"' }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe('replace with "this"'); expect(result.params.old_string).toBe('find "me"'); expect(result.occurrences).toBe(1); }); it('Test 2.2: old_string (over-escaped, no intended literal \\), new_string (correctly formatted) -> new_string unescaped (harmlessly)', async () => { const currentContent = 'This is a test string to find "me".'; const originalParams = { file_path: '/test/file.txt', old_string: 'find \\"me\\"', new_string: 'replace with this', }; const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(0); expect(result.params.new_string).toBe('replace with this'); expect(result.params.old_string).toBe('find "me"'); expect(result.occurrences).toBe(1); }); it('Test 2.3: old_string (over-escaped, with intended literal \\), new_string (simple) -> new_string corrected', async () => { const currentContent = 'This is a test string to find \\me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find \\\\me', new_string: 'replace with foobar', }; mockResponses.push({ corrected_target_snippet: 'find \\me', }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe('replace with foobar'); expect(result.params.old_string).toBe('find \\me'); expect(result.occurrences).toBe(1); }); }); describe('Scenario Group 3: LLM Correction Path', () => { it('Test 3.1: old_string (no literal \\), new_string (escaped by Gemini), LLM re-escapes new_string -> final new_string is double unescaped', async () => { const currentContent = 'This is a test string to corrected find me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find me', new_string: 'replace with \\\\"this\\\\"', }; const llmNewString = 'LLM says replace with "that"'; mockResponses.push({ corrected_new_string_escaping: llmNewString }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe(llmNewString); expect(result.params.old_string).toBe('find me'); expect(result.occurrences).toBe(1); }); it('Test 3.2: old_string (with literal \\), new_string (escaped by Gemini), LLM re-escapes new_string -> final new_string is unescaped once', async () => { const currentContent = 'This is a test string to corrected find me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find\\me', new_string: 'replace with \\\\"this\\\\"', }; const llmCorrectedOldString = 'corrected find me'; const llmNewString = 'LLM says replace with "that"'; mockResponses.push({ corrected_target_snippet: llmCorrectedOldString }); mockResponses.push({ corrected_new_string: llmNewString }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(2); expect(result.params.new_string).toBe(llmNewString); expect(result.params.old_string).toBe(llmCorrectedOldString); expect(result.occurrences).toBe(1); }); it('Test 3.3: old_string needs LLM, new_string is fine -> old_string corrected, new_string original', async () => { const currentContent = 'This is a test string to be corrected.'; const originalParams = { file_path: '/test/file.txt', old_string: 'fiiind me', new_string: 'replace with "this"', }; const llmCorrectedOldString = 'to be corrected'; mockResponses.push({ corrected_target_snippet: llmCorrectedOldString }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe('replace with "this"'); expect(result.params.old_string).toBe(llmCorrectedOldString); expect(result.occurrences).toBe(1); }); it('Test 3.4: LLM correction path, correctNewString returns the originalNewString it was passed (which was unescaped) -> final new_string is unescaped', async () => { const currentContent = 'This is a test string to corrected find me.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find me', new_string: 'replace with \\\\"this\\\\"', }; const newStringForLLMAndReturnedByLLM = 'replace with "this"'; mockResponses.push({ corrected_new_string_escaping: newStringForLLMAndReturnedByLLM, }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params.new_string).toBe(newStringForLLMAndReturnedByLLM); expect(result.occurrences).toBe(1); }); }); describe('Scenario Group 4: No Match Found / Multiple Matches', () => { it('Test 4.1: No version of old_string (original, unescaped, LLM-corrected) matches -> returns original params, 0 occurrences', async () => { const currentContent = 'This content has nothing to find.'; const originalParams = { file_path: '/test/file.txt', old_string: 'nonexistent string', new_string: 'some new string', }; mockResponses.push({ corrected_target_snippet: 'still nonexistent' }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(1); expect(result.params).toEqual(originalParams); expect(result.occurrences).toBe(0); }); it('Test 4.2: unescapedOldStringAttempt results in >1 occurrences -> returns original params, count occurrences', async () => { const currentContent = 'This content has find "me" and also find "me" again.'; const originalParams = { file_path: '/test/file.txt', old_string: 'find "me"', new_string: 'some new string', }; const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(0); expect(result.params).toEqual(originalParams); expect(result.occurrences).toBe(2); }); }); describe('Scenario Group 5: Specific unescapeStringForGeminiBug checks (integrated into ensureCorrectEdit)', () => { it('Test 5.1: old_string needs LLM to become currentContent, new_string also needs correction', async () => { const currentContent = 'const x = "a\\nbc\\\\"def\\\\"'; const originalParams = { file_path: '/test/file.txt', old_string: 'const x = \\\\"a\\\\nbc\\\\\\\\"def\\\\\\\\"', new_string: 'const y = \\\\"new\\\\nval\\\\\\\\"content\\\\\\\\"', }; const expectedFinalNewString = 'const y = "new\\nval\\\\"content\\\\"'; mockResponses.push({ corrected_target_snippet: currentContent }); mockResponses.push({ corrected_new_string: expectedFinalNewString }); const result = await ensureCorrectEdit( currentContent, originalParams, mockGeminiClientInstance, abortSignal, ); expect(mockGenerateJson).toHaveBeenCalledTimes(2); expect(result.params.old_string).toBe(currentContent); expect(result.params.new_string).toBe(expectedFinalNewString); expect(result.occurrences).toBe(1); }); }); }); });