|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; |
|
|
|
|
|
|
|
|
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); |
|
|
this.startChat = (...params: any[]) => mockStartChat(...params); |
|
|
this.sendMessageStream = (...params: any[]) => |
|
|
mockSendMessageStream(...params); |
|
|
return this; |
|
|
}), |
|
|
})); |
|
|
|
|
|
|
|
|
import { |
|
|
countOccurrences, |
|
|
ensureCorrectEdit, |
|
|
ensureCorrectFileContent, |
|
|
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 (aggressive unescaping)', () => { |
|
|
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"'); |
|
|
}); |
|
|
it('should handle escaped backslashes', () => { |
|
|
expect(unescapeStringForGeminiBug('\\\\')).toBe('\\'); |
|
|
expect(unescapeStringForGeminiBug('C:\\\\Users')).toBe('C:\\Users'); |
|
|
expect(unescapeStringForGeminiBug('path\\\\to\\\\file')).toBe( |
|
|
'path\to\\file', |
|
|
); |
|
|
}); |
|
|
it('should handle escaped backslashes mixed with other escapes (aggressive unescaping)', () => { |
|
|
expect(unescapeStringForGeminiBug('line1\\\\\\nline2')).toBe( |
|
|
'line1\nline2', |
|
|
); |
|
|
expect(unescapeStringForGeminiBug('quote\\\\"text\\\\nline')).toBe( |
|
|
'quote"text\nline', |
|
|
); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('ensureCorrectEdit', () => { |
|
|
let mockGeminiClientInstance: Mocked<GeminiClient>; |
|
|
let mockToolRegistry: Mocked<ToolRegistry>; |
|
|
let mockConfigInstance: Config; |
|
|
const abortSignal = new AbortController().signal; |
|
|
|
|
|
beforeEach(() => { |
|
|
mockToolRegistry = new ToolRegistry({} as Config) as Mocked<ToolRegistry>; |
|
|
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<string, any> | 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) => { |
|
|
|
|
|
if (signal && signal.aborted) { |
|
|
return Promise.reject(new Error('Aborted')); |
|
|
} |
|
|
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<GeminiClient>; |
|
|
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', |
|
|
}; |
|
|
const result = await ensureCorrectEdit( |
|
|
currentContent, |
|
|
originalParams, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
expect(mockGenerateJson).toHaveBeenCalledTimes(0); |
|
|
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); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('ensureCorrectFileContent', () => { |
|
|
let mockGeminiClientInstance: Mocked<GeminiClient>; |
|
|
let mockToolRegistry: Mocked<ToolRegistry>; |
|
|
let mockConfigInstance: Config; |
|
|
const abortSignal = new AbortController().signal; |
|
|
|
|
|
beforeEach(() => { |
|
|
mockToolRegistry = new ToolRegistry({} as Config) as Mocked<ToolRegistry>; |
|
|
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<string, any> | 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) => { |
|
|
if (signal && signal.aborted) { |
|
|
return Promise.reject(new Error('Aborted')); |
|
|
} |
|
|
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<GeminiClient>; |
|
|
resetEditCorrectorCaches_TEST_ONLY(); |
|
|
}); |
|
|
|
|
|
it('should return content unchanged if no escaping issues detected', async () => { |
|
|
const content = 'This is normal content without escaping issues'; |
|
|
const result = await ensureCorrectFileContent( |
|
|
content, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
expect(result).toBe(content); |
|
|
expect(mockGenerateJson).toHaveBeenCalledTimes(0); |
|
|
}); |
|
|
|
|
|
it('should call correctStringEscaping for potentially escaped content', async () => { |
|
|
const content = 'console.log(\\"Hello World\\");'; |
|
|
const correctedContent = 'console.log("Hello World");'; |
|
|
mockResponses.push({ |
|
|
corrected_string_escaping: correctedContent, |
|
|
}); |
|
|
|
|
|
const result = await ensureCorrectFileContent( |
|
|
content, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(result).toBe(correctedContent); |
|
|
expect(mockGenerateJson).toHaveBeenCalledTimes(1); |
|
|
}); |
|
|
|
|
|
it('should handle correctStringEscaping returning corrected content via correct property name', async () => { |
|
|
|
|
|
const content = 'const message = \\"Hello\\nWorld\\";'; |
|
|
const correctedContent = 'const message = "Hello\nWorld";'; |
|
|
|
|
|
|
|
|
mockResponses.push({ |
|
|
corrected_string_escaping: correctedContent, |
|
|
}); |
|
|
|
|
|
const result = await ensureCorrectFileContent( |
|
|
content, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(result).toBe(correctedContent); |
|
|
expect(mockGenerateJson).toHaveBeenCalledTimes(1); |
|
|
}); |
|
|
|
|
|
it('should return original content if LLM correction fails', async () => { |
|
|
const content = 'console.log(\\"Hello World\\");'; |
|
|
|
|
|
mockResponses.push({}); |
|
|
|
|
|
const result = await ensureCorrectFileContent( |
|
|
content, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(result).toBe(content); |
|
|
expect(mockGenerateJson).toHaveBeenCalledTimes(1); |
|
|
}); |
|
|
|
|
|
it('should handle various escape sequences that need correction', async () => { |
|
|
const content = |
|
|
'const obj = { name: \\"John\\", age: 30, bio: \\"Developer\\nEngineer\\" };'; |
|
|
const correctedContent = |
|
|
'const obj = { name: "John", age: 30, bio: "Developer\nEngineer" };'; |
|
|
|
|
|
mockResponses.push({ |
|
|
corrected_string_escaping: correctedContent, |
|
|
}); |
|
|
|
|
|
const result = await ensureCorrectFileContent( |
|
|
content, |
|
|
mockGeminiClientInstance, |
|
|
abortSignal, |
|
|
); |
|
|
|
|
|
expect(result).toBe(correctedContent); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|