gemini-cli/packages/cli/src/ui/hooks/useAtCompletion.test.ts

381 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useAtCompletion } from './useAtCompletion.js';
import { Config, FileSearch } from '@google/gemini-cli-core';
import {
createTmpDir,
cleanupTmpDir,
FileSystemStructure,
} from '@google/gemini-cli-test-utils';
import { useState } from 'react';
import { 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<Suggestion[]>([]);
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,
})),
} 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(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'src/components/',
'file.txt',
'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/components/',
'src/components/Button.tsx',
'src/index.js',
]);
});
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',
]);
});
});
describe('UI State and Loading Behavior', () => {
it('should be in a loading state during initial file system crawl', async () => {
testRootDir = await createTmpDir({});
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
// It's initially true because the effect runs synchronously.
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 100ms', 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 100ms', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure);
// Spy on the search method to introduce an artificial delay
const originalSearch = FileSearch.prototype.search;
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
async function (...args) {
await new Promise((resolve) => setTimeout(resolve, 200));
return originalSearch.apply(this, args);
},
);
const { result, rerender } = renderHook(
({ pattern }) =>
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
{ initialProps: { pattern: 'a' } },
);
// Wait for the initial (slow) search to complete
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'a.txt',
]);
});
// Now, rerender to trigger the second search
rerender({ pattern: 'b' });
// Wait for the loading indicator to appear
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(true);
});
// Suggestions should be cleared while loading
expect(result.current.suggestions).toEqual([]);
// Wait for the final (slow) search to complete
await waitFor(
() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'b.txt',
]);
},
{ timeout: 1000 },
); // Increase timeout for the slow search
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 searchSpy = vi
.spyOn(FileSearch.prototype, 'search')
.mockImplementation(async (...args) => {
const delay = args[0] === 'a' ? 500 : 50;
await new Promise((resolve) => setTimeout(resolve, delay));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [args[0] as any];
});
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(searchSpy).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(searchSpy).toHaveBeenCalledWith('b', expect.any(Object));
vi.restoreAllMocks();
});
});
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);
});
});
});