1248 lines
36 KiB
TypeScript
1248 lines
36 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, act } from '@testing-library/react';
|
|
import { useCompletion } from './useCompletion.js';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
|
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
|
import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js';
|
|
|
|
describe('useCompletion', () => {
|
|
let testRootDir: string;
|
|
let mockConfig: Config;
|
|
|
|
// A minimal mock is sufficient for these tests.
|
|
const mockCommandContext = {} as CommandContext;
|
|
|
|
async function createEmptyDir(...pathSegments: string[]) {
|
|
const fullPath = path.join(testRootDir, ...pathSegments);
|
|
await fs.mkdir(fullPath, { recursive: true });
|
|
return fullPath;
|
|
}
|
|
|
|
async function createTestFile(content: string, ...pathSegments: string[]) {
|
|
const fullPath = path.join(testRootDir, ...pathSegments);
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(fullPath, content);
|
|
return fullPath;
|
|
}
|
|
|
|
// Helper to create real TextBuffer objects within renderHook
|
|
function useTextBufferForTest(text: string) {
|
|
return useTextBuffer({
|
|
initialText: text,
|
|
initialCursorOffset: text.length,
|
|
viewport: { width: 80, height: 20 },
|
|
isValidPath: () => false,
|
|
onChange: () => {},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
testRootDir = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'completion-unit-test-'),
|
|
);
|
|
mockConfig = {
|
|
getTargetDir: () => testRootDir,
|
|
getProjectRoot: () => testRootDir,
|
|
getFileFilteringOptions: vi.fn(() => ({
|
|
respectGitIgnore: true,
|
|
respectGeminiIgnore: true,
|
|
})),
|
|
getEnableRecursiveFileSearch: vi.fn(() => true),
|
|
getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)),
|
|
} as unknown as Config;
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await fs.rm(testRootDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('Core Hook Behavior', () => {
|
|
describe('State Management', () => {
|
|
it('should initialize with default state', () => {
|
|
const slashCommands = [
|
|
{ name: 'dummy', description: 'dummy' },
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest(''),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toEqual([]);
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
expect(result.current.visibleStartIndex).toBe(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
|
});
|
|
|
|
it('should reset state when isActive becomes false', () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
altNames: ['?'],
|
|
description: 'Show help',
|
|
action: vi.fn(),
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ text }) => {
|
|
const textBuffer = useTextBufferForTest(text);
|
|
return useCompletion(
|
|
textBuffer,
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
);
|
|
},
|
|
{ initialProps: { text: '/help' } },
|
|
);
|
|
|
|
// Inactive because of the leading space
|
|
rerender({ text: ' /help' });
|
|
|
|
expect(result.current.suggestions).toEqual([]);
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
expect(result.current.visibleStartIndex).toBe(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
|
});
|
|
|
|
it('should reset all state to default values', () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
description: 'Show help',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/help'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.setActiveSuggestionIndex(5);
|
|
result.current.setShowSuggestions(true);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.resetCompletionState();
|
|
});
|
|
|
|
expect(result.current.suggestions).toEqual([]);
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
expect(result.current.visibleStartIndex).toBe(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Navigation', () => {
|
|
it('should handle navigateUp with no suggestions', () => {
|
|
const slashCommands = [
|
|
{ name: 'dummy', description: 'dummy' },
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest(''),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
});
|
|
|
|
it('should handle navigateDown with no suggestions', () => {
|
|
const slashCommands = [
|
|
{ name: 'dummy', description: 'dummy' },
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest(''),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.navigateDown();
|
|
});
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
});
|
|
|
|
it('should navigate up through suggestions with wrap-around', () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
description: 'Show help',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/h'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.length).toBe(1);
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
});
|
|
|
|
it('should navigate down through suggestions with wrap-around', () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
description: 'Show help',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/h'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.length).toBe(1);
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.navigateDown();
|
|
});
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
});
|
|
|
|
it('should handle navigation with multiple suggestions', () => {
|
|
const slashCommands = [
|
|
{ name: 'help', description: 'Show help' },
|
|
{ name: 'stats', description: 'Show stats' },
|
|
{ name: 'clear', description: 'Clear screen' },
|
|
{ name: 'memory', description: 'Manage memory' },
|
|
{ name: 'chat', description: 'Manage chat' },
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.length).toBe(5);
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.navigateDown();
|
|
});
|
|
expect(result.current.activeSuggestionIndex).toBe(1);
|
|
|
|
act(() => {
|
|
result.current.navigateDown();
|
|
});
|
|
expect(result.current.activeSuggestionIndex).toBe(2);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
expect(result.current.activeSuggestionIndex).toBe(1);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
expect(result.current.activeSuggestionIndex).toBe(4);
|
|
});
|
|
|
|
it('should handle navigation with large suggestion lists and scrolling', () => {
|
|
const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({
|
|
name: `command${i}`,
|
|
description: `Command ${i}`,
|
|
})) as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/command'),
|
|
testRootDir,
|
|
largeMockCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.length).toBe(15);
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
expect(result.current.visibleStartIndex).toBe(0);
|
|
|
|
act(() => {
|
|
result.current.navigateUp();
|
|
});
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(14);
|
|
expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Slash Command Completion (`/`)', () => {
|
|
describe('Top-Level Commands', () => {
|
|
it('should suggest all top-level commands for the root slash', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
altNames: ['?'],
|
|
description: 'Show help',
|
|
},
|
|
{
|
|
name: 'stats',
|
|
altNames: ['usage'],
|
|
description: 'check session stats. Usage: /stats [model|tools]',
|
|
},
|
|
{
|
|
name: 'clear',
|
|
description: 'Clear the screen',
|
|
},
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: 'chat',
|
|
description: 'Manage chat history',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.length).toBe(slashCommands.length);
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
|
|
);
|
|
});
|
|
|
|
it('should filter commands based on partial input', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/mem'),
|
|
testRootDir,
|
|
slashCommands,
|
|
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 partial altNames', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'stats',
|
|
altNames: ['usage'],
|
|
description: 'check session stats. Usage: /stats [model|tools]',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/usag'), // part of the word "usage"
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toEqual([
|
|
{
|
|
label: 'stats',
|
|
value: 'stats',
|
|
description: 'check session stats. Usage: /stats [model|tools]',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'clear',
|
|
description: 'Clear the screen',
|
|
action: vi.fn(),
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/clear'), // No trailing space
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
});
|
|
|
|
it.each([['/?'], ['/usage']])(
|
|
'should not suggest commands when altNames is fully typed',
|
|
async (query) => {
|
|
const mockSlashCommands = [
|
|
{
|
|
name: 'help',
|
|
altNames: ['?'],
|
|
description: 'Show help',
|
|
action: vi.fn(),
|
|
},
|
|
{
|
|
name: 'stats',
|
|
altNames: ['usage'],
|
|
description: 'check session stats. Usage: /stats [model|tools]',
|
|
action: vi.fn(),
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest(query),
|
|
testRootDir,
|
|
mockSlashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
},
|
|
);
|
|
|
|
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'clear',
|
|
description: 'Clear the screen',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/clear '),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
});
|
|
|
|
it('should not provide suggestions for an unknown command', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'help',
|
|
description: 'Show help',
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/unknown-command'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Sub-Commands', () => {
|
|
it('should suggest sub-commands for a parent command', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/memory'), // Note: no trailing space
|
|
testRootDir,
|
|
slashCommands,
|
|
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 suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/memory'),
|
|
testRootDir,
|
|
slashCommands,
|
|
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 filter sub-commands by prefix', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/memory a'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toEqual([
|
|
{ label: 'add', value: 'add', description: 'Add to memory' },
|
|
]);
|
|
});
|
|
|
|
it('should provide no suggestions for an invalid sub-command', async () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/memory dothisnow'),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Argument Completion', () => {
|
|
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 slashCommands = [
|
|
{
|
|
name: 'chat',
|
|
description: 'Manage chat history',
|
|
subCommands: [
|
|
{
|
|
name: 'resume',
|
|
description: 'Resume a saved chat',
|
|
completion: mockCompletionFn,
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/chat resume my-ch'),
|
|
testRootDir,
|
|
slashCommands,
|
|
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 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 slashCommands = [
|
|
{
|
|
name: 'chat',
|
|
description: 'Manage chat history',
|
|
subCommands: [
|
|
{
|
|
name: 'resume',
|
|
description: 'Resume a saved chat',
|
|
completion: mockCompletionFn,
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/chat resume '),
|
|
testRootDir,
|
|
slashCommands,
|
|
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 handle completion function that returns null', async () => {
|
|
const completionFn = vi.fn().mockResolvedValue(null);
|
|
const slashCommands = [
|
|
{
|
|
name: 'chat',
|
|
description: 'Manage chat history',
|
|
subCommands: [
|
|
{
|
|
name: 'resume',
|
|
description: 'Resume a saved chat',
|
|
completion: completionFn,
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('/chat resume '),
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('File Path Completion (`@`)', () => {
|
|
describe('Basic Completion', () => {
|
|
it('should use glob for top-level @ completions when available', async () => {
|
|
await createTestFile('', 'src', 'index.ts');
|
|
await createTestFile('', 'derp', 'script.ts');
|
|
await createTestFile('', 'README.md');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@s'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
expect(result.current.suggestions).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
label: 'derp/script.ts',
|
|
value: 'derp/script.ts',
|
|
},
|
|
{ label: 'src', value: 'src' },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should handle directory-specific completions with git filtering', async () => {
|
|
await createEmptyDir('.git');
|
|
await createTestFile('*.log', '.gitignore');
|
|
await createTestFile('', 'src', 'component.tsx');
|
|
await createTestFile('', 'src', 'temp.log');
|
|
await createTestFile('', 'src', 'index.ts');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@src/comp'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
// Should filter out .log files but include matching .tsx files
|
|
expect(result.current.suggestions).toEqual([
|
|
{ label: 'component.tsx', value: 'component.tsx' },
|
|
]);
|
|
});
|
|
|
|
it('should include dotfiles in glob search when input starts with a dot', async () => {
|
|
await createTestFile('', '.env');
|
|
await createTestFile('', '.gitignore');
|
|
await createTestFile('', 'src', 'index.ts');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@.'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
expect(result.current.suggestions).toEqual([
|
|
{ label: '.env', value: '.env' },
|
|
{ label: '.gitignore', value: '.gitignore' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Configuration-based Behavior', () => {
|
|
it('should not perform recursive search when disabled in config', async () => {
|
|
const mockConfigNoRecursive = {
|
|
...mockConfig,
|
|
getEnableRecursiveFileSearch: vi.fn(() => false),
|
|
} as unknown as Config;
|
|
|
|
await createEmptyDir('data');
|
|
await createEmptyDir('dist');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@d'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfigNoRecursive,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
expect(result.current.suggestions).toEqual([
|
|
{ label: 'data/', value: 'data/' },
|
|
{ label: 'dist/', value: 'dist/' },
|
|
]);
|
|
});
|
|
|
|
it('should work without config (fallback behavior)', async () => {
|
|
await createEmptyDir('src');
|
|
await createEmptyDir('node_modules');
|
|
await createTestFile('', 'README.md');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
undefined,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
// Without config, should include all files
|
|
expect(result.current.suggestions).toHaveLength(3);
|
|
expect(result.current.suggestions).toEqual(
|
|
expect.arrayContaining([
|
|
{ label: 'src/', value: 'src/' },
|
|
{ label: 'node_modules/', value: 'node_modules/' },
|
|
{ label: 'README.md', value: 'README.md' },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should handle git discovery service initialization failure gracefully', async () => {
|
|
// Intentionally don't create a .git directory to cause an initialization failure.
|
|
await createEmptyDir('src');
|
|
await createTestFile('', 'README.md');
|
|
|
|
const consoleSpy = vi
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation(() => {});
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
// Since we use centralized service, initialization errors are handled at config level
|
|
// This test should verify graceful fallback behavior
|
|
expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
|
|
// Should still show completions even if git discovery fails
|
|
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Git-Aware Filtering', () => {
|
|
it('should filter git-ignored entries from @ completions', async () => {
|
|
await createEmptyDir('.git');
|
|
await createTestFile('dist', '.gitignore');
|
|
await createEmptyDir('data');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@d'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
// Wait for async operations to complete
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
|
});
|
|
|
|
expect(result.current.suggestions).toEqual(
|
|
expect.arrayContaining([{ label: 'data', value: 'data' }]),
|
|
);
|
|
expect(result.current.showSuggestions).toBe(true);
|
|
});
|
|
|
|
it('should filter git-ignored directories from @ completions', async () => {
|
|
await createEmptyDir('.git');
|
|
await createTestFile('node_modules\ndist\n.env', '.gitignore');
|
|
// gitignored entries
|
|
await createEmptyDir('node_modules');
|
|
await createEmptyDir('dist');
|
|
await createTestFile('', '.env');
|
|
|
|
// visible
|
|
await createEmptyDir('src');
|
|
await createTestFile('', 'README.md');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
// Wait for async operations to complete
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
|
});
|
|
|
|
expect(result.current.suggestions).toEqual([
|
|
{ label: 'README.md', value: 'README.md' },
|
|
{ label: 'src/', value: 'src/' },
|
|
]);
|
|
expect(result.current.showSuggestions).toBe(true);
|
|
});
|
|
|
|
it('should handle recursive search with git-aware filtering', async () => {
|
|
await createEmptyDir('.git');
|
|
await createTestFile('node_modules/\ntemp/', '.gitignore');
|
|
await createTestFile('', 'data', 'test.txt');
|
|
await createEmptyDir('dist');
|
|
await createEmptyDir('node_modules');
|
|
await createTestFile('', 'src', 'index.ts');
|
|
await createEmptyDir('src', 'components');
|
|
await createTestFile('', 'temp', 'temp.log');
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
useTextBufferForTest('@t'),
|
|
testRootDir,
|
|
[],
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
});
|
|
|
|
// Should not include anything from node_modules or dist
|
|
const suggestionLabels = result.current.suggestions.map((s) => s.label);
|
|
expect(suggestionLabels).not.toContain('temp/');
|
|
expect(suggestionLabels).not.toContain('node_modules/');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleAutocomplete', () => {
|
|
it('should complete a partial command', () => {
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
// Create a mock buffer that we can spy on directly
|
|
const mockBuffer = {
|
|
text: '/mem',
|
|
setText: vi.fn(),
|
|
} as unknown as TextBuffer;
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
mockBuffer,
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
|
'memory',
|
|
]);
|
|
|
|
act(() => {
|
|
result.current.handleAutocomplete(0);
|
|
});
|
|
|
|
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory ');
|
|
});
|
|
|
|
it('should append a sub-command when the parent is complete', () => {
|
|
const mockBuffer = {
|
|
text: '/memory',
|
|
setText: vi.fn(),
|
|
} as unknown as TextBuffer;
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
mockBuffer,
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
// Suggestions are populated by useEffect
|
|
expect(result.current.suggestions.map((s) => s.value)).toEqual([
|
|
'show',
|
|
'add',
|
|
]);
|
|
|
|
act(() => {
|
|
result.current.handleAutocomplete(1); // index 1 is 'add'
|
|
});
|
|
|
|
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add ');
|
|
});
|
|
|
|
it('should complete a command with an alternative name', () => {
|
|
const mockBuffer = {
|
|
text: '/?',
|
|
setText: vi.fn(),
|
|
} as unknown as TextBuffer;
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
mockBuffer,
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
result.current.suggestions.push({
|
|
label: 'help',
|
|
value: 'help',
|
|
description: 'Show help',
|
|
});
|
|
|
|
act(() => {
|
|
result.current.handleAutocomplete(0);
|
|
});
|
|
|
|
expect(mockBuffer.setText).toHaveBeenCalledWith('/help ');
|
|
});
|
|
|
|
it('should complete a file path', async () => {
|
|
const mockBuffer = {
|
|
text: '@src/fi',
|
|
lines: ['@src/fi'],
|
|
cursor: [0, 7],
|
|
setText: vi.fn(),
|
|
replaceRangeByOffset: vi.fn(),
|
|
} as unknown as TextBuffer;
|
|
const slashCommands = [
|
|
{
|
|
name: 'memory',
|
|
description: 'Manage memory',
|
|
subCommands: [
|
|
{
|
|
name: 'show',
|
|
description: 'Show memory',
|
|
},
|
|
{
|
|
name: 'add',
|
|
description: 'Add to memory',
|
|
},
|
|
],
|
|
},
|
|
] as unknown as SlashCommand[];
|
|
|
|
const { result } = renderHook(() =>
|
|
useCompletion(
|
|
mockBuffer,
|
|
testRootDir,
|
|
slashCommands,
|
|
mockCommandContext,
|
|
mockConfig,
|
|
),
|
|
);
|
|
|
|
result.current.suggestions.push({
|
|
label: 'file1.txt',
|
|
value: 'file1.txt',
|
|
});
|
|
|
|
act(() => {
|
|
result.current.handleAutocomplete(0);
|
|
});
|
|
|
|
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
|
|
5, // after '@src/'
|
|
mockBuffer.text.length,
|
|
'file1.txt',
|
|
);
|
|
});
|
|
});
|
|
});
|