2025-07-14 14:10:26 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2025-07-18 00:55:29 -04:00
|
|
|
/** @vitest-environment jsdom */
|
|
|
|
|
|
2025-07-14 14:10:26 -07:00
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
|
|
import type { Mocked } from 'vitest';
|
|
|
|
|
import { renderHook, act } from '@testing-library/react';
|
|
|
|
|
import { useCompletion } from './useCompletion.js';
|
|
|
|
|
import * as fs from 'fs/promises';
|
|
|
|
|
import { glob } from 'glob';
|
2025-07-22 00:34:55 -04:00
|
|
|
import {
|
|
|
|
|
CommandContext,
|
|
|
|
|
CommandKind,
|
|
|
|
|
SlashCommand,
|
|
|
|
|
} from '../commands/types.js';
|
2025-07-14 14:10:26 -07:00
|
|
|
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
2025-07-24 21:41:35 -07:00
|
|
|
import { useTextBuffer } from '../components/shared/text-buffer.js';
|
|
|
|
|
|
|
|
|
|
// Helper to create real TextBuffer objects within renderHook
|
|
|
|
|
const useTextBufferForTest = (text: string) => {
|
|
|
|
|
const cursorOffset = text.length;
|
|
|
|
|
|
|
|
|
|
return useTextBuffer({
|
|
|
|
|
initialText: text,
|
|
|
|
|
initialCursorOffset: cursorOffset,
|
|
|
|
|
viewport: { width: 80, height: 20 },
|
|
|
|
|
isValidPath: () => false,
|
|
|
|
|
onChange: () => {},
|
|
|
|
|
});
|
|
|
|
|
};
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
// Mock dependencies
|
|
|
|
|
vi.mock('fs/promises');
|
|
|
|
|
vi.mock('glob');
|
|
|
|
|
vi.mock('@google/gemini-cli-core', async () => {
|
|
|
|
|
const actual = await vi.importActual('@google/gemini-cli-core');
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
FileDiscoveryService: vi.fn(),
|
|
|
|
|
isNodeError: vi.fn((error) => error.code === 'ENOENT'),
|
|
|
|
|
escapePath: vi.fn((path) => path),
|
|
|
|
|
unescapePath: vi.fn((path) => path),
|
|
|
|
|
getErrorMessage: vi.fn((error) => error.message),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
vi.mock('glob');
|
|
|
|
|
|
|
|
|
|
describe('useCompletion', () => {
|
|
|
|
|
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
|
|
|
|
let mockConfig: Mocked<Config>;
|
|
|
|
|
let mockCommandContext: CommandContext;
|
|
|
|
|
let mockSlashCommands: SlashCommand[];
|
|
|
|
|
|
|
|
|
|
const testCwd = '/test/project';
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockFileDiscoveryService = {
|
|
|
|
|
shouldGitIgnoreFile: vi.fn(),
|
|
|
|
|
shouldGeminiIgnoreFile: vi.fn(),
|
|
|
|
|
shouldIgnoreFile: vi.fn(),
|
|
|
|
|
filterFiles: vi.fn(),
|
|
|
|
|
getGeminiIgnorePatterns: vi.fn(),
|
|
|
|
|
projectRoot: '',
|
|
|
|
|
gitIgnoreFilter: null,
|
|
|
|
|
geminiIgnoreFilter: null,
|
|
|
|
|
} as unknown as Mocked<FileDiscoveryService>;
|
|
|
|
|
|
|
|
|
|
mockConfig = {
|
|
|
|
|
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
|
|
|
|
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
|
|
|
|
getEnableRecursiveFileSearch: vi.fn(() => true),
|
2025-07-20 00:55:33 -07:00
|
|
|
getFileFilteringOptions: vi.fn(() => ({
|
|
|
|
|
respectGitIgnore: true,
|
|
|
|
|
respectGeminiIgnore: true,
|
|
|
|
|
})),
|
2025-07-14 14:10:26 -07:00
|
|
|
} as unknown as Mocked<Config>;
|
|
|
|
|
|
|
|
|
|
mockCommandContext = {} as CommandContext;
|
|
|
|
|
|
|
|
|
|
mockSlashCommands = [
|
|
|
|
|
{
|
|
|
|
|
name: 'help',
|
2025-07-20 16:57:34 -04:00
|
|
|
altNames: ['?'],
|
2025-07-14 14:10:26 -07:00
|
|
|
description: 'Show help',
|
|
|
|
|
action: vi.fn(),
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
},
|
2025-07-16 08:45:10 +09:00
|
|
|
{
|
|
|
|
|
name: 'stats',
|
2025-07-20 16:57:34 -04:00
|
|
|
altNames: ['usage'],
|
2025-07-16 08:45:10 +09:00
|
|
|
description: 'check session stats. Usage: /stats [model|tools]',
|
|
|
|
|
action: vi.fn(),
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-16 08:45:10 +09:00
|
|
|
},
|
2025-07-14 14:10:26 -07:00
|
|
|
{
|
|
|
|
|
name: 'clear',
|
|
|
|
|
description: 'Clear the screen',
|
|
|
|
|
action: vi.fn(),
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'memory',
|
|
|
|
|
description: 'Manage memory',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
subCommands: [
|
|
|
|
|
{
|
|
|
|
|
name: 'show',
|
|
|
|
|
description: 'Show memory',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'add',
|
|
|
|
|
description: 'Add to memory',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'chat',
|
|
|
|
|
description: 'Manage chat history',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
subCommands: [
|
|
|
|
|
{
|
|
|
|
|
name: 'save',
|
|
|
|
|
description: 'Save chat',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
|
|
|
|
|
2025-07-14 14:10:26 -07:00
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'resume',
|
|
|
|
|
description: 'Resume a saved chat',
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
|
|
|
|
|
2025-07-14 14:10:26 -07:00
|
|
|
action: vi.fn(),
|
|
|
|
|
completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Hook initialization and state', () => {
|
|
|
|
|
it('should initialize with default state', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
it('should reset state when query becomes inactive', () => {
|
2025-07-14 14:10:26 -07:00
|
|
|
const { result, rerender } = renderHook(
|
2025-07-24 21:41:35 -07:00
|
|
|
({ text }) => {
|
|
|
|
|
const textBuffer = useTextBufferForTest(text);
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
{ initialProps: { text: '/help' } },
|
2025-07-14 14:10:26 -07:00
|
|
|
);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
// Inactive because of the leading space
|
|
|
|
|
rerender({ text: ' /help' });
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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 provide required functions', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(typeof result.current.setActiveSuggestionIndex).toBe('function');
|
|
|
|
|
expect(typeof result.current.setShowSuggestions).toBe('function');
|
|
|
|
|
expect(typeof result.current.resetCompletionState).toBe('function');
|
|
|
|
|
expect(typeof result.current.navigateUp).toBe('function');
|
|
|
|
|
expect(typeof result.current.navigateDown).toBe('function');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('resetCompletionState', () => {
|
|
|
|
|
it('should reset all state to default values', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/help');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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 functions', () => {
|
|
|
|
|
it('should handle navigateUp with no suggestions', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.navigateUp();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle navigateDown with no suggestions', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
result.current.navigateDown();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(-1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should navigate up through suggestions with wrap-around', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/h');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/h');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
2025-07-16 08:45:10 +09:00
|
|
|
expect(result.current.suggestions.length).toBe(5);
|
2025-07-14 14:10:26 -07:00
|
|
|
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();
|
|
|
|
|
});
|
2025-07-16 08:45:10 +09:00
|
|
|
expect(result.current.activeSuggestionIndex).toBe(4);
|
2025-07-14 14:10:26 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle navigation with large suggestion lists and scrolling', () => {
|
|
|
|
|
const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({
|
|
|
|
|
name: `command${i}`,
|
|
|
|
|
description: `Command ${i}`,
|
2025-07-22 00:34:55 -04:00
|
|
|
kind: CommandKind.BUILT_IN,
|
2025-07-14 14:10:26 -07:00
|
|
|
action: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/command');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
largeMockCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
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', () => {
|
|
|
|
|
it('should show all commands for root slash', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
2025-07-16 08:45:10 +09:00
|
|
|
expect(result.current.suggestions).toHaveLength(5);
|
2025-07-14 14:10:26 -07:00
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
2025-07-16 08:45:10 +09:00
|
|
|
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
|
2025-07-14 14:10:26 -07:00
|
|
|
);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(true);
|
|
|
|
|
expect(result.current.activeSuggestionIndex).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should filter commands by prefix', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/h');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('help');
|
|
|
|
|
expect(result.current.suggestions[0].description).toBe('Show help');
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-16 08:45:10 +09:00
|
|
|
it.each([['/?'], ['/usage']])(
|
2025-07-20 16:57:34 -04:00
|
|
|
'should not suggest commands when altNames is fully typed',
|
2025-07-16 08:45:10 +09:00
|
|
|
(altName) => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest(altName);
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-16 08:45:10 +09:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-16 08:45:10 +09:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2025-07-20 16:57:34 -04:00
|
|
|
it('should suggest commands based on partial altNames matches', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
2025-07-16 08:45:10 +09:00
|
|
|
expect(result.current.suggestions[0].label).toBe('stats');
|
2025-07-14 14:10:26 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not show suggestions for exact leaf command match', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/clear');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should show sub-commands for parent commands', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/memory');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
|
|
|
expect.arrayContaining(['show', 'add']),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should show all sub-commands after parent command with space', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/memory ');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
|
|
|
expect.arrayContaining(['show', 'add']),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should filter sub-commands by prefix', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/memory a');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('add');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle unknown command gracefully', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/unknown');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Command argument completion', () => {
|
|
|
|
|
it('should call completion function for command arguments', async () => {
|
|
|
|
|
const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
|
|
|
|
|
const commandsWithCompletion = [...mockSlashCommands];
|
|
|
|
|
const chatCommand = commandsWithCompletion.find(
|
|
|
|
|
(cmd) => cmd.name === 'chat',
|
|
|
|
|
);
|
|
|
|
|
const resumeCommand = chatCommand?.subCommands?.find(
|
|
|
|
|
(cmd) => cmd.name === 'resume',
|
|
|
|
|
);
|
|
|
|
|
if (resumeCommand) {
|
|
|
|
|
resumeCommand.completion = completionFn;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/chat resume ');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithCompletion,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(completionFn).toHaveBeenCalledWith(mockCommandContext, '');
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual([
|
|
|
|
|
'arg1',
|
|
|
|
|
'arg2',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should call completion function with partial argument', async () => {
|
|
|
|
|
const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
|
|
|
|
|
const commandsWithCompletion = [...mockSlashCommands];
|
|
|
|
|
const chatCommand = commandsWithCompletion.find(
|
|
|
|
|
(cmd) => cmd.name === 'chat',
|
|
|
|
|
);
|
|
|
|
|
const resumeCommand = chatCommand?.subCommands?.find(
|
|
|
|
|
(cmd) => cmd.name === 'resume',
|
|
|
|
|
);
|
|
|
|
|
if (resumeCommand) {
|
|
|
|
|
resumeCommand.completion = completionFn;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/chat resume ar');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithCompletion,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(completionFn).toHaveBeenCalledWith(mockCommandContext, 'ar');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle completion function that returns null', async () => {
|
|
|
|
|
const completionFn = vi.fn().mockResolvedValue(null);
|
|
|
|
|
const commandsWithCompletion = [...mockSlashCommands];
|
|
|
|
|
const chatCommand = commandsWithCompletion.find(
|
|
|
|
|
(cmd) => cmd.name === 'chat',
|
|
|
|
|
);
|
|
|
|
|
const resumeCommand = chatCommand?.subCommands?.find(
|
|
|
|
|
(cmd) => cmd.name === 'resume',
|
|
|
|
|
);
|
|
|
|
|
if (resumeCommand) {
|
|
|
|
|
resumeCommand.completion = completionFn;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/chat resume ');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithCompletion,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-22 00:34:55 -04:00
|
|
|
describe('Slash command completion with namespaced names', () => {
|
|
|
|
|
let commandsWithNamespaces: SlashCommand[];
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
commandsWithNamespaces = [
|
|
|
|
|
...mockSlashCommands,
|
|
|
|
|
{
|
|
|
|
|
name: 'git:commit',
|
|
|
|
|
description: 'A namespaced git command',
|
|
|
|
|
kind: CommandKind.FILE,
|
|
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'git:push',
|
|
|
|
|
description: 'Another namespaced git command',
|
|
|
|
|
kind: CommandKind.FILE,
|
|
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'docker:build',
|
|
|
|
|
description: 'A docker command',
|
|
|
|
|
kind: CommandKind.FILE,
|
|
|
|
|
action: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should suggest a namespaced command based on a partial match', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/git:co');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-22 00:34:55 -04:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithNamespaces,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-22 00:34:55 -04:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('git:commit');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should suggest all commands within a namespace when the namespace prefix is typed', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/git:');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-22 00:34:55 -04:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithNamespaces,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-22 00:34:55 -04:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
|
|
|
expect.arrayContaining(['git:commit', 'git:push']),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).not.toContain(
|
|
|
|
|
'docker:build',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not provide suggestions if the namespaced command is a perfect leaf match', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('/git:commit');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-22 00:34:55 -04:00
|
|
|
testCwd,
|
|
|
|
|
commandsWithNamespaces,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-22 00:34:55 -04:00
|
|
|
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-14 14:10:26 -07:00
|
|
|
describe('File path completion (@-syntax)', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.mocked(fs.readdir).mockResolvedValue([
|
|
|
|
|
{ name: 'file1.txt', isDirectory: () => false },
|
|
|
|
|
{ name: 'file2.js', isDirectory: () => false },
|
|
|
|
|
{ name: 'folder1', isDirectory: () => true },
|
|
|
|
|
{ name: '.hidden', isDirectory: () => false },
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should show file completions for @ prefix', async () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(3);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
|
|
|
expect.arrayContaining(['file1.txt', 'file2.js', 'folder1/']),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should filter files by prefix', async () => {
|
|
|
|
|
// Mock for recursive search since enableRecursiveFileSearch is true
|
|
|
|
|
vi.mocked(glob).mockResolvedValue([
|
|
|
|
|
`${testCwd}/file1.txt`,
|
|
|
|
|
`${testCwd}/file2.js`,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@file');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(2);
|
|
|
|
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
|
|
|
|
expect.arrayContaining(['file1.txt', 'file2.js']),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should include hidden files when prefix starts with dot', async () => {
|
|
|
|
|
// Mock for recursive search since enableRecursiveFileSearch is true
|
|
|
|
|
vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@.');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('.hidden');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle ENOENT error gracefully', async () => {
|
|
|
|
|
const enoentError = new Error('No such file or directory');
|
|
|
|
|
(enoentError as Error & { code: string }).code = 'ENOENT';
|
|
|
|
|
vi.mocked(fs.readdir).mockRejectedValue(enoentError);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@nonexistent');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle other errors by resetting state', async () => {
|
|
|
|
|
const consoleErrorSpy = vi
|
|
|
|
|
.spyOn(console, 'error')
|
|
|
|
|
.mockImplementation(() => {});
|
|
|
|
|
vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
|
|
|
|
|
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Debouncing', () => {
|
|
|
|
|
it('should debounce file completion requests', async () => {
|
|
|
|
|
// Mock for recursive search since enableRecursiveFileSearch is true
|
|
|
|
|
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
|
|
|
|
|
|
|
|
|
|
const { rerender } = renderHook(
|
2025-07-24 21:41:35 -07:00
|
|
|
({ text }) => {
|
|
|
|
|
const textBuffer = useTextBufferForTest(text);
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
{ initialProps: { text: '@f' } },
|
2025-07-14 14:10:26 -07:00
|
|
|
);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
rerender({ text: '@fi' });
|
|
|
|
|
rerender({ text: '@fil' });
|
|
|
|
|
rerender({ text: '@file' });
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(glob).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Query handling edge cases', () => {
|
|
|
|
|
it('should handle empty query', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle query without slash or @', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('regular text');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(0);
|
|
|
|
|
expect(result.current.showSuggestions).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle query with whitespace', () => {
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest(' /hel');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('help');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle @ at the end of query', async () => {
|
|
|
|
|
// Mock for recursive search since enableRecursiveFileSearch is true
|
|
|
|
|
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('some text @');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
// Wait for completion
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should process the @ query and get suggestions
|
|
|
|
|
expect(result.current.isLoadingSuggestions).toBe(false);
|
|
|
|
|
expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('File sorting behavior', () => {
|
|
|
|
|
it('should prioritize source files over test files with same base name', async () => {
|
|
|
|
|
// Mock glob to return files with same base name but different extensions
|
|
|
|
|
vi.mocked(glob).mockResolvedValue([
|
|
|
|
|
`${testCwd}/component.test.ts`,
|
|
|
|
|
`${testCwd}/component.ts`,
|
|
|
|
|
`${testCwd}/utils.spec.js`,
|
|
|
|
|
`${testCwd}/utils.js`,
|
|
|
|
|
`${testCwd}/api.test.tsx`,
|
|
|
|
|
`${testCwd}/api.tsx`,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@comp');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(6);
|
|
|
|
|
|
|
|
|
|
// Extract labels for easier testing
|
|
|
|
|
const labels = result.current.suggestions.map((s) => s.label);
|
|
|
|
|
|
|
|
|
|
// Verify the exact sorted order: source files should come before their test counterparts
|
|
|
|
|
expect(labels).toEqual([
|
|
|
|
|
'api.tsx',
|
|
|
|
|
'api.test.tsx',
|
|
|
|
|
'component.ts',
|
|
|
|
|
'component.test.ts',
|
|
|
|
|
'utils.js',
|
|
|
|
|
'utils.spec.js',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
describe('handleAutocomplete', () => {
|
|
|
|
|
it('should complete a partial command', () => {
|
|
|
|
|
// Create a mock buffer that we can spy on directly
|
|
|
|
|
const mockBuffer = {
|
|
|
|
|
text: '/mem',
|
|
|
|
|
lines: ['/mem'],
|
|
|
|
|
cursor: [0, 4],
|
|
|
|
|
preferredCol: null,
|
|
|
|
|
selectionAnchor: null,
|
|
|
|
|
allVisualLines: ['/mem'],
|
|
|
|
|
viewportVisualLines: ['/mem'],
|
|
|
|
|
visualCursor: [0, 4],
|
|
|
|
|
visualScrollRow: 0,
|
|
|
|
|
setText: vi.fn(),
|
|
|
|
|
insert: vi.fn(),
|
|
|
|
|
newline: vi.fn(),
|
|
|
|
|
backspace: vi.fn(),
|
|
|
|
|
del: vi.fn(),
|
|
|
|
|
move: vi.fn(),
|
|
|
|
|
undo: vi.fn(),
|
|
|
|
|
redo: vi.fn(),
|
|
|
|
|
replaceRange: vi.fn(),
|
|
|
|
|
replaceRangeByOffset: vi.fn(),
|
|
|
|
|
moveToOffset: vi.fn(),
|
|
|
|
|
deleteWordLeft: vi.fn(),
|
|
|
|
|
deleteWordRight: vi.fn(),
|
|
|
|
|
killLineRight: vi.fn(),
|
|
|
|
|
killLineLeft: vi.fn(),
|
|
|
|
|
handleInput: vi.fn(),
|
|
|
|
|
openInExternalEditor: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useCompletion(
|
|
|
|
|
mockBuffer,
|
|
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
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 ',
|
|
|
|
|
lines: ['/memory '],
|
|
|
|
|
cursor: [0, 8],
|
|
|
|
|
preferredCol: null,
|
|
|
|
|
selectionAnchor: null,
|
|
|
|
|
allVisualLines: ['/memory '],
|
|
|
|
|
viewportVisualLines: ['/memory '],
|
|
|
|
|
visualCursor: [0, 8],
|
|
|
|
|
visualScrollRow: 0,
|
|
|
|
|
setText: vi.fn(),
|
|
|
|
|
insert: vi.fn(),
|
|
|
|
|
newline: vi.fn(),
|
|
|
|
|
backspace: vi.fn(),
|
|
|
|
|
del: vi.fn(),
|
|
|
|
|
move: vi.fn(),
|
|
|
|
|
undo: vi.fn(),
|
|
|
|
|
redo: vi.fn(),
|
|
|
|
|
replaceRange: vi.fn(),
|
|
|
|
|
replaceRangeByOffset: vi.fn(),
|
|
|
|
|
moveToOffset: vi.fn(),
|
|
|
|
|
deleteWordLeft: vi.fn(),
|
|
|
|
|
deleteWordRight: vi.fn(),
|
|
|
|
|
killLineRight: vi.fn(),
|
|
|
|
|
killLineLeft: vi.fn(),
|
|
|
|
|
handleInput: vi.fn(),
|
|
|
|
|
openInExternalEditor: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useCompletion(
|
|
|
|
|
mockBuffer,
|
|
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
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: '/?',
|
|
|
|
|
lines: ['/?'],
|
|
|
|
|
cursor: [0, 2],
|
|
|
|
|
preferredCol: null,
|
|
|
|
|
selectionAnchor: null,
|
|
|
|
|
allVisualLines: ['/?'],
|
|
|
|
|
viewportVisualLines: ['/?'],
|
|
|
|
|
visualCursor: [0, 2],
|
|
|
|
|
visualScrollRow: 0,
|
|
|
|
|
setText: vi.fn(),
|
|
|
|
|
insert: vi.fn(),
|
|
|
|
|
newline: vi.fn(),
|
|
|
|
|
backspace: vi.fn(),
|
|
|
|
|
del: vi.fn(),
|
|
|
|
|
move: vi.fn(),
|
|
|
|
|
undo: vi.fn(),
|
|
|
|
|
redo: vi.fn(),
|
|
|
|
|
replaceRange: vi.fn(),
|
|
|
|
|
replaceRangeByOffset: vi.fn(),
|
|
|
|
|
moveToOffset: vi.fn(),
|
|
|
|
|
deleteWordLeft: vi.fn(),
|
|
|
|
|
deleteWordRight: vi.fn(),
|
|
|
|
|
killLineRight: vi.fn(),
|
|
|
|
|
killLineLeft: vi.fn(),
|
|
|
|
|
handleInput: vi.fn(),
|
|
|
|
|
openInExternalEditor: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useCompletion(
|
|
|
|
|
mockBuffer,
|
|
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
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],
|
|
|
|
|
preferredCol: null,
|
|
|
|
|
selectionAnchor: null,
|
|
|
|
|
allVisualLines: ['@src/fi'],
|
|
|
|
|
viewportVisualLines: ['@src/fi'],
|
|
|
|
|
visualCursor: [0, 7],
|
|
|
|
|
visualScrollRow: 0,
|
|
|
|
|
setText: vi.fn(),
|
|
|
|
|
insert: vi.fn(),
|
|
|
|
|
newline: vi.fn(),
|
|
|
|
|
backspace: vi.fn(),
|
|
|
|
|
del: vi.fn(),
|
|
|
|
|
move: vi.fn(),
|
|
|
|
|
undo: vi.fn(),
|
|
|
|
|
redo: vi.fn(),
|
|
|
|
|
replaceRange: vi.fn(),
|
|
|
|
|
replaceRangeByOffset: vi.fn(),
|
|
|
|
|
moveToOffset: vi.fn(),
|
|
|
|
|
deleteWordLeft: vi.fn(),
|
|
|
|
|
deleteWordRight: vi.fn(),
|
|
|
|
|
killLineRight: vi.fn(),
|
|
|
|
|
killLineLeft: vi.fn(),
|
|
|
|
|
handleInput: vi.fn(),
|
|
|
|
|
openInExternalEditor: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() =>
|
|
|
|
|
useCompletion(
|
|
|
|
|
mockBuffer,
|
|
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
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',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-14 14:10:26 -07:00
|
|
|
describe('Config and FileDiscoveryService integration', () => {
|
|
|
|
|
it('should work without config', async () => {
|
|
|
|
|
vi.mocked(fs.readdir).mockResolvedValue([
|
|
|
|
|
{ name: 'file1.txt', isDirectory: () => false },
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
undefined,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('file1.txt');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should respect file filtering when config is provided', async () => {
|
|
|
|
|
vi.mocked(fs.readdir).mockResolvedValue([
|
|
|
|
|
{ name: 'file1.txt', isDirectory: () => false },
|
|
|
|
|
{ name: 'ignored.log', isDirectory: () => false },
|
|
|
|
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
|
|
|
|
|
|
|
|
|
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
|
|
|
|
(path: string) => path.includes('.log'),
|
|
|
|
|
);
|
|
|
|
|
|
2025-07-24 21:41:35 -07:00
|
|
|
const { result } = renderHook(() => {
|
|
|
|
|
const textBuffer = useTextBufferForTest('@');
|
|
|
|
|
return useCompletion(
|
|
|
|
|
textBuffer,
|
2025-07-14 14:10:26 -07:00
|
|
|
testCwd,
|
|
|
|
|
mockSlashCommands,
|
|
|
|
|
mockCommandContext,
|
|
|
|
|
mockConfig,
|
2025-07-24 21:41:35 -07:00
|
|
|
);
|
|
|
|
|
});
|
2025-07-14 14:10:26 -07:00
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.current.suggestions).toHaveLength(1);
|
|
|
|
|
expect(result.current.suggestions[0].label).toBe('file1.txt');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|