| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, beforeEach, vi } from 'vitest'; |
| import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; |
| import { type CommandContext } from '../../ui/commands/types.js'; |
| import { AtFileProcessor } from './atFileProcessor.js'; |
| import { MessageType } from '../../ui/types.js'; |
| import type { Config } from '@google/gemini-cli-core'; |
| import type { PartUnion } from '@google/genai'; |
|
|
| |
| const mockReadPathFromWorkspace = vi.hoisted(() => vi.fn()); |
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { |
| const original = await importOriginal<object>(); |
| return { |
| ...original, |
| readPathFromWorkspace: mockReadPathFromWorkspace, |
| }; |
| }); |
|
|
| describe('AtFileProcessor', () => { |
| let context: CommandContext; |
| let mockConfig: Config; |
|
|
| beforeEach(() => { |
| vi.clearAllMocks(); |
|
|
| mockConfig = { |
| |
| get config() { |
| return this; |
| }, |
| } as unknown as Config; |
|
|
| context = createMockCommandContext({ |
| services: { |
| agentContext: mockConfig, |
| }, |
| }); |
|
|
| |
| mockReadPathFromWorkspace.mockImplementation( |
| async (path: string): Promise<PartUnion[]> => [ |
| { text: `content of ${path}` }, |
| ], |
| ); |
| }); |
|
|
| it('should not change the prompt if no @{ trigger is present', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'This is a simple prompt.' }]; |
| const result = await processor.process(prompt, context); |
| expect(result).toEqual(prompt); |
| expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should not change the prompt if config service is missing', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; |
| const contextWithoutConfig = createMockCommandContext({ |
| services: { |
| agentContext: null, |
| }, |
| }); |
| const result = await processor.process(prompt, contextWithoutConfig); |
| expect(result).toEqual(prompt); |
| expect(mockReadPathFromWorkspace).not.toHaveBeenCalled(); |
| }); |
|
|
| describe('Parsing Logic', () => { |
| it('should replace a single valid @{path/to/file.txt} placeholder', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [ |
| { text: 'Analyze this file: @{path/to/file.txt}' }, |
| ]; |
| const result = await processor.process(prompt, context); |
| expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( |
| 'path/to/file.txt', |
| mockConfig, |
| ); |
| expect(result).toEqual([ |
| { text: 'Analyze this file: ' }, |
| { text: 'content of path/to/file.txt' }, |
| ]); |
| }); |
|
|
| it('should replace multiple different @{...} placeholders', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [ |
| { text: 'Compare @{file1.js} with @{file2.js}' }, |
| ]; |
| const result = await processor.process(prompt, context); |
| expect(mockReadPathFromWorkspace).toHaveBeenCalledTimes(2); |
| expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( |
| 'file1.js', |
| mockConfig, |
| ); |
| expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( |
| 'file2.js', |
| mockConfig, |
| ); |
| expect(result).toEqual([ |
| { text: 'Compare ' }, |
| { text: 'content of file1.js' }, |
| { text: ' with ' }, |
| { text: 'content of file2.js' }, |
| ]); |
| }); |
|
|
| it('should handle placeholders at the beginning, middle, and end', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [ |
| { text: '@{start.txt} in the @{middle.txt} and @{end.txt}' }, |
| ]; |
| const result = await processor.process(prompt, context); |
| expect(result).toEqual([ |
| { text: 'content of start.txt' }, |
| { text: ' in the ' }, |
| { text: 'content of middle.txt' }, |
| { text: ' and ' }, |
| { text: 'content of end.txt' }, |
| ]); |
| }); |
|
|
| it('should correctly parse paths that contain balanced braces', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [ |
| { text: 'Analyze @{path/with/{braces}/file.txt}' }, |
| ]; |
| const result = await processor.process(prompt, context); |
| expect(mockReadPathFromWorkspace).toHaveBeenCalledWith( |
| 'path/with/{braces}/file.txt', |
| mockConfig, |
| ); |
| expect(result).toEqual([ |
| { text: 'Analyze ' }, |
| { text: 'content of path/with/{braces}/file.txt' }, |
| ]); |
| }); |
|
|
| it('should throw an error if the prompt contains an unclosed trigger', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'Hello @{world' }]; |
| |
| await expect(processor.process(prompt, context)).rejects.toThrow( |
| /Unclosed injection/, |
| ); |
| }); |
| }); |
|
|
| describe('Integration and Error Handling', () => { |
| it('should leave the placeholder unmodified if readPathFromWorkspace throws', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [ |
| { text: 'Analyze @{not-found.txt} and @{good-file.txt}' }, |
| ]; |
| mockReadPathFromWorkspace.mockImplementation(async (path: string) => { |
| if (path === 'not-found.txt') { |
| throw new Error('File not found'); |
| } |
| return [{ text: `content of ${path}` }]; |
| }); |
|
|
| const result = await processor.process(prompt, context); |
| expect(result).toEqual([ |
| { text: 'Analyze ' }, |
| { text: '@{not-found.txt}' }, |
| { text: ' and ' }, |
| { text: 'content of good-file.txt' }, |
| ]); |
| }); |
| }); |
|
|
| describe('UI Feedback', () => { |
| it('should call ui.addItem with an ERROR on failure', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'Analyze @{bad-file.txt}' }]; |
| mockReadPathFromWorkspace.mockRejectedValue(new Error('Access denied')); |
|
|
| await processor.process(prompt, context); |
|
|
| expect(context.ui.addItem).toHaveBeenCalledTimes(1); |
| expect(context.ui.addItem).toHaveBeenCalledWith( |
| { |
| type: MessageType.ERROR, |
| text: "Failed to inject content for '@{bad-file.txt}': Access denied", |
| }, |
| expect.any(Number), |
| ); |
| }); |
|
|
| it('should call ui.addItem with a WARNING if the file was ignored', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'Analyze @{ignored.txt}' }]; |
| |
| mockReadPathFromWorkspace.mockResolvedValue([]); |
|
|
| const result = await processor.process(prompt, context); |
|
|
| |
| expect(result).toEqual([{ text: 'Analyze ' }]); |
|
|
| expect(context.ui.addItem).toHaveBeenCalledTimes(1); |
| expect(context.ui.addItem).toHaveBeenCalledWith( |
| { |
| type: MessageType.INFO, |
| text: "File '@{ignored.txt}' was ignored by .gitignore or .geminiignore and was not included in the prompt.", |
| }, |
| expect.any(Number), |
| ); |
| }); |
|
|
| it('should NOT call ui.addItem on success', async () => { |
| const processor = new AtFileProcessor(); |
| const prompt: PartUnion[] = [{ text: 'Analyze @{good-file.txt}' }]; |
| await processor.process(prompt, context); |
| expect(context.ui.addItem).not.toHaveBeenCalled(); |
| }); |
| }); |
| }); |
|
|