diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.integration.test.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.integration.test.ts | 431 |
1 files changed, 410 insertions, 21 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts index f5864a58..705b2735 100644 --- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts @@ -9,8 +9,15 @@ import type { Mocked } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useCompletion } from './useCompletion.js'; import * as fs from 'fs/promises'; -import { FileDiscoveryService } from '@google/gemini-cli-core'; import { glob } from 'glob'; +import { CommandContext, SlashCommand } from '../commands/types.js'; +import { Config, FileDiscoveryService } from '@google/gemini-cli-core'; + +interface MockConfig { + getFileFilteringRespectGitIgnore: () => boolean; + getEnableRecursiveFileSearch: () => boolean; + getFileService: () => FileDiscoveryService | null; +} // Mock dependencies vi.mock('fs/promises'); @@ -29,23 +36,83 @@ vi.mock('glob'); describe('useCompletion git-aware filtering integration', () => { let mockFileDiscoveryService: Mocked<FileDiscoveryService>; - let mockConfig: { - fileFiltering?: { enabled?: boolean; respectGitignore?: boolean }; - }; + let mockConfig: MockConfig; + const testCwd = '/test/project'; const slashCommands = [ { name: 'help', description: 'Show help', action: vi.fn() }, { name: 'clear', description: 'Clear screen', action: vi.fn() }, ]; + // A minimal mock is sufficient for these tests. + const mockCommandContext = {} as CommandContext; + + const mockSlashCommands: SlashCommand[] = [ + { + name: 'help', + altName: '?', + description: 'Show help', + action: vi.fn(), + }, + { + name: 'clear', + description: 'Clear the screen', + action: vi.fn(), + }, + { + name: 'memory', + description: 'Manage memory', + // This command is a parent, no action. + subCommands: [ + { + name: 'show', + description: 'Show memory', + action: vi.fn(), + }, + { + name: 'add', + description: 'Add to memory', + action: vi.fn(), + }, + ], + }, + { + name: 'chat', + description: 'Manage chat history', + subCommands: [ + { + name: 'save', + description: 'Save chat', + action: vi.fn(), + }, + { + name: 'resume', + description: 'Resume a saved chat', + action: vi.fn(), + // This command provides its own argument completions + completion: vi + .fn() + .mockResolvedValue([ + 'my-chat-tag-1', + 'my-chat-tag-2', + 'my-channel', + ]), + }, + ], + }, + ]; + beforeEach(() => { mockFileDiscoveryService = { shouldGitIgnoreFile: vi.fn(), shouldGeminiIgnoreFile: vi.fn(), shouldIgnoreFile: vi.fn(), filterFiles: vi.fn(), - getGeminiIgnorePatterns: vi.fn(() => []), - }; + getGeminiIgnorePatterns: vi.fn(), + projectRoot: '', + gitIgnoreFilter: null, + geminiIgnoreFilter: null, + } as unknown as Mocked<FileDiscoveryService>; mockConfig = { getFileFilteringRespectGitIgnore: vi.fn(() => true), @@ -81,7 +148,14 @@ describe('useCompletion git-aware filtering integration', () => { ); const { result } = renderHook(() => - useCompletion('@d', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@d', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); // Wait for async operations to complete @@ -104,7 +178,7 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'dist', isDirectory: () => true }, { name: 'README.md', isDirectory: () => false }, { name: '.env', isDirectory: () => false }, - ] as Array<{ name: string; isDirectory: () => boolean }>); + ] as unknown as Awaited<ReturnType<typeof fs.readdir>>); // Mock git ignore service to ignore certain files mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( @@ -123,7 +197,14 @@ describe('useCompletion git-aware filtering integration', () => { ); const { result } = renderHook(() => - useCompletion('@', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); // Wait for async operations to complete @@ -182,7 +263,14 @@ describe('useCompletion git-aware filtering integration', () => { ); const { result } = renderHook(() => - useCompletion('@t', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@t', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); // Wait for async operations to complete @@ -206,15 +294,22 @@ describe('useCompletion git-aware filtering integration', () => { const mockConfigNoRecursive = { ...mockConfig, getEnableRecursiveFileSearch: vi.fn(() => false), - }; + } as unknown as Config; vi.mocked(fs.readdir).mockResolvedValue([ { name: 'data', isDirectory: () => true }, { name: 'dist', isDirectory: () => true }, - ] as Array<{ name: string; isDirectory: () => boolean }>); + ] as unknown as Awaited<ReturnType<typeof fs.readdir>>); renderHook(() => - useCompletion('@d', testCwd, true, slashCommands, mockConfigNoRecursive), + useCompletion( + '@d', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfigNoRecursive, + ), ); await act(async () => { @@ -232,10 +327,17 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'src', isDirectory: () => true }, { name: 'node_modules', isDirectory: () => true }, { name: 'README.md', isDirectory: () => false }, - ] as Array<{ name: string; isDirectory: () => boolean }>); + ] as unknown as Awaited<ReturnType<typeof fs.readdir>>); const { result } = renderHook(() => - useCompletion('@', testCwd, true, slashCommands, undefined), + useCompletion( + '@', + testCwd, + true, + slashCommands, + mockCommandContext, + undefined, + ), ); await act(async () => { @@ -257,12 +359,19 @@ describe('useCompletion git-aware filtering integration', () => { vi.mocked(fs.readdir).mockResolvedValue([ { name: 'src', isDirectory: () => true }, { name: 'README.md', isDirectory: () => false }, - ] as Array<{ name: string; isDirectory: () => boolean }>); + ] as unknown as Awaited<ReturnType<typeof fs.readdir>>); const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { result } = renderHook(() => - useCompletion('@', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); await act(async () => { @@ -283,7 +392,7 @@ describe('useCompletion git-aware filtering integration', () => { { name: 'component.tsx', isDirectory: () => false }, { name: 'temp.log', isDirectory: () => false }, { name: 'index.ts', isDirectory: () => false }, - ] as Array<{ name: string; isDirectory: () => boolean }>); + ] as unknown as Awaited<ReturnType<typeof fs.readdir>>); mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation( (path: string) => path.includes('.log'), @@ -298,7 +407,14 @@ describe('useCompletion git-aware filtering integration', () => { ); const { result } = renderHook(() => - useCompletion('@src/comp', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@src/comp', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); await act(async () => { @@ -316,7 +432,14 @@ describe('useCompletion git-aware filtering integration', () => { vi.mocked(glob).mockResolvedValue(globResults); const { result } = renderHook(() => - useCompletion('@s', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@s', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); await act(async () => { @@ -344,7 +467,14 @@ describe('useCompletion git-aware filtering integration', () => { vi.mocked(glob).mockResolvedValue(globResults); const { result } = renderHook(() => - useCompletion('@.', testCwd, true, slashCommands, mockConfig), + useCompletion( + '@.', + testCwd, + true, + slashCommands, + mockCommandContext, + mockConfig as Config, + ), ); await act(async () => { @@ -363,4 +493,263 @@ describe('useCompletion git-aware filtering integration', () => { { label: 'src/index.ts', value: 'src/index.ts' }, ]); }); + + it('should suggest top-level command names based on partial input', async () => { + const { result } = renderHook(() => + useCompletion( + '/mem', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'memory', value: 'memory', description: 'Manage memory' }, + ]); + expect(result.current.showSuggestions).toBe(true); + }); + + it('should suggest commands based on altName', async () => { + const { result } = renderHook(() => + useCompletion( + '/?', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'help', value: 'help', description: 'Show help' }, + ]); + }); + + it('should suggest sub-command names for a parent command', async () => { + const { result } = renderHook(() => + useCompletion( + '/memory a', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toEqual([ + { label: 'add', value: 'add', description: 'Add to memory' }, + ]); + }); + + it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { + const { result } = renderHook(() => + useCompletion( + '/memory ', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), + ); + }); + + it('should call the command.completion function for argument suggestions', async () => { + const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel']; + const mockCompletionFn = vi + .fn() + .mockImplementation(async (context: CommandContext, partialArg: string) => + availableTags.filter((tag) => tag.startsWith(partialArg)), + ); + + const mockCommandsWithFiltering = JSON.parse( + JSON.stringify(mockSlashCommands), + ) as SlashCommand[]; + + const chatCmd = mockCommandsWithFiltering.find( + (cmd) => cmd.name === 'chat', + ); + if (!chatCmd || !chatCmd.subCommands) { + throw new Error( + "Test setup error: Could not find the 'chat' command with subCommands in the mock data.", + ); + } + + const resumeCmd = chatCmd.subCommands.find((sc) => sc.name === 'resume'); + if (!resumeCmd) { + throw new Error( + "Test setup error: Could not find the 'resume' sub-command in the mock data.", + ); + } + + resumeCmd.completion = mockCompletionFn; + + const { result } = renderHook(() => + useCompletion( + '/chat resume my-ch', + '/test/cwd', + true, + mockCommandsWithFiltering, + mockCommandContext, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, 'my-ch'); + + expect(result.current.suggestions).toEqual([ + { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, + { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, + ]); + }); + + it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => { + const { result } = renderHook(() => + useCompletion( + '/clear ', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + + it('should not provide suggestions for an unknown command', async () => { + const { result } = renderHook(() => + useCompletion( + '/unknown-command', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + + it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => { + const { result } = renderHook(() => + useCompletion( + '/memory', // Note: no trailing space + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + // Assert that suggestions for sub-commands are shown immediately + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { label: 'show', value: 'show', description: 'Show memory' }, + { label: 'add', value: 'add', description: 'Add to memory' }, + ]), + ); + expect(result.current.showSuggestions).toBe(true); + }); + + it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { + const { result } = renderHook(() => + useCompletion( + '/clear', // No trailing space + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); + + it('should call command.completion with an empty string when args start with a space', async () => { + const mockCompletionFn = vi + .fn() + .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']); + + const isolatedMockCommands = JSON.parse( + JSON.stringify(mockSlashCommands), + ) as SlashCommand[]; + + const resumeCommand = isolatedMockCommands + .find((cmd) => cmd.name === 'chat') + ?.subCommands?.find((cmd) => cmd.name === 'resume'); + + if (!resumeCommand) { + throw new Error( + 'Test setup failed: could not find resume command in mock', + ); + } + resumeCommand.completion = mockCompletionFn; + + const { result } = renderHook(() => + useCompletion( + '/chat resume ', // Trailing space, no partial argument + '/test/cwd', + true, + isolatedMockCommands, + mockCommandContext, + ), + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, ''); + expect(result.current.suggestions).toHaveLength(3); + expect(result.current.showSuggestions).toBe(true); + }); + + it('should suggest all top-level commands for the root slash', async () => { + const { result } = renderHook(() => + useCompletion( + '/', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions.length).toBe(mockSlashCommands.length); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['help', 'clear', 'memory', 'chat']), + ); + }); + + it('should provide no suggestions for an invalid sub-command', async () => { + const { result } = renderHook(() => + useCompletion( + '/memory dothisnow', + '/test/cwd', + true, + mockSlashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.showSuggestions).toBe(false); + }); }); |
