diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/slashCommandProcessor.test.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 625 |
1 files changed, 297 insertions, 328 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 4920b088..32a6810e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -11,419 +11,388 @@ const { mockProcessExit } = vi.hoisted(() => ({ vi.mock('node:process', () => ({ default: { exit: mockProcessExit, - cwd: vi.fn(() => '/mock/cwd'), - get env() { - return process.env; - }, - platform: 'test-platform', - version: 'test-node-version', - memoryUsage: vi.fn(() => ({ - rss: 12345678, - heapTotal: 23456789, - heapUsed: 10234567, - external: 1234567, - arrayBuffers: 123456, - })), }, - exit: mockProcessExit, - cwd: vi.fn(() => '/mock/cwd'), - get env() { - return process.env; - }, - platform: 'test-platform', - version: 'test-node-version', - memoryUsage: vi.fn(() => ({ - rss: 12345678, - heapTotal: 23456789, - heapUsed: 10234567, - external: 1234567, - arrayBuffers: 123456, - })), })); -vi.mock('node:fs/promises', () => ({ - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), +const mockLoadCommands = vi.fn(); +vi.mock('../../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: vi.fn().mockImplementation(() => ({ + loadCommands: mockLoadCommands, + })), })); -const mockGetCliVersionFn = vi.fn(() => Promise.resolve('0.1.0')); -vi.mock('../../utils/version.js', () => ({ - getCliVersion: (...args: []) => mockGetCliVersionFn(...args), +vi.mock('../contexts/SessionContext.js', () => ({ + useSessionStats: vi.fn(() => ({ stats: {} })), })); -import { act, renderHook } from '@testing-library/react'; -import { vi, describe, it, expect, beforeEach, beforeAll, Mock } from 'vitest'; -import open from 'open'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; -import { SlashCommandProcessorResult } from '../types.js'; -import { Config, GeminiClient } from '@google/gemini-cli-core'; -import { useSessionStats } from '../contexts/SessionContext.js'; -import { LoadedSettings } from '../../config/settings.js'; -import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; -import { CommandService } from '../../services/CommandService.js'; import { SlashCommand } from '../commands/types.js'; +import { Config } from '@google/gemini-cli-core'; +import { LoadedSettings } from '../../config/settings.js'; +import { MessageType } from '../types.js'; +import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; -vi.mock('../contexts/SessionContext.js', () => ({ - useSessionStats: vi.fn(), -})); - -vi.mock('../../services/CommandService.js'); - -vi.mock('./useShowMemoryCommand.js', () => ({ - SHOW_MEMORY_COMMAND_NAME: '/memory show', - createShowMemoryAction: vi.fn(() => vi.fn()), -})); - -vi.mock('open', () => ({ - default: vi.fn(), -})); +describe('useSlashCommandProcessor', () => { + const mockAddItem = vi.fn(); + const mockClearItems = vi.fn(); + const mockLoadHistory = vi.fn(); + const mockSetShowHelp = vi.fn(); + const mockOpenAuthDialog = vi.fn(); + const mockSetQuittingMessages = vi.fn(); -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal<typeof import('@google/gemini-cli-core')>(); - return { - ...actual, - }; -}); + const mockConfig = { + getProjectRoot: () => '/mock/cwd', + getSessionId: () => 'test-session', + getGeminiClient: () => ({ + setHistory: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as Config; -describe('useSlashCommandProcessor', () => { - let mockAddItem: ReturnType<typeof vi.fn>; - let mockClearItems: ReturnType<typeof vi.fn>; - let mockLoadHistory: ReturnType<typeof vi.fn>; - let mockRefreshStatic: ReturnType<typeof vi.fn>; - let mockSetShowHelp: ReturnType<typeof vi.fn>; - let mockOnDebugMessage: ReturnType<typeof vi.fn>; - let mockOpenThemeDialog: ReturnType<typeof vi.fn>; - let mockOpenAuthDialog: ReturnType<typeof vi.fn>; - let mockOpenEditorDialog: ReturnType<typeof vi.fn>; - let mockSetQuittingMessages: ReturnType<typeof vi.fn>; - let mockTryCompressChat: ReturnType<typeof vi.fn>; - let mockGeminiClient: GeminiClient; - let mockConfig: Config; - let mockCorgiMode: ReturnType<typeof vi.fn>; - const mockUseSessionStats = useSessionStats as Mock; + const mockSettings = {} as LoadedSettings; beforeEach(() => { vi.clearAllMocks(); - - mockAddItem = vi.fn(); - mockClearItems = vi.fn(); - mockLoadHistory = vi.fn(); - mockRefreshStatic = vi.fn(); - mockSetShowHelp = vi.fn(); - mockOnDebugMessage = vi.fn(); - mockOpenThemeDialog = vi.fn(); - mockOpenAuthDialog = vi.fn(); - mockOpenEditorDialog = vi.fn(); - mockSetQuittingMessages = vi.fn(); - mockTryCompressChat = vi.fn(); - mockGeminiClient = { - tryCompressChat: mockTryCompressChat, - } as unknown as GeminiClient; - mockConfig = { - getDebugMode: vi.fn(() => false), - getGeminiClient: () => mockGeminiClient, - getSandbox: vi.fn(() => 'test-sandbox'), - getModel: vi.fn(() => 'test-model'), - getProjectRoot: vi.fn(() => '/test/dir'), - getCheckpointingEnabled: vi.fn(() => true), - getBugCommand: vi.fn(() => undefined), - getSessionId: vi.fn(() => 'test-session-id'), - getIdeMode: vi.fn(() => false), - } as unknown as Config; - mockCorgiMode = vi.fn(); - mockUseSessionStats.mockReturnValue({ - stats: { - sessionStartTime: new Date('2025-01-01T00:00:00.000Z'), - cumulative: { - promptCount: 0, - promptTokenCount: 0, - candidatesTokenCount: 0, - totalTokenCount: 0, - cachedContentTokenCount: 0, - toolUsePromptTokenCount: 0, - thoughtsTokenCount: 0, - }, - }, - }); - - (open as Mock).mockClear(); - mockProcessExit.mockClear(); - (ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear(); - process.env = { ...globalThis.process.env }; + (vi.mocked(BuiltinCommandLoader) as Mock).mockClear(); + mockLoadCommands.mockResolvedValue([]); }); - const getProcessorHook = () => { - const settings = { - merged: { - contextFileName: 'GEMINI.md', - }, - } as unknown as LoadedSettings; - return renderHook(() => + const setupProcessorHook = (commands: SlashCommand[] = []) => { + mockLoadCommands.mockResolvedValue(Object.freeze(commands)); + const { result } = renderHook(() => useSlashCommandProcessor( mockConfig, - settings, + mockSettings, mockAddItem, mockClearItems, mockLoadHistory, - mockRefreshStatic, + vi.fn(), // refreshStatic mockSetShowHelp, - mockOnDebugMessage, - mockOpenThemeDialog, + vi.fn(), // onDebugMessage + vi.fn(), // openThemeDialog mockOpenAuthDialog, - mockOpenEditorDialog, - mockCorgiMode, + vi.fn(), // openEditorDialog + vi.fn(), // toggleCorgiMode mockSetQuittingMessages, - vi.fn(), // mockOpenPrivacyNotice + vi.fn(), // openPrivacyNotice ), ); - }; - describe('Command Processing', () => { - let ActualCommandService: typeof CommandService; + return result; + }; - beforeAll(async () => { - const actual = (await vi.importActual( - '../../services/CommandService.js', - )) as { CommandService: typeof CommandService }; - ActualCommandService = actual.CommandService; + describe('Initialization and Command Loading', () => { + it('should initialize CommandService with BuiltinCommandLoader', () => { + setupProcessorHook(); + expect(BuiltinCommandLoader).toHaveBeenCalledTimes(1); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); }); - beforeEach(() => { - vi.clearAllMocks(); + it('should call loadCommands and populate state after mounting', async () => { + const testCommand: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', + }; + const result = setupProcessorHook([testCommand]); + + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(1); + }); + + expect(result.current.slashCommands[0]?.name).toBe('test'); + expect(mockLoadCommands).toHaveBeenCalledTimes(1); }); - it('should execute a registered command', async () => { - const mockAction = vi.fn(); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; + it('should provide an immutable array of commands to consumers', async () => { + const testCommand: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', + }; + const result = setupProcessorHook([testCommand]); - // We create the instance outside the mock implementation. - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); + await waitFor(() => { + expect(result.current.slashCommands).toHaveLength(1); + }); - // This mock ensures the hook uses our pre-configured instance. - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + const commands = result.current.slashCommands; - const { result } = getProcessorHook(); + expect(() => { + // @ts-expect-error - We are intentionally testing a violation of the readonly type. + commands.push({ + name: 'rogue', + description: 'a rogue command', + kind: 'built-in', + }); + }).toThrow(TypeError); + }); + }); - await vi.waitFor(() => { - // We check that the `slashCommands` array, which is the public API - // of our hook, eventually contains the command we injected. - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); - }); + describe('Command Execution Logic', () => { + it('should display an error for an unknown command', async () => { + const result = setupProcessorHook(); + await waitFor(() => expect(result.current.slashCommands).toBeDefined()); - let commandResult: SlashCommandProcessorResult | false = false; await act(async () => { - commandResult = await result.current.handleSlashCommand('/test'); + await result.current.handleSlashCommand('/nonexistent'); }); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(commandResult).toEqual({ type: 'handled' }); + // Expect 2 calls: one for the user's input, one for the error message. + expect(mockAddItem).toHaveBeenCalledTimes(2); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Unknown command: /nonexistent', + }), + expect.any(Number), + ); }); - it('should return "schedule_tool" for a command returning a tool action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'tool', - toolName: 'my_tool', - toolArgs: { arg1: 'value1' }, - }); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + it('should display help for a parent command invoked without a subcommand', async () => { + const parentCommand: SlashCommand = { + name: 'parent', + description: 'a parent command', + kind: 'built-in', + subCommands: [ + { + name: 'child1', + description: 'First child.', + kind: 'built-in', + }, + ], + }; + const result = setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/parent'); }); - const commandResult = await result.current.handleSlashCommand('/test'); - - expect(mockAction).toHaveBeenCalledTimes(1); - expect(commandResult).toEqual({ - type: 'schedule_tool', - toolName: 'my_tool', - toolArgs: { arg1: 'value1' }, - }); + expect(mockAddItem).toHaveBeenCalledTimes(2); + expect(mockAddItem).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining( + "Command '/parent' requires a subcommand.", + ), + }), + expect.any(Number), + ); }); - it('should return "handled" for a command returning a message action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'message', - messageType: 'info', - content: 'This is a message', - }); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + it('should correctly find and execute a nested subcommand', async () => { + const childAction = vi.fn(); + const parentCommand: SlashCommand = { + name: 'parent', + description: 'a parent command', + kind: 'built-in', + subCommands: [ + { + name: 'child', + description: 'a child command', + kind: 'built-in', + action: childAction, + }, + ], + }; + const result = setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/parent child with args'); }); - const commandResult = await result.current.handleSlashCommand('/test'); + expect(childAction).toHaveBeenCalledTimes(1); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockAddItem).toHaveBeenCalledWith( + expect(childAction).toHaveBeenCalledWith( expect.objectContaining({ - type: 'info', - text: 'This is a message', + services: expect.objectContaining({ + config: mockConfig, + }), + ui: expect.objectContaining({ + addItem: mockAddItem, + }), }), - expect.any(Number), + 'with args', ); - expect(commandResult).toEqual({ type: 'handled' }); }); + }); - it('should return "handled" for a command returning a dialog action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'dialog', - dialog: 'help', - }); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + describe('Action Result Handling', () => { + it('should handle "dialog: help" action', async () => { + const command: SlashCommand = { + name: 'helpcmd', + description: 'a help command', + kind: 'built-in', + action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }), + }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/helpcmd'); }); - const commandResult = await result.current.handleSlashCommand('/test'); - - expect(mockAction).toHaveBeenCalledTimes(1); expect(mockSetShowHelp).toHaveBeenCalledWith(true); - expect(commandResult).toEqual({ type: 'handled' }); }); - it('should open the auth dialog for a command returning an auth dialog action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'dialog', - dialog: 'auth', + it('should handle "load_history" action', async () => { + const command: SlashCommand = { + name: 'load', + description: 'a load command', + kind: 'built-in', + action: vi.fn().mockResolvedValue({ + type: 'load_history', + history: [{ type: MessageType.USER, text: 'old prompt' }], + clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }], + }), + }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/load'); }); - const newAuthCommand: SlashCommand = { name: 'auth', action: mockAction }; - const mockLoader = async () => [newAuthCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, + expect(mockClearItems).toHaveBeenCalledTimes(1); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'user', text: 'old prompt' }), + expect.any(Number), ); + }); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'auth'), - ).toBe(true); - }); - - const commandResult = await result.current.handleSlashCommand('/auth'); + describe('with fake timers', () => { + // This test needs to let the async `waitFor` complete with REAL timers + // before switching to FAKE timers to test setTimeout. + it('should handle a "quit" action', async () => { + const quitAction = vi + .fn() + .mockResolvedValue({ type: 'quit', messages: [] }); + const command: SlashCommand = { + name: 'exit', + description: 'an exit command', + kind: 'built-in', + action: quitAction, + }; + const result = setupProcessorHook([command]); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockOpenAuthDialog).toHaveBeenCalledWith(); - expect(commandResult).toEqual({ type: 'handled' }); - }); + await waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); - it('should open the theme dialog for a command returning a theme dialog action', async () => { - const mockAction = vi.fn().mockResolvedValue({ - type: 'dialog', - dialog: 'theme', - }); - const newCommand: SlashCommand = { name: 'test', action: mockAction }; - const mockLoader = async () => [newCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, - ); + vi.useFakeTimers(); - const { result } = getProcessorHook(); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'test'), - ).toBe(true); - }); + try { + await act(async () => { + await result.current.handleSlashCommand('/exit'); + }); - const commandResult = await result.current.handleSlashCommand('/test'); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); - expect(mockAction).toHaveBeenCalledTimes(1); - expect(mockOpenThemeDialog).toHaveBeenCalledWith(); - expect(commandResult).toEqual({ type: 'handled' }); + expect(mockSetQuittingMessages).toHaveBeenCalledWith([]); + expect(mockProcessExit).toHaveBeenCalledWith(0); + } finally { + vi.useRealTimers(); + } + }); }); + }); - it('should show help for a parent command with no action', async () => { - const parentCommand: SlashCommand = { - name: 'parent', - subCommands: [ - { name: 'child', description: 'A child.', action: vi.fn() }, - ], + describe('Command Parsing and Matching', () => { + it('should be case-sensitive', async () => { + const command: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - const mockLoader = async () => [parentCommand]; - const commandServiceInstance = new ActualCommandService( - mockConfig, - mockLoader, - ); - vi.mocked(CommandService).mockImplementation( - () => commandServiceInstance, + await act(async () => { + // Use uppercase when command is lowercase + await result.current.handleSlashCommand('/Test'); + }); + + // It should fail and call addItem with an error + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Unknown command: /Test', + }), + expect.any(Number), ); + }); - const { result } = getProcessorHook(); + it('should correctly match an altName', async () => { + const action = vi.fn(); + const command: SlashCommand = { + name: 'main', + altNames: ['alias'], + description: 'a command with an alias', + kind: 'built-in', + action, + }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - await vi.waitFor(() => { - expect( - result.current.slashCommands.some((c) => c.name === 'parent'), - ).toBe(true); + await act(async () => { + await result.current.handleSlashCommand('/alias'); }); + expect(action).toHaveBeenCalledTimes(1); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: MessageType.ERROR }), + ); + }); + + it('should handle extra whitespace around the command', async () => { + const action = vi.fn(); + const command: SlashCommand = { + name: 'test', + description: 'a test command', + kind: 'built-in', + action, + }; + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await act(async () => { - await result.current.handleSlashCommand('/parent'); + await result.current.handleSlashCommand(' /test with-args '); }); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: expect.stringContaining( - "Command '/parent' requires a subcommand.", - ), - }), - expect.any(Number), + expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args'); + }); + }); + + describe('Lifecycle', () => { + it('should abort command loading when the hook unmounts', async () => { + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const { unmount } = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), // refreshStatic + mockSetShowHelp, + vi.fn(), // onDebugMessage + vi.fn(), // openThemeDialog + mockOpenAuthDialog, + vi.fn(), // openEditorDialog + vi.fn(), // toggleCorgiMode + mockSetQuittingMessages, + vi.fn(), // openPrivacyNotice + ), ); + + unmount(); + + expect(abortSpy).toHaveBeenCalledTimes(1); }); }); }); |
