Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -6,9 +6,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
const mockGenerateJson = vi.hoisted(() => vi.fn());
const mockOpenDiff = vi.hoisted(() => vi.fn());
import { IDEConnectionStatus } from '../ide/ide-client.js';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn(),
},
}));
vi.mock('../utils/editor.js', () => ({
openDiff: mockOpenDiff,
@@ -38,19 +45,31 @@ describe('EditTool', () => {
let tempDir: string;
let rootDir: string;
let mockConfig: Config;
let geminiClient: any;
let baseLlmClient: any;
beforeEach(() => {
vi.restoreAllMocks();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-tool-test-'));
rootDir = path.join(tempDir, 'root');
fs.mkdirSync(rootDir);
geminiClient = {
generateJson: mockGenerateJson, // mockGenerateJson is already defined and hoisted
};
baseLlmClient = {
generateJson: vi.fn(),
};
mockConfig = {
getGeminiClient: vi.fn().mockReturnValue(geminiClient),
getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient),
getTargetDir: () => rootDir,
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
getFileSystemService: () => new StandardFileSystemService(),
getIdeClient: () => undefined,
getIdeMode: () => false,
getApiKey: () => 'test-api-key',
getModel: () => 'test-model',
@@ -107,6 +126,86 @@ describe('EditTool', () => {
'hello world',
);
});
it('should treat $ literally and not as replacement pattern', () => {
const current = "price is $100 and pattern end is ' '";
const oldStr = 'price is $100';
const newStr = 'price is $200';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("price is $200 and pattern end is ' '");
});
it("should treat $' literally and not as a replacement pattern", () => {
const current = 'foo';
const oldStr = 'foo';
const newStr = "bar$'baz";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("bar$'baz");
});
it('should treat $& literally and not as a replacement pattern', () => {
const current = 'hello world';
const oldStr = 'hello';
const newStr = '$&-replacement';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('$&-replacement world');
});
it('should treat $` literally and not as a replacement pattern', () => {
const current = 'prefix-middle-suffix';
const oldStr = 'middle';
const newStr = 'new$`content';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('prefix-new$`content-suffix');
});
it('should treat $1, $2 capture groups literally', () => {
const current = 'test string';
const oldStr = 'test';
const newStr = '$1$2replacement';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('$1$2replacement string');
});
it('should use replaceAll for normal strings without problematic $ sequences', () => {
const current = 'normal text replacement';
const oldStr = 'text';
const newStr = 'string';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('normal string replacement');
});
it('should handle multiple occurrences with problematic $ sequences', () => {
const current = 'foo bar foo baz';
const oldStr = 'foo';
const newStr = "test$'end";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("test$'end bar test$'end baz");
});
it('should handle complex regex patterns with $ at end', () => {
const current = "| select('match', '^[sv]d[a-z]$')";
const oldStr = "'^[sv]d[a-z]$'";
const newStr = "'^[sv]d[a-z]$' # updated";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("| select('match', '^[sv]d[a-z]$' # updated)");
});
it('should handle empty replacement with problematic $ in newString', () => {
const current = 'test content';
const oldStr = 'nothing';
const newStr = "replacement$'text";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('test content'); // No replacement because oldStr not found
});
it('should handle $$ (escaped dollar) correctly', () => {
const current = 'price value';
const oldStr = 'value';
const newStr = '$$100';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('price $$100');
});
});
describe('validateToolParams', () => {
@@ -229,9 +328,32 @@ describe('EditTool', () => {
);
});
// This test is no longer relevant since editCorrector functionality was removed
it.skip('should use corrected params from ensureCorrectEdit for diff generation', async () => {
// Test skipped - editCorrector functionality removed
it('should rethrow calculateEdit errors when the abort signal is triggered', async () => {
const filePath = path.join(rootDir, 'abort-confirmation.txt');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error('Abort requested');
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(
invocation.shouldConfirmExecute(abortController.signal),
).rejects.toBe(abortError);
calculateSpy.mockRestore();
});
});
@@ -263,6 +385,33 @@ describe('EditTool', () => {
);
});
it('should reject when calculateEdit fails after an abort signal', async () => {
const params: EditToolParams = {
file_path: path.join(rootDir, 'abort-execute.txt'),
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
const abortController = new AbortController();
const abortError = new Error('Abort requested during execute');
const calculateSpy = vi
.spyOn(invocation as any, 'calculateEdit')
.mockImplementation(async () => {
if (!abortController.signal.aborted) {
abortController.abort();
}
throw abortError;
});
await expect(invocation.execute(abortController.signal)).rejects.toBe(
abortError,
);
calculateSpy.mockRestore();
});
it('should edit an existing file and return diff with fileName', async () => {
const initialContent = 'This is some old text.';
const newContent = 'This is some new text.'; // old -> new
@@ -303,7 +452,20 @@ describe('EditTool', () => {
expect(result.llmContent).toMatch(/Created new file/);
expect(fs.existsSync(newFilePath)).toBe(true);
expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
expect(result.returnDisplay).toBe(`Created ${newFileName}`);
const display = result.returnDisplay as FileDiff;
expect(display.fileDiff).toMatch(/\+Content for the new file\./);
expect(display.fileName).toBe(newFileName);
expect((result.returnDisplay as FileDiff).diffStat).toStrictEqual({
model_added_lines: 1,
model_removed_lines: 0,
model_added_chars: 25,
model_removed_chars: 0,
user_added_lines: 0,
user_removed_lines: 0,
user_added_chars: 0,
user_removed_chars: 0,
});
});
it('should return error if old_string is not found in file', async () => {
@@ -341,7 +503,7 @@ describe('EditTool', () => {
});
it('should successfully replace multiple occurrences when expected_replacements specified', async () => {
fs.writeFileSync(filePath, 'old text old text old text', 'utf8');
fs.writeFileSync(filePath, 'old text\nold text\nold text', 'utf8');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
@@ -354,12 +516,23 @@ describe('EditTool', () => {
expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(filePath, 'utf8')).toBe(
'new text new text new text',
'new text\nnew text\nnew text',
);
const display = result.returnDisplay as FileDiff;
expect(display.fileDiff).toMatch(/old text old text old text/);
expect(display.fileDiff).toMatch(/new text new text new text/);
expect(display.fileDiff).toMatch(/-old text\n-old text\n-old text/);
expect(display.fileDiff).toMatch(/\+new text\n\+new text\n\+new text/);
expect(display.fileName).toBe(testFile);
expect((result.returnDisplay as FileDiff).diffStat).toStrictEqual({
model_added_lines: 3,
model_removed_lines: 3,
model_added_chars: 24,
model_removed_chars: 24,
user_added_lines: 0,
user_removed_lines: 0,
user_added_chars: 0,
user_removed_chars: 0,
});
});
it('should return error if expected_replacements does not match actual occurrences', async () => {
@@ -396,13 +569,14 @@ describe('EditTool', () => {
});
it('should include modification message when proposed content is modified', async () => {
const initialContent = 'This is some old text.';
const initialContent = 'Line 1\nold line\nLine 3\nLine 4\nLine 5\n';
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
modified_by_user: true,
ai_proposed_content: 'Line 1\nAI line\nLine 3\nLine 4\nLine 5\n',
};
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
@@ -414,6 +588,16 @@ describe('EditTool', () => {
expect(result.llmContent).toMatch(
/User modified the `new_string` content/,
);
expect((result.returnDisplay as FileDiff).diffStat).toStrictEqual({
model_added_lines: 1,
model_removed_lines: 1,
model_added_chars: 7,
model_removed_chars: 8,
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 8,
user_removed_chars: 7,
});
});
it('should not include modification message when proposed content is not modified', async () => {
@@ -681,12 +865,10 @@ describe('EditTool', () => {
filePath = path.join(rootDir, testFile);
ideClient = {
openDiff: vi.fn(),
getConnectionStatus: vi.fn().mockReturnValue({
status: IDEConnectionStatus.Connected,
}),
isDiffingEnabled: vi.fn().mockReturnValue(true),
};
vi.mocked(IdeClient.getInstance).mockResolvedValue(ideClient);
(mockConfig as any).getIdeMode = () => true;
(mockConfig as any).getIdeClient = () => ideClient;
});
it('should call ideClient.openDiff and update params on confirmation', async () => {