diff options
| author | Tommaso Sciortino <[email protected]> | 2025-07-22 17:18:57 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-23 00:18:57 +0000 |
| commit | 30c68922a372755a5b9f918baf053e0d1f156fc5 (patch) | |
| tree | f2889f7f89fc3609071f943ce110a0d755d78b7f /packages/cli/src/ui/hooks/atCommandProcessor.test.ts | |
| parent | a00f1bb916b551fc17fa5bab80fb51dcdc88f00d (diff) | |
Fix windows bugs in atCommandProcessor.ts (#4684)
Diffstat (limited to 'packages/cli/src/ui/hooks/atCommandProcessor.test.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/atCommandProcessor.test.ts | 1106 |
1 files changed, 345 insertions, 761 deletions
diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 6e272b24..de05667e 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -5,117 +5,74 @@ */ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; -import type { Mocked } from 'vitest'; import { handleAtCommand } from './atCommandProcessor.js'; -import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; +import { + Config, + FileDiscoveryService, + GlobTool, + ReadManyFilesTool, + ToolRegistry, +} from '@google/gemini-cli-core'; +import * as os from 'os'; import { ToolCallStatus } from '../types.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import * as fsPromises from 'fs/promises'; -import type { Stats } from 'fs'; +import * as path from 'path'; -const mockGetToolRegistry = vi.fn(); -const mockGetTargetDir = vi.fn(); -const mockConfig = { - getToolRegistry: mockGetToolRegistry, - getTargetDir: mockGetTargetDir, - isSandboxed: vi.fn(() => false), - getFileService: vi.fn(), - getFileFilteringRespectGitIgnore: vi.fn(() => true), - getFileFilteringRespectGeminiIgnore: vi.fn(() => true), - getFileFilteringOptions: vi.fn(() => ({ - respectGitIgnore: true, - respectGeminiIgnore: true, - })), - getEnableRecursiveFileSearch: vi.fn(() => true), -} as unknown as Config; +describe('handleAtCommand', () => { + let testRootDir: string; + let mockConfig: Config; -const mockReadManyFilesExecute = vi.fn(); -const mockReadManyFilesTool = { - name: 'read_many_files', - displayName: 'Read Many Files', - description: 'Reads multiple files.', - execute: mockReadManyFilesExecute, - getDescription: vi.fn((params) => `Read files: ${params.paths.join(', ')}`), -}; + const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn(); + const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); -const mockGlobExecute = vi.fn(); -const mockGlobTool = { - name: 'glob', - displayName: 'Glob Tool', - execute: mockGlobExecute, - getDescription: vi.fn(() => 'Glob tool description'), -}; + let abortController: AbortController; -const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn(); -const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); + async function createTestFile(fullPath: string, fileContents: string) { + await fsPromises.mkdir(path.dirname(fullPath), { recursive: true }); + await fsPromises.writeFile(fullPath, fileContents); + return path.resolve(testRootDir, fullPath); + } -vi.mock('fs/promises', async () => { - const actual = await vi.importActual('fs/promises'); - return { - ...actual, - stat: vi.fn(), - }; -}); - -vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); - return { - ...actual, - FileDiscoveryService: vi.fn(), - }; -}); + beforeEach(async () => { + vi.resetAllMocks(); -describe('handleAtCommand', () => { - let abortController: AbortController; - let mockFileDiscoveryService: Mocked<FileDiscoveryService>; + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'folder-structure-test-'), + ); - beforeEach(() => { - vi.resetAllMocks(); abortController = new AbortController(); - mockGetTargetDir.mockReturnValue('/test/dir'); - mockGetToolRegistry.mockReturnValue({ - getTool: vi.fn((toolName: string) => { - if (toolName === 'read_many_files') return mockReadManyFilesTool; - if (toolName === 'glob') return mockGlobTool; - return undefined; - }), - }); - vi.mocked(fsPromises.stat).mockResolvedValue({ - isDirectory: () => false, - } as Stats); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: '', - returnDisplay: '', - }); - mockGlobExecute.mockResolvedValue({ - llmContent: 'No files found', - returnDisplay: '', - }); - // Mock FileDiscoveryService - mockFileDiscoveryService = { - initialize: vi.fn(), - shouldIgnoreFile: vi.fn(() => false), - filterFiles: vi.fn((files) => files), - getIgnoreInfo: vi.fn(() => ({ gitIgnored: [] })), - isGitRepository: vi.fn(() => true), - }; - vi.mocked(FileDiscoveryService).mockImplementation( - () => mockFileDiscoveryService, - ); + const getToolRegistry = vi.fn(); - // Mock getFileService to return the mocked FileDiscoveryService - mockConfig.getFileService = vi - .fn() - .mockReturnValue(mockFileDiscoveryService); + mockConfig = { + getToolRegistry, + getTargetDir: () => testRootDir, + isSandboxed: () => false, + getFileService: () => new FileDiscoveryService(testRootDir), + getFileFilteringRespectGitIgnore: () => true, + getFileFilteringRespectGeminiIgnore: () => true, + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + }), + getEnableRecursiveFileSearch: vi.fn(() => true), + } as unknown as Config; + + const registry = new ToolRegistry(mockConfig); + registry.registerTool(new ReadManyFilesTool(mockConfig)); + registry.registerTool(new GlobTool(mockConfig)); + getToolRegistry.mockReturnValue(registry); }); - afterEach(() => { + afterEach(async () => { abortController.abort(); + await fsPromises.rm(testRootDir, { recursive: true, force: true }); }); it('should pass through query if no @ command is present', async () => { const query = 'regular user query'; + const result = await handleAtCommand({ query, config: mockConfig, @@ -124,17 +81,20 @@ describe('handleAtCommand', () => { messageId: 123, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 123, ); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); }); it('should pass through original query if only a lone @ symbol is present', async () => { const queryWithSpaces = ' @ '; + const result = await handleAtCommand({ query: queryWithSpaces, config: mockConfig, @@ -143,25 +103,27 @@ describe('handleAtCommand', () => { messageId: 124, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [{ text: queryWithSpaces }], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: queryWithSpaces }, 124, ); - expect(result.processedQuery).toEqual([{ text: queryWithSpaces }]); - expect(result.shouldProceed).toBe(true); expect(mockOnDebugMessage).toHaveBeenCalledWith( 'Lone @ detected, will be treated as text in the modified query.', ); }); it('should process a valid text file path', async () => { - const filePath = 'path/to/file.txt'; - const query = `@${filePath}`; const fileContent = 'This is the file content.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'file.txt'), + fileContent, + ); + const query = `@${filePath}`; const result = await handleAtCommand({ query, @@ -171,20 +133,21 @@ describe('handleAtCommand', () => { messageId: 125, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 125, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [filePath], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'tool_group', @@ -192,28 +155,17 @@ describe('handleAtCommand', () => { }), 125, ); - expect(result.processedQuery).toEqual([ - { text: `@${filePath}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should process a valid directory path and convert to glob', async () => { - const dirPath = 'path/to/dir'; + const fileContent = 'This is the file content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'file.txt'), + fileContent, + ); + const dirPath = path.dirname(filePath); const query = `@${dirPath}`; const resolvedGlob = `${dirPath}/**`; - const fileContent = 'Directory content.'; - vi.mocked(fsPromises.stat).mockResolvedValue({ - isDirectory: () => true, - } as Stats); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${resolvedGlob} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read directory contents.', - }); const result = await handleAtCommand({ query, @@ -223,76 +175,35 @@ describe('handleAtCommand', () => { messageId: 126, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `@${resolvedGlob}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 126, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [resolvedGlob], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`, ); - expect(result.processedQuery).toEqual([ - { text: `@${resolvedGlob}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${resolvedGlob}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); - }); - - it('should process a valid image file path (as text content for now)', async () => { - const imagePath = 'path/to/image.png'; - const query = `@${imagePath}`; - // For @-commands, read_many_files is expected to return text or structured text. - // If it were to return actual image Part, the test and handling would be different. - // Current implementation of read_many_files for images returns base64 in text. - const imageFileTextContent = '[base64 image data for path/to/image.png]'; - const imagePart = { - mimeType: 'image/png', - inlineData: imageFileTextContent, - }; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [imagePart], - returnDisplay: 'Read 1 image.', - }); - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 127, - signal: abortController.signal, - }); - expect(result.processedQuery).toEqual([ - { text: `@${imagePath}` }, - { text: '\n--- Content from referenced files ---' }, - imagePart, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should handle query with text before and after @command', async () => { + const fileContent = 'Markdown content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'doc.md'), + fileContent, + ); const textBefore = 'Explain this: '; - const filePath = 'doc.md'; const textAfter = ' in detail.'; const query = `${textBefore}@${filePath}${textAfter}`; - const fileContent = 'Markdown content.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 doc.', - }); const result = await handleAtCommand({ query, @@ -302,64 +213,76 @@ describe('handleAtCommand', () => { messageId: 128, signal: abortController.signal, }); + + expect(result).toEqual({ + processedQuery: [ + { text: `${textBefore}@${filePath}${textAfter}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockAddItem).toHaveBeenCalledWith( { type: 'user', text: query }, 128, ); - expect(result.processedQuery).toEqual([ - { text: `${textBefore}@${filePath}${textAfter}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${filePath}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should correctly unescape paths with escaped spaces', async () => { - const rawPath = 'path/to/my\\ file.txt'; - const unescapedPath = 'path/to/my file.txt'; - const query = `@${rawPath}`; - const fileContent = 'Content of file with space.'; - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${unescapedPath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const fileContent = 'This is the file content.'; + const filePath = await createTestFile( + path.join(testRootDir, 'path', 'to', 'my file.txt'), + fileContent, + ); + const escapedpath = path.join(testRootDir, 'path', 'to', 'my\\ file.txt'); + const query = `@${escapedpath}`; - await handleAtCommand({ + const result = await handleAtCommand({ query, config: mockConfig, addItem: mockAddItem, onDebugMessage: mockOnDebugMessage, - messageId: 129, + messageId: 125, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [unescapedPath], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, + + expect(result).toEqual({ + processedQuery: [ + { text: `@${filePath}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${filePath}:\n` }, + { text: fileContent }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); + expect(mockAddItem).toHaveBeenCalledWith( + { type: 'user', text: query }, + 125, + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'tool_group', + tools: [expect.objectContaining({ status: ToolCallStatus.Success })], + }), + 125, ); }); it('should handle multiple @file references', async () => { - const file1 = 'file1.txt'; const content1 = 'Content file1'; - const file2 = 'file2.md'; + const file1Path = await createTestFile( + path.join(testRootDir, 'file1.txt'), + content1, + ); const content2 = 'Content file2'; - const query = `@${file1} @${file2}`; - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${file2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const file2Path = await createTestFile( + path.join(testRootDir, 'file2.md'), + content2, + ); + const query = `@${file1Path} @${file2Path}`; const result = await handleAtCommand({ query, @@ -369,45 +292,36 @@ describe('handleAtCommand', () => { messageId: 130, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, file2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `@${file1} @${file2}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${file2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle multiple @file references with interleaved text', async () => { const text1 = 'Check '; - const file1 = 'f1.txt'; const content1 = 'C1'; + const file1Path = await createTestFile( + path.join(testRootDir, 'f1.txt'), + content1, + ); const text2 = ' and '; - const file2 = 'f2.md'; const content2 = 'C2'; + const file2Path = await createTestFile( + path.join(testRootDir, 'f2.md'), + content2, + ); const text3 = ' please.'; - const query = `${text1}@${file1}${text2}@${file2}${text3}`; - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${file2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const query = `${text1}@${file1Path}${text2}@${file2Path}${text3}`; const result = await handleAtCommand({ query, @@ -417,67 +331,34 @@ describe('handleAtCommand', () => { messageId: 131, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, file2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `${text1}@${file1}${text2}@${file2}${text3}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${file2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + + expect(result).toEqual({ + processedQuery: [ + { text: query }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle a mix of valid, invalid, and lone @ references', async () => { - const file1 = 'valid1.txt'; const content1 = 'Valid content 1'; + const file1Path = await createTestFile( + path.join(testRootDir, 'valid1.txt'), + content1, + ); const invalidFile = 'nonexistent.txt'; - const query = `Look at @${file1} then @${invalidFile} and also just @ symbol, then @valid2.glob`; - const file2Glob = 'valid2.glob'; - const resolvedFile2 = 'resolved/valid2.actual'; const content2 = 'Globbed content'; - - // Mock fs.stat for file1 (valid) - vi.mocked(fsPromises.stat).mockImplementation(async (p) => { - if (p.toString().endsWith(file1)) - return { isDirectory: () => false } as Stats; - if (p.toString().endsWith(invalidFile)) - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - // For valid2.glob, stat will fail, triggering glob - if (p.toString().endsWith(file2Glob)) - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - return { isDirectory: () => false } as Stats; // Default - }); - - // Mock glob to find resolvedFile2 for valid2.glob - mockGlobExecute.mockImplementation(async (params) => { - if (params.pattern.includes('valid2.glob')) { - return { - llmContent: `Found files:\n${mockGetTargetDir()}/${resolvedFile2}`, - returnDisplay: 'Found 1 file', - }; - } - return { llmContent: 'No files found', returnDisplay: '' }; - }); - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [ - `--- ${file1} ---\n\n${content1}\n\n`, - `--- ${resolvedFile2} ---\n\n${content2}\n\n`, - ], - returnDisplay: 'Read 2 files.', - }); + const file2Path = await createTestFile( + path.join(testRootDir, 'resolved', 'valid2.actual'), + content2, + ); + const query = `Look at @${file1Path} then @${invalidFile} and also just @ symbol, then @${file2Path}`; const result = await handleAtCommand({ query, @@ -488,29 +369,20 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [file1, resolvedFile2], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, + expect(result).toEqual({ + processedQuery: [ + { + text: `Look at @${file1Path} then @${invalidFile} and also just @ symbol, then @${file2Path}`, }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - // Original query has @nonexistent.txt and @, but resolved has @resolved/valid2.actual - { - text: `Look at @${file1} then @${invalidFile} and also just @ symbol, then @${resolvedFile2}`, - }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${file1}:\n` }, - { text: content1 }, - { text: `\nContent from @${resolvedFile2}:\n` }, - { text: content2 }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${file2Path}:\n` }, + { text: content2 }, + { text: `\nContent from @${file1Path}:\n` }, + { text: content1 }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${invalidFile} not found directly, attempting glob search.`, ); @@ -524,13 +396,6 @@ describe('handleAtCommand', () => { it('should return original query if all @paths are invalid or lone @', async () => { const query = 'Check @nonexistent.txt and @ also'; - vi.mocked(fsPromises.stat).mockRejectedValue( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - mockGlobExecute.mockResolvedValue({ - llmContent: 'No files found', - returnDisplay: '', - }); const result = await handleAtCommand({ query, @@ -540,110 +405,32 @@ describe('handleAtCommand', () => { messageId: 133, signal: abortController.signal, }); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - // The modified query string will be "Check @nonexistent.txt and @ also" because no paths were resolved for reading. - expect(result.processedQuery).toEqual([ - { text: 'Check @nonexistent.txt and @ also' }, - ]); - - expect(result.shouldProceed).toBe(true); - }); - - it('should process a file path case-insensitively', async () => { - // const actualFilePath = 'path/to/MyFile.txt'; // Unused, path in llmContent should match queryPath - const queryPath = 'path/to/myfile.txt'; // Different case - const query = `@${queryPath}`; - const fileContent = 'This is the case-insensitive file content.'; - - // Mock fs.stat to "find" MyFile.txt when looking for myfile.txt - // This simulates a case-insensitive file system or resolution - vi.mocked(fsPromises.stat).mockImplementation(async (p) => { - if (p.toString().toLowerCase().endsWith('myfile.txt')) { - return { - isDirectory: () => false, - // You might need to add other Stats properties if your code uses them - } as Stats; - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${queryPath} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 134, // New messageId - signal: abortController.signal, + expect(result).toEqual({ + processedQuery: [{ text: 'Check @nonexistent.txt and @ also' }], + shouldProceed: true, }); - - expect(mockAddItem).toHaveBeenCalledWith( - { type: 'user', text: query }, - 134, - ); - // The atCommandProcessor resolves the path before calling read_many_files. - // We expect it to be called with the path that fs.stat "found". - // In a real case-insensitive FS, stat(myfile.txt) might return info for MyFile.txt. - // The key is that *a* valid path that points to the content is used. - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - // Depending on how path resolution and fs.stat mock interact, - // this could be queryPath or actualFilePath. - // For this test, we'll assume the processor uses the path that stat "succeeded" with. - // If the underlying fs/stat is truly case-insensitive, it might resolve to actualFilePath. - // If the mock is simpler, it might use queryPath if stat(queryPath) succeeds. - // The most important part is that *some* version of the path that leads to the content is used. - // Let's assume it uses the path from the query if stat confirms it exists (even if different case on disk) - { - paths: [queryPath], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_group', - tools: [expect.objectContaining({ status: ToolCallStatus.Success })], - }), - 134, - ); - expect(result.processedQuery).toEqual([ - { text: `@${queryPath}` }, // Query uses the input path - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${queryPath}:\n` }, // Content display also uses input path - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); describe('git-aware filtering', () => { - it('should skip git-ignored files in @ commands', async () => { - const gitIgnoredFile = 'node_modules/package.json'; - const query = `@${gitIgnoredFile}`; + beforeEach(async () => { + await fsPromises.mkdir(path.join(testRootDir, '.git'), { + recursive: true, + }); + }); - // Mock the file discovery service to report this file as git-ignored - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path !== gitIgnoredFile) return false; - if (options?.respectGitIgnore) return true; - if (options?.respectGeminiIgnore) return false; - return false; - }, + it('should skip git-ignored files in @ commands', async () => { + await createTestFile( + path.join(testRootDir, '.gitignore'), + 'node_modules/package.json', + ); + const gitIgnoredFile = await createTestFile( + path.join(testRootDir, 'node_modules', 'package.json'), + 'the file contents', ); + const query = `@${gitIgnoredFile}`; + const result = await handleAtCommand({ query, config: mockConfig, @@ -653,48 +440,29 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - // Should be called twice - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 2, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitIgnoredFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: node_modules/package.json', + `Ignored 1 files:\nGit-ignored: ${gitIgnoredFile}`, ); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); }); it('should process non-git-ignored files normally', async () => { - const validFile = 'src/index.ts'; - const query = `@${validFile}`; - const fileContent = 'console.log("Hello world");'; + await createTestFile( + path.join(testRootDir, '.gitignore'), + 'node_modules/package.json', + ); - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - _path: string, - _options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => false, + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'index.ts'), + 'console.log("Hello world");', ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const query = `@${validFile}`; const result = await handleAtCommand({ query, @@ -705,65 +473,29 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - // Should be called twice - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 2, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [validFile], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `@${validFile}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + expect(result).toEqual({ + processedQuery: [ + { text: `@${validFile}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${validFile}:\n` }, + { text: 'console.log("Hello world");' }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); }); it('should handle mixed git-ignored and valid files', async () => { - const validFile = 'README.md'; - const gitIgnoredFile = '.env'; - const query = `@${validFile} @${gitIgnoredFile}`; - const fileContent = '# Project README'; - - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path === gitIgnoredFile && options?.respectGitIgnore) { - return true; - } - if (options?.respectGeminiIgnore) { - return false; - } - return false; - }, + await createTestFile(path.join(testRootDir, '.gitignore'), '.env'); + const validFile = await createTestFile( + path.join(testRootDir, 'README.md'), + '# Project README', ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); + const gitIgnoredFile = await createTestFile( + path.join(testRootDir, '.env'), + 'SECRET=123', + ); + const query = `@${validFile} @${gitIgnoredFile}`; const result = await handleAtCommand({ query, @@ -774,66 +506,30 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - // Should be called twice for each file - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 4, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); + expect(result).toEqual({ + processedQuery: [ + { text: `@${validFile} @${gitIgnoredFile}` }, + { text: '\n--- Content from referenced files ---' }, + { text: `\nContent from @${validFile}:\n` }, + { text: '# Project README' }, + { text: '\n--- End of content ---' }, + ], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitIgnoredFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: .env', + `Ignored 1 files:\nGit-ignored: ${gitIgnoredFile}`, ); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [validFile], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ - { text: `@${validFile} @${gitIgnoredFile}` }, - { text: '\n--- Content from referenced files ---' }, - { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, - { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); }); it('should always ignore .git directory files', async () => { - const gitFile = '.git/config'; - const query = `@${gitFile}`; - - // Mock to return true for git ignore check, false for gemini ignore check - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - _path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => options?.respectGitIgnore === true, + const gitFile = await createTestFile( + path.join(testRootDir, '.git', 'config'), + '[core]\n\trepositoryformatversion = 0\n', ); + const query = `@${gitFile}`; const result = await handleAtCommand({ query, @@ -844,27 +540,16 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - // Should be called twice - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 2, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - gitFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${gitFile} is git-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGit-ignored: .git/config', + `Ignored 1 files:\nGit-ignored: ${gitFile}`, ); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); }); }); @@ -877,10 +562,6 @@ describe('handleAtCommand', () => { const invalidFile = 'nonexistent.txt'; const query = `@${invalidFile}`; - vi.mocked(fsPromises.stat).mockRejectedValue( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - const result = await handleAtCommand({ query, config: mockConfig, @@ -890,7 +571,6 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - expect(mockGlobExecute).not.toHaveBeenCalled(); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Glob tool not found. Path ${invalidFile} will be skipped.`, ); @@ -901,24 +581,15 @@ describe('handleAtCommand', () => { describe('gemini-ignore filtering', () => { it('should skip gemini-ignored files in @ commands', async () => { - const geminiIgnoredFile = 'build/output.js'; - const query = `@${geminiIgnoredFile}`; - - // Mock the file discovery service to report this file as gemini-ignored - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path !== geminiIgnoredFile) return false; - if (options?.respectGeminiIgnore) return true; - if (options?.respectGitIgnore) return false; - return false; - }, + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'build/output.js', ); + const geminiIgnoredFile = await createTestFile( + path.join(testRootDir, 'build', 'output.js'), + 'console.log("Hello");', + ); + const query = `@${geminiIgnoredFile}`; const result = await handleAtCommand({ query, @@ -929,177 +600,90 @@ describe('handleAtCommand', () => { signal: abortController.signal, }); - // Should be called twice - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 2, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - geminiIgnoredFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - geminiIgnoredFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - + expect(result).toEqual({ + processedQuery: [{ text: query }], + shouldProceed: true, + }); expect(mockOnDebugMessage).toHaveBeenCalledWith( `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`, ); expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'No valid file paths found in @ commands to read.', + `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, ); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGemini-ignored: build/output.js', - ); - expect(mockReadManyFilesExecute).not.toHaveBeenCalled(); - expect(result.processedQuery).toEqual([{ text: query }]); - expect(result.shouldProceed).toBe(true); }); + }); + it('should process non-ignored files when .geminiignore is present', async () => { + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'build/output.js', + ); + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'index.ts'), + 'console.log("Hello world");', + ); + const query = `@${validFile}`; - it('should process non-ignored files when .geminiignore is present', async () => { - const validFile = 'src/index.ts'; - const query = `@${validFile}`; - const fileContent = 'console.log("Hello world")'; - - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - _path: string, - _options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => false, - ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 205, - signal: abortController.signal, - }); - - // Should be called twice - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 2, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - validFile, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 205, + signal: abortController.signal, + }); - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [validFile], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); - expect(result.processedQuery).toEqual([ + expect(result).toEqual({ + processedQuery: [ { text: `@${validFile}` }, { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, + { text: 'console.log("Hello world");' }, { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + ], + shouldProceed: true, }); + }); - it('should handle mixed gemini-ignored and valid files', async () => { - const validFile = 'src/main.ts'; - const geminiIgnoredFile = 'dist/bundle.js'; - const query = `@${validFile} @${geminiIgnoredFile}`; - const fileContent = '// Main application entry'; - - mockFileDiscoveryService.shouldIgnoreFile.mockImplementation( - ( - path: string, - options?: { - respectGitIgnore?: boolean; - respectGeminiIgnore?: boolean; - }, - ) => { - if (path === geminiIgnoredFile && options?.respectGeminiIgnore) { - return true; - } - if (options?.respectGitIgnore) { - return false; - } - return false; - }, - ); - mockReadManyFilesExecute.mockResolvedValue({ - llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`], - returnDisplay: 'Read 1 file.', - }); - - const result = await handleAtCommand({ - query, - config: mockConfig, - addItem: mockAddItem, - onDebugMessage: mockOnDebugMessage, - messageId: 206, - signal: abortController.signal, - }); - - // Should be called twice for each file - once for git ignore check and once for gemini ignore check - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledTimes( - 4, - ); - - // Verify both files were checked against both ignore types - [validFile, geminiIgnoredFile].forEach((file) => { - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - file, - { respectGitIgnore: true, respectGeminiIgnore: false }, - ); - expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith( - file, - { respectGitIgnore: false, respectGeminiIgnore: true }, - ); - }); - - expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Path ${validFile} resolved to file: ${validFile}`, - ); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`, - ); - expect(mockOnDebugMessage).toHaveBeenCalledWith( - 'Ignored 1 files:\nGemini-ignored: dist/bundle.js', - ); + it('should handle mixed gemini-ignored and valid files', async () => { + await createTestFile( + path.join(testRootDir, '.geminiignore'), + 'dist/bundle.js', + ); + const validFile = await createTestFile( + path.join(testRootDir, 'src', 'main.ts'), + '// Main application entry', + ); + const geminiIgnoredFile = await createTestFile( + path.join(testRootDir, 'dist', 'bundle.js'), + 'console.log("bundle");', + ); + const query = `@${validFile} @${geminiIgnoredFile}`; - expect(mockReadManyFilesExecute).toHaveBeenCalledWith( - { - paths: [validFile], - file_filtering_options: { - respect_git_ignore: true, - respect_gemini_ignore: true, - }, - }, - abortController.signal, - ); + const result = await handleAtCommand({ + query, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 206, + signal: abortController.signal, + }); - expect(result.processedQuery).toEqual([ + expect(result).toEqual({ + processedQuery: [ { text: `@${validFile} @${geminiIgnoredFile}` }, { text: '\n--- Content from referenced files ---' }, { text: `\nContent from @${validFile}:\n` }, - { text: fileContent }, + { text: '// Main application entry' }, { text: '\n--- End of content ---' }, - ]); - expect(result.shouldProceed).toBe(true); + ], + shouldProceed: true, }); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Path ${geminiIgnoredFile} is gemini-ignored and will be skipped.`, + ); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + `Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`, + ); }); + // }); }); |
