/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { act, useState } from 'react'; import * as path from 'node:path'; import { renderHook } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { useAtCompletion } from './useAtCompletion.js'; import type { Config, FileSearch } from '@google/gemini-cli-core'; import { FileSearchFactory, FileDiscoveryService, escapePath, } from '@google/gemini-cli-core'; import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; // Test harness to capture the state from the hook's callbacks. function useTestHarnessForAtCompletion( enabled: boolean, pattern: string, config: Config | undefined, cwd: string, ) { const [suggestions, setSuggestions] = useState([]); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); useAtCompletion({ enabled, pattern, config, cwd, setSuggestions, setIsLoadingSuggestions, }); return { suggestions, isLoadingSuggestions }; } describe('useAtCompletion', () => { let testRootDir: string; let mockConfig: Config; beforeEach(() => { mockConfig = { getFileFilteringOptions: vi.fn(() => ({ respectGitIgnore: true, respectGeminiIgnore: true, })), getEnableRecursiveFileSearch: () => true, getFileFilteringEnableFuzzySearch: () => true, getResourceRegistry: vi.fn().mockReturnValue({ getAllResources: () => [], }), } as unknown as Config; vi.clearAllMocks(); }); afterEach(async () => { if (testRootDir) { await cleanupTmpDir(testRootDir); } vi.restoreAllMocks(); }); describe('File Search Logic', () => { it('should perform a recursive search for an empty pattern', async () => { const structure: FileSystemStructure = { 'file.txt': '', src: { 'index.js': '', components: ['Button.tsx', 'Button with spaces.tsx'], }, }; testRootDir = await createTmpDir(structure); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(5); }); expect(result.current.suggestions.length).toBeGreaterThan(0); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', 'src/components/', 'file.txt', `${escapePath('src/components/Button with spaces.tsx')}`, 'src/components/Button.tsx', 'src/index.js', ]); }); it('should correctly filter the recursive list based on a pattern', async () => { const structure: FileSystemStructure = { 'file.txt': '', src: { 'index.js': '', components: { 'Button.tsx': '', }, }, }; testRootDir = await createTmpDir(structure); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', 'src/index.js', 'src/components/', 'src/components/Button.tsx', ]); }); it('should append a trailing slash to directory paths in suggestions', async () => { const structure: FileSystemStructure = { 'file.txt': '', dir: {}, }; testRootDir = await createTmpDir(structure); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'dir/', 'file.txt', ]); }); it('should perform a case-insensitive search by lowercasing the pattern', async () => { testRootDir = await createTmpDir({ 'cRaZycAsE.txt': '' }); const fileSearch = FileSearchFactory.create({ projectRoot: testRootDir, ignoreDirs: [], fileDiscoveryService: new FileDiscoveryService(testRootDir, { respectGitIgnore: false, respectGeminiIgnore: false, }), cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, enableFuzzySearch: true, }); await fileSearch.initialize(); vi.spyOn(FileSearchFactory, 'create').mockReturnValue(fileSearch); const { result } = renderHook(() => useTestHarnessForAtCompletion( true, 'CrAzYCaSe', mockConfig, testRootDir, ), ); // The hook should find 'cRaZycAsE.txt' even though the pattern is 'CrAzYCaSe'. await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'cRaZycAsE.txt', ]); }); }); }); describe('MCP resource suggestions', () => { it('should include MCP resources in the suggestion list using fuzzy matching', async () => { mockConfig.getResourceRegistry = vi.fn().mockReturnValue({ getAllResources: () => [ { serverName: 'server-1', uri: 'file:///tmp/server-1/logs.txt', name: 'logs', discoveredAt: Date.now(), }, ], }); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, 'logs', mockConfig, '/tmp'), ); await waitFor(() => { expect( result.current.suggestions.some( (suggestion) => suggestion.value === 'server-1:file:///tmp/server-1/logs.txt', ), ).toBe(true); }); }); }); describe('UI State and Loading Behavior', () => { it('should be in a loading state during initial file system crawl', async () => { testRootDir = await createTmpDir({}); // Mock FileSearch to be slow to catch the loading state const mockFileSearch = { initialize: vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }), search: vi.fn().mockResolvedValue([]), }; vi.spyOn(FileSearchFactory, 'create').mockReturnValue( mockFileSearch as unknown as FileSearch, ); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); // It's initially true because the effect runs synchronously. await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(true); }); // Wait for the loading to complete. await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); }); it('should NOT show a loading indicator for subsequent searches that complete under 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); const { result, rerender } = renderHook( ({ pattern }) => useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), { initialProps: { pattern: 'a' } }, ); await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); }); expect(result.current.isLoadingSuggestions).toBe(false); rerender({ pattern: 'b' }); // Wait for the final result await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); }); expect(result.current.isLoadingSuggestions).toBe(false); }); it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); const realFileSearch = FileSearchFactory.create({ projectRoot: testRootDir, ignoreDirs: [], fileDiscoveryService: new FileDiscoveryService(testRootDir, { respectGitIgnore: true, respectGeminiIgnore: true, }), cache: false, cacheTtl: 0, enableRecursiveFileSearch: true, enableFuzzySearch: true, }); await realFileSearch.initialize(); // Mock that returns results immediately but we'll control timing with fake timers const mockFileSearch: FileSearch = { initialize: vi.fn().mockResolvedValue(undefined), search: vi .fn() .mockImplementation(async (pattern, options) => realFileSearch.search(pattern, options), ), }; vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch); const { result, rerender } = renderHook( ({ pattern }) => useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), { initialProps: { pattern: 'a' } }, ); // Wait for the initial search to complete (using real timers) await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); }); // Now switch to fake timers for precise control of the loading behavior vi.useFakeTimers(); // Trigger the second search act(() => { rerender({ pattern: 'b' }); }); // Initially, loading should be false (before 200ms timer) expect(result.current.isLoadingSuggestions).toBe(false); // Advance time by exactly 200ms to trigger the loading state act(() => { vi.advanceTimersByTime(200); }); // Now loading should be true and suggestions should be cleared expect(result.current.isLoadingSuggestions).toBe(true); expect(result.current.suggestions).toEqual([]); // Switch back to real timers for the final waitFor vi.useRealTimers(); // Wait for the search results to be processed await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); }); expect(result.current.isLoadingSuggestions).toBe(false); }); it('should abort the previous search when a new one starts', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); const mockFileSearch: FileSearch = { initialize: vi.fn().mockResolvedValue(undefined), search: vi.fn().mockImplementation(async (pattern: string) => { const delay = pattern === 'a' ? 500 : 50; await new Promise((resolve) => setTimeout(resolve, delay)); return [pattern]; }), }; vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch); const { result, rerender } = renderHook( ({ pattern }) => useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir), { initialProps: { pattern: 'a' } }, ); // Wait for the hook to be ready (initialization is complete) await waitFor(() => { expect(mockFileSearch.search).toHaveBeenCalledWith( 'a', expect.any(Object), ); }); // Now that the first search is in-flight, trigger the second one. act(() => { rerender({ pattern: 'b' }); }); // The abort should have been called for the first search. expect(abortSpy).toHaveBeenCalledTimes(1); // Wait for the final result, which should be from the second, faster search. await waitFor( () => { expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']); }, { timeout: 1000 }, ); // The search spy should have been called for both patterns. expect(mockFileSearch.search).toHaveBeenCalledWith( 'b', expect.any(Object), ); }); }); describe('State Management', () => { it('should reset the state when disabled after being in a READY state', async () => { const structure: FileSystemStructure = { 'a.txt': '' }; testRootDir = await createTmpDir(structure); const { result, rerender } = renderHook( ({ enabled }) => useTestHarnessForAtCompletion(enabled, 'a', mockConfig, testRootDir), { initialProps: { enabled: true } }, ); // Wait for the hook to be ready and have suggestions await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); }); // Now, disable the hook rerender({ enabled: false }); // The suggestions should be cleared immediately because of the RESET action expect(result.current.suggestions).toEqual([]); }); it('should reset the state when disabled after being in an ERROR state', async () => { testRootDir = await createTmpDir({}); // Force an error during initialization const mockFileSearch: FileSearch = { initialize: vi .fn() .mockRejectedValue(new Error('Initialization failed')), search: vi.fn(), }; vi.spyOn(FileSearchFactory, 'create').mockReturnValue(mockFileSearch); const { result, rerender } = renderHook( ({ enabled }) => useTestHarnessForAtCompletion(enabled, '', mockConfig, testRootDir), { initialProps: { enabled: true } }, ); // Wait for the hook to enter the error state await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); expect(result.current.suggestions).toEqual([]); // No suggestions on error // Now, disable the hook rerender({ enabled: false }); // The state should still be reset (though visually it's the same) // We can't directly inspect the internal state, but we can ensure it doesn't crash // and the suggestions remain empty. expect(result.current.suggestions).toEqual([]); }); }); describe('Filtering and Configuration', () => { it('should respect .gitignore files', async () => { const gitignoreContent = ['dist/', '*.log'].join('\n'); const structure: FileSystemStructure = { '.git': {}, '.gitignore': gitignoreContent, dist: {}, 'test.log': '', src: {}, }; testRootDir = await createTmpDir(structure); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', '.gitignore', ]); }); it('should work correctly when config is undefined', async () => { const structure: FileSystemStructure = { node_modules: {}, src: {}, }; testRootDir = await createTmpDir(structure); const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', undefined, testRootDir), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'node_modules/', 'src/', ]); }); it('should reset and re-initialize when the cwd changes', async () => { const structure1: FileSystemStructure = { 'file1.txt': '' }; const rootDir1 = await createTmpDir(structure1); const structure2: FileSystemStructure = { 'file2.txt': '' }; const rootDir2 = await createTmpDir(structure2); const { result, rerender } = renderHook( ({ cwd, pattern }) => useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd), { initialProps: { cwd: rootDir1, pattern: 'file', }, }, ); // Wait for initial suggestions from the first directory await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file1.txt', ]); }); // Change the CWD act(() => { rerender({ cwd: rootDir2, pattern: 'file' }); }); // After CWD changes, suggestions should be cleared and it should load again. await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(true); expect(result.current.suggestions).toEqual([]); }); // Wait for the new suggestions from the second directory await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file2.txt', ]); }); expect(result.current.isLoadingSuggestions).toBe(false); await cleanupTmpDir(rootDir1); await cleanupTmpDir(rootDir2); }); it('should perform a non-recursive search when enableRecursiveFileSearch is false', async () => { const structure: FileSystemStructure = { 'file.txt': '', src: { 'index.js': '', }, }; testRootDir = await createTmpDir(structure); const nonRecursiveConfig = { getEnableRecursiveFileSearch: () => false, getFileFilteringOptions: vi.fn(() => ({ respectGitIgnore: true, respectGeminiIgnore: true, })), getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; const { result } = renderHook(() => useTestHarnessForAtCompletion( true, '', nonRecursiveConfig, testRootDir, ), ); await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); // Should only contain top-level items expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', 'file.txt', ]); }); }); describe('Multi-directory workspace support', () => { const multiDirTmpDirs: string[] = []; afterEach(async () => { await Promise.all(multiDirTmpDirs.map((dir) => cleanupTmpDir(dir))); multiDirTmpDirs.length = 0; }); it('should include files from workspace directories beyond cwd', async () => { const cwdStructure: FileSystemStructure = { 'main.txt': '' }; const addedDirStructure: FileSystemStructure = { 'added-file.txt': '' }; const cwdDir = await createTmpDir(cwdStructure); multiDirTmpDirs.push(cwdDir); const addedDir = await createTmpDir(addedDirStructure); multiDirTmpDirs.push(addedDir); const multiDirConfig = { ...mockConfig, getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: () => [cwdDir, addedDir], onDirectoriesChanged: vi.fn(() => () => {}), }), } as unknown as Config; const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', multiDirConfig, cwdDir), ); await waitFor(() => { const values = result.current.suggestions.map((s) => s.value); expect(values).toContain('main.txt'); expect(values).toContain( escapePath(path.join(addedDir, 'added-file.txt')), ); }); }); it('should pick up newly added directories via onDirectoriesChanged', async () => { const cwdStructure: FileSystemStructure = { 'original.txt': '' }; const addedStructure: FileSystemStructure = { 'new-file.txt': '' }; const cwdDir = await createTmpDir(cwdStructure); multiDirTmpDirs.push(cwdDir); const addedDir = await createTmpDir(addedStructure); multiDirTmpDirs.push(addedDir); let dirChangeListener: (() => void) | null = null; const directories = [cwdDir]; const dynamicConfig = { ...mockConfig, getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: () => [...directories], onDirectoriesChanged: vi.fn((listener: () => void) => { dirChangeListener = listener; return () => { dirChangeListener = null; }; }), }), } as unknown as Config; const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', dynamicConfig, cwdDir), ); await waitFor(() => { const values = result.current.suggestions.map((s) => s.value); expect(values).toContain('original.txt'); expect(values.every((v) => !v.includes('new-file.txt'))).toBe(true); }); directories.push(addedDir); act(() => { dirChangeListener?.(); }); await waitFor(() => { const values = result.current.suggestions.map((s) => s.value); expect(values).toContain( escapePath(path.join(addedDir, 'new-file.txt')), ); }); }); it('should show same-named files from different directories without false deduplication', async () => { const dir1Structure: FileSystemStructure = { 'readme.md': '' }; const dir2Structure: FileSystemStructure = { 'readme.md': '' }; const dir1 = await createTmpDir(dir1Structure); multiDirTmpDirs.push(dir1); const dir2 = await createTmpDir(dir2Structure); multiDirTmpDirs.push(dir2); const multiDirConfig = { ...mockConfig, getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: () => [dir1, dir2], onDirectoriesChanged: vi.fn(() => () => {}), }), } as unknown as Config; const { result } = renderHook(() => useTestHarnessForAtCompletion(true, 'readme', multiDirConfig, dir1), ); await waitFor(() => { const values = result.current.suggestions.map((s) => s.value); const readmeEntries = values.filter((v) => v.includes('readme.md')); expect(readmeEntries.length).toBe(2); expect(readmeEntries).toContain('readme.md'); expect(readmeEntries).toContain( escapePath(path.join(dir2, 'readme.md')), ); }); }); }); });