summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.integration.test.ts')
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.integration.test.ts431
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);
+ });
});