diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 110 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 124 |
2 files changed, 147 insertions, 87 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 66c1b883..24880fc1 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -4,18 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({ +const { logSlashCommand } = vi.hoisted(() => ({ logSlashCommand: vi.fn(), - SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal<typeof import('@google/gemini-cli-core')>(); + return { ...original, logSlashCommand, - SlashCommandEvent, getIdeInstaller: vi.fn().mockReturnValue(null), }; }); @@ -25,10 +24,10 @@ const { mockProcessExit } = vi.hoisted(() => ({ })); vi.mock('node:process', () => { - const mockProcess = { + const mockProcess: Partial<NodeJS.Process> = { exit: mockProcessExit, - platform: 'test-platform', - }; + platform: 'sunos', + } as unknown as NodeJS.Process; return { ...mockProcess, default: mockProcess, @@ -77,22 +76,28 @@ import { ConfirmShellCommandsActionReturn, SlashCommand, } from '../commands/types.js'; -import { Config, ToolConfirmationOutcome } from '@google/gemini-cli-core'; +import { ToolConfirmationOutcome } from '@google/gemini-cli-core'; import { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { + SlashCommandStatus, + makeFakeConfig, +} from '@google/gemini-cli-core/index.js'; -const createTestCommand = ( +function createTestCommand( overrides: Partial<SlashCommand>, kind: CommandKind = CommandKind.BUILT_IN, -): SlashCommand => ({ - name: 'test', - description: 'a test command', - kind, - ...overrides, -}); +): SlashCommand { + return { + name: 'test', + description: 'a test command', + kind, + ...overrides, + }; +} describe('useSlashCommandProcessor', () => { const mockAddItem = vi.fn(); @@ -102,15 +107,7 @@ describe('useSlashCommandProcessor', () => { const mockOpenAuthDialog = vi.fn(); const mockSetQuittingMessages = vi.fn(); - const mockConfig = { - getProjectRoot: vi.fn(() => '/mock/cwd'), - getSessionId: vi.fn(() => 'test-session'), - getGeminiClient: vi.fn(() => ({ - setHistory: vi.fn().mockResolvedValue(undefined), - })), - getExtensions: vi.fn(() => []), - getIdeMode: vi.fn(() => false), - } as unknown as Config; + const mockConfig = makeFakeConfig({}); const mockSettings = {} as LoadedSettings; @@ -884,7 +881,9 @@ describe('useSlashCommandProcessor', () => { const loggingTestCommands: SlashCommand[] = [ createTestCommand({ name: 'logtest', - action: mockCommandAction, + action: vi + .fn() + .mockResolvedValue({ type: 'message', content: 'hello world' }), }), createTestCommand({ name: 'logwithsub', @@ -896,6 +895,10 @@ describe('useSlashCommandProcessor', () => { ], }), createTestCommand({ + name: 'fail', + action: vi.fn().mockRejectedValue(new Error('oh no!')), + }), + createTestCommand({ name: 'logalias', altNames: ['la'], action: mockCommandAction, @@ -905,7 +908,6 @@ describe('useSlashCommandProcessor', () => { beforeEach(() => { mockCommandAction.mockClear(); vi.mocked(logSlashCommand).mockClear(); - vi.mocked(SlashCommandEvent).mockClear(); }); it('should log a simple slash command', async () => { @@ -917,8 +919,45 @@ describe('useSlashCommandProcessor', () => { await result.current.handleSlashCommand('/logtest'); }); - expect(logSlashCommand).toHaveBeenCalledTimes(1); - expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined); + expect(logSlashCommand).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + command: 'logtest', + subcommand: undefined, + status: SlashCommandStatus.SUCCESS, + }), + ); + }); + + it('logs nothing for a bogus command', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/bogusbogusbogus'); + }); + + expect(logSlashCommand).not.toHaveBeenCalled(); + }); + + it('logs a failure event for a failed command', async () => { + const result = setupProcessorHook(loggingTestCommands); + await waitFor(() => + expect(result.current.slashCommands.length).toBeGreaterThan(0), + ); + await act(async () => { + await result.current.handleSlashCommand('/fail'); + }); + + expect(logSlashCommand).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + command: 'fail', + status: 'error', + subcommand: undefined, + }), + ); }); it('should log a slash command with a subcommand', async () => { @@ -930,8 +969,13 @@ describe('useSlashCommandProcessor', () => { await result.current.handleSlashCommand('/logwithsub sub'); }); - expect(logSlashCommand).toHaveBeenCalledTimes(1); - expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub'); + expect(logSlashCommand).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + command: 'logwithsub', + subcommand: 'sub', + }), + ); }); it('should log the command path when an alias is used', async () => { @@ -942,8 +986,12 @@ describe('useSlashCommandProcessor', () => { await act(async () => { await result.current.handleSlashCommand('/la'); }); - expect(logSlashCommand).toHaveBeenCalledTimes(1); - expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined); + expect(logSlashCommand).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + command: 'logalias', + }), + ); }); it('should not log for unknown commands', async () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index b4ce0d4d..aaa2fbff 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -14,7 +14,8 @@ import { GitService, Logger, logSlashCommand, - SlashCommandEvent, + makeSlashCommandEvent, + SlashCommandStatus, ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -229,76 +230,70 @@ export const useSlashCommandProcessor = ( overwriteConfirmed?: boolean, ): Promise<SlashCommandProcessorResult | false> => { setIsProcessing(true); - try { - if (typeof rawQuery !== 'string') { - return false; - } - const trimmed = rawQuery.trim(); - if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { - return false; - } + if (typeof rawQuery !== 'string') { + return false; + } - const userMessageTimestamp = Date.now(); - addItem( - { type: MessageType.USER, text: trimmed }, - userMessageTimestamp, - ); + const trimmed = rawQuery.trim(); + if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { + return false; + } - const parts = trimmed.substring(1).trim().split(/\s+/); - const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] + const userMessageTimestamp = Date.now(); + addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp); - let currentCommands = commands; - let commandToExecute: SlashCommand | undefined; - let pathIndex = 0; - const canonicalPath: string[] = []; + const parts = trimmed.substring(1).trim().split(/\s+/); + const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add'] - for (const part of commandPath) { - // TODO: For better performance and architectural clarity, this two-pass - // search could be replaced. A more optimal approach would be to - // pre-compute a single lookup map in `CommandService.ts` that resolves - // all name and alias conflicts during the initial loading phase. The - // processor would then perform a single, fast lookup on that map. + let currentCommands = commands; + let commandToExecute: SlashCommand | undefined; + let pathIndex = 0; + let hasError = false; + const canonicalPath: string[] = []; - // First pass: check for an exact match on the primary command name. - let foundCommand = currentCommands.find((cmd) => cmd.name === part); + for (const part of commandPath) { + // TODO: For better performance and architectural clarity, this two-pass + // search could be replaced. A more optimal approach would be to + // pre-compute a single lookup map in `CommandService.ts` that resolves + // all name and alias conflicts during the initial loading phase. The + // processor would then perform a single, fast lookup on that map. - // Second pass: if no primary name matches, check for an alias. - if (!foundCommand) { - foundCommand = currentCommands.find((cmd) => - cmd.altNames?.includes(part), - ); - } + // First pass: check for an exact match on the primary command name. + let foundCommand = currentCommands.find((cmd) => cmd.name === part); - if (foundCommand) { - commandToExecute = foundCommand; - canonicalPath.push(foundCommand.name); - pathIndex++; - if (foundCommand.subCommands) { - currentCommands = foundCommand.subCommands; - } else { - break; - } + // Second pass: if no primary name matches, check for an alias. + if (!foundCommand) { + foundCommand = currentCommands.find((cmd) => + cmd.altNames?.includes(part), + ); + } + + if (foundCommand) { + commandToExecute = foundCommand; + canonicalPath.push(foundCommand.name); + pathIndex++; + if (foundCommand.subCommands) { + currentCommands = foundCommand.subCommands; } else { break; } + } else { + break; } + } + + const resolvedCommandPath = canonicalPath; + const subcommand = + resolvedCommandPath.length > 1 + ? resolvedCommandPath.slice(1).join(' ') + : undefined; + try { if (commandToExecute) { const args = parts.slice(pathIndex).join(' '); if (commandToExecute.action) { - if (config) { - const resolvedCommandPath = canonicalPath; - const event = new SlashCommandEvent( - resolvedCommandPath[0], - resolvedCommandPath.length > 1 - ? resolvedCommandPath.slice(1).join(' ') - : undefined, - ); - logSlashCommand(config, event); - } - const fullCommandContext: CommandContext = { ...commandContext, invocation: { @@ -320,7 +315,6 @@ export const useSlashCommandProcessor = ( ]), }; } - const result = await commandToExecute.action( fullCommandContext, args, @@ -493,8 +487,18 @@ export const useSlashCommandProcessor = ( content: `Unknown command: ${trimmed}`, timestamp: new Date(), }); + return { type: 'handled' }; - } catch (e) { + } catch (e: unknown) { + hasError = true; + if (config) { + const event = makeSlashCommandEvent({ + command: resolvedCommandPath[0], + subcommand, + status: SlashCommandStatus.ERROR, + }); + logSlashCommand(config, event); + } addItem( { type: MessageType.ERROR, @@ -504,6 +508,14 @@ export const useSlashCommandProcessor = ( ); return { type: 'handled' }; } finally { + if (config && resolvedCommandPath[0] && !hasError) { + const event = makeSlashCommandEvent({ + command: resolvedCommandPath[0], + subcommand, + status: SlashCommandStatus.SUCCESS, + }); + logSlashCommand(config, event); + } setIsProcessing(false); } }, |
