| |
| |
| |
| |
| |
|
|
| import { |
| describe, |
| it, |
| expect, |
| vi, |
| beforeEach, |
| afterEach, |
| type Mock, |
| type MockedObject, |
| } from 'vitest'; |
| import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; |
| import { waitFor } from '../test-utils/async.js'; |
| import { act, useContext } from 'react'; |
| import { AppContainer } from './AppContainer.js'; |
| import { SettingsContext } from './contexts/SettingsContext.js'; |
| import { type TrackedToolCall } from './hooks/useToolScheduler.js'; |
| import { |
| type Config, |
| makeFakeConfig, |
| CoreEvent, |
| type UserFeedbackPayload, |
| type ResumedSessionData, |
| type StartupWarning, |
| WarningPriority, |
| AuthType, |
| type AgentDefinition, |
| CoreToolCallStatus, |
| } from '@google/gemini-cli-core'; |
|
|
| |
| const mockCoreEvents = vi.hoisted(() => ({ |
| on: vi.fn(), |
| off: vi.fn(), |
| drainBacklogs: vi.fn(), |
| emit: vi.fn(), |
| })); |
|
|
| |
| const mockIdeClient = vi.hoisted(() => ({ |
| getInstance: vi.fn().mockReturnValue(new Promise(() => {})), |
| })); |
|
|
| |
| const mocks = vi.hoisted(() => ({ |
| mockStdout: { write: vi.fn() }, |
| mockRerender: vi.fn(), |
| })); |
| const terminalNotificationsMocks = vi.hoisted(() => ({ |
| notifyViaTerminal: vi.fn().mockResolvedValue(true), |
| isNotificationsEnabled: vi.fn(() => true), |
| getNotificationMethod: vi.fn(() => 'auto'), |
| buildRunEventNotificationContent: vi.fn((event) => ({ |
| title: 'Mock Notification', |
| subtitle: 'Mock Subtitle', |
| body: JSON.stringify(event), |
| })), |
| })); |
|
|
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { |
| const actual = |
| await importOriginal<typeof import('@google/gemini-cli-core')>(); |
| return { |
| ...actual, |
| coreEvents: mockCoreEvents, |
| IdeClient: mockIdeClient, |
| writeToStdout: vi.fn((...args) => |
| process.stdout.write( |
| ...(args as Parameters<typeof process.stdout.write>), |
| ), |
| ), |
| writeToStderr: vi.fn((...args) => |
| process.stderr.write( |
| ...(args as Parameters<typeof process.stderr.write>), |
| ), |
| ), |
| patchStdio: vi.fn(() => () => {}), |
| createWorkingStdio: vi.fn(() => ({ |
| stdout: process.stdout, |
| stderr: process.stderr, |
| })), |
| enableMouseEvents: vi.fn(), |
| disableMouseEvents: vi.fn(), |
| FileDiscoveryService: vi.fn().mockImplementation(() => ({ |
| initialize: vi.fn(), |
| })), |
| startupProfiler: { |
| flush: vi.fn(), |
| start: vi.fn(), |
| end: vi.fn(), |
| }, |
| }; |
| }); |
| import ansiEscapes from 'ansi-escapes'; |
| import { type LoadedSettings } from '../config/settings.js'; |
| import { createMockSettings } from '../test-utils/settings.js'; |
| import type { InitializationResult } from '../core/initializer.js'; |
| import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; |
| import { StreamingState, MessageType } from './types.js'; |
| import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; |
| import { |
| UIActionsContext, |
| type UIActions, |
| } from './contexts/UIActionsContext.js'; |
| import { KeypressProvider } from './contexts/KeypressContext.js'; |
| import { OverflowProvider } from './contexts/OverflowContext.js'; |
| import { |
| useOverflowActions, |
| type OverflowActions, |
| } from './contexts/OverflowContext.js'; |
|
|
| |
| vi.mock('ink', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('ink')>(); |
| return { |
| ...actual, |
| useStdout: () => ({ stdout: mocks.mockStdout }), |
| useApp: () => ({ |
| rerender: mocks.mockRerender, |
| }), |
| measureElement: vi.fn(), |
| }; |
| }); |
|
|
| import { InputContext, type InputState } from './contexts/InputContext.js'; |
| import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js'; |
|
|
| |
| |
| let capturedUIState: UIState; |
| let capturedInputState: InputState; |
| let capturedQuotaState: QuotaState; |
| let capturedUIActions: UIActions; |
| let capturedOverflowActions: OverflowActions; |
| function TestContextConsumer() { |
| capturedUIState = useContext(UIStateContext)!; |
| capturedInputState = useContext(InputContext)!; |
| capturedQuotaState = useContext(QuotaContext)!; |
| capturedUIActions = useContext(UIActionsContext)!; |
| capturedOverflowActions = useOverflowActions()!; |
| return null; |
| } |
|
|
| vi.mock('./App.js', () => ({ |
| App: TestContextConsumer, |
| })); |
|
|
| vi.mock('./hooks/useQuotaAndFallback.js'); |
| vi.mock('./hooks/useHistoryManager.js'); |
| vi.mock('./hooks/useThemeCommand.js'); |
| vi.mock('./auth/useAuth.js'); |
| vi.mock('../config/auth.js', () => ({ |
| validateAuthMethod: vi.fn().mockResolvedValue(null), |
| })); |
| vi.mock('./hooks/useEditorSettings.js'); |
| vi.mock('./hooks/useSettingsCommand.js'); |
| vi.mock('./hooks/useModelCommand.js'); |
| vi.mock('./hooks/slashCommandProcessor.js'); |
| vi.mock('./hooks/useConsoleMessages.js'); |
| vi.mock('./hooks/useTerminalSize.js', () => ({ |
| useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })), |
| })); |
| vi.mock('./hooks/useGeminiStream.js'); |
| vi.mock('./hooks/vim.js'); |
| vi.mock('./hooks/useFocus.js'); |
| vi.mock('./hooks/useBracketedPaste.js'); |
| vi.mock('./hooks/useLoadingIndicator.js'); |
| vi.mock('./hooks/useSuspend.js'); |
| vi.mock('./hooks/useFolderTrust.js'); |
| vi.mock('./hooks/useIdeTrustListener.js'); |
| vi.mock('./hooks/useMessageQueue.js'); |
| vi.mock('./hooks/useApprovalModeIndicator.js'); |
| vi.mock('./hooks/useGitBranchName.js'); |
| vi.mock('./hooks/useExtensionUpdates.js'); |
| vi.mock('./contexts/VimModeContext.js'); |
| vi.mock('./contexts/SessionContext.js'); |
| vi.mock('./components/shared/text-buffer.js'); |
| vi.mock('./hooks/useLogger.js'); |
| vi.mock('./hooks/useInputHistoryStore.js'); |
| vi.mock('./hooks/atCommandProcessor.js'); |
| vi.mock('./hooks/useHookDisplayState.js'); |
| vi.mock('./hooks/useBanner.js', () => ({ |
| useBanner: vi.fn((bannerData) => ({ |
| bannerText: ( |
| bannerData.warningText || |
| bannerData.defaultText || |
| '' |
| ).replace(/\\n/g, '\n'), |
| })), |
| })); |
| vi.mock('./hooks/useShellInactivityStatus.js', () => ({ |
| useShellInactivityStatus: vi.fn(() => ({ |
| shouldShowFocusHint: false, |
| inactivityStatus: 'none', |
| })), |
| })); |
| vi.mock('../utils/terminalNotifications.js', () => ({ |
| notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, |
| isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, |
| getNotificationMethod: terminalNotificationsMocks.getNotificationMethod, |
| buildRunEventNotificationContent: |
| terminalNotificationsMocks.buildRunEventNotificationContent, |
| })); |
| vi.mock('./hooks/useTerminalTheme.js', () => ({ |
| useTerminalTheme: vi.fn(), |
| })); |
|
|
| import { useHookDisplayState } from './hooks/useHookDisplayState.js'; |
| import { useTerminalTheme } from './hooks/useTerminalTheme.js'; |
| import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; |
| import { useFocus } from './hooks/useFocus.js'; |
|
|
| |
| vi.mock('../utils/events.js'); |
| vi.mock('../utils/handleAutoUpdate.js'); |
| vi.mock('./utils/ConsolePatcher.js'); |
| vi.mock('../utils/cleanup.js'); |
|
|
| import { useHistory } from './hooks/useHistoryManager.js'; |
| import { useThemeCommand } from './hooks/useThemeCommand.js'; |
| import { useAuthCommand } from './auth/useAuth.js'; |
| import { validateAuthMethod } from '../config/auth.js'; |
| import { useEditorSettings } from './hooks/useEditorSettings.js'; |
| import { useSettingsCommand } from './hooks/useSettingsCommand.js'; |
| import { useModelCommand } from './hooks/useModelCommand.js'; |
| import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; |
| import { useErrorCount } from './hooks/useConsoleMessages.js'; |
| import { useGeminiStream } from './hooks/useGeminiStream.js'; |
| import { useVim } from './hooks/vim.js'; |
| import { useFolderTrust } from './hooks/useFolderTrust.js'; |
| import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; |
| import { useMessageQueue } from './hooks/useMessageQueue.js'; |
| import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; |
| import { useGitBranchName } from './hooks/useGitBranchName.js'; |
| import { |
| useConfirmUpdateRequests, |
| useExtensionUpdates, |
| } from './hooks/useExtensionUpdates.js'; |
| import { useVimMode } from './contexts/VimModeContext.js'; |
| import { useSessionStats } from './contexts/SessionContext.js'; |
| import { useTextBuffer } from './components/shared/text-buffer.js'; |
| import { useLogger } from './hooks/useLogger.js'; |
| import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; |
| import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; |
| import { useKeypress, type Key } from './hooks/useKeypress.js'; |
| import * as useKeypressModule from './hooks/useKeypress.js'; |
| import { useSuspend } from './hooks/useSuspend.js'; |
| import { |
| writeToStdout, |
| enableMouseEvents, |
| disableMouseEvents, |
| } from '@google/gemini-cli-core'; |
| import { type ExtensionManager } from '../config/extension-manager.js'; |
| import { |
| WARNING_PROMPT_DURATION_MS, |
| EXPAND_HINT_DURATION_MS, |
| } from './constants.js'; |
|
|
| describe('AppContainer State Management', () => { |
| let mockConfig: Config; |
| let mockSettings: LoadedSettings; |
| let mockInitResult: InitializationResult; |
| let mockExtensionManager: MockedObject<ExtensionManager>; |
|
|
| type AppContainerProps = { |
| settings?: LoadedSettings; |
| config?: Config; |
| version?: string; |
| initResult?: InitializationResult; |
| startupWarnings?: StartupWarning[]; |
| resumedSessionData?: ResumedSessionData; |
| }; |
|
|
| |
| const getAppContainer = ({ |
| settings = mockSettings, |
| config = mockConfig, |
| version = '1.0.0', |
| initResult = mockInitResult, |
| startupWarnings, |
| resumedSessionData, |
| }: AppContainerProps = {}) => ( |
| <SettingsContext.Provider value={settings}> |
| <KeypressProvider config={config}> |
| <OverflowProvider> |
| <AppContainer |
| config={config} |
| version={version} |
| initializationResult={initResult} |
| startupWarnings={startupWarnings} |
| resumedSessionData={resumedSessionData} |
| /> |
| </OverflowProvider> |
| </KeypressProvider> |
| </SettingsContext.Provider> |
| ); |
|
|
| |
| const renderAppContainer = async (props?: AppContainerProps) => |
| render(getAppContainer(props)); |
|
|
| |
| const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock; |
| const mockedUseHistory = useHistory as Mock; |
| const mockedUseThemeCommand = useThemeCommand as Mock; |
| const mockedUseAuthCommand = useAuthCommand as Mock; |
| const mockedUseEditorSettings = useEditorSettings as Mock; |
| const mockedUseSettingsCommand = useSettingsCommand as Mock; |
| const mockedUseModelCommand = useModelCommand as Mock; |
| const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; |
| const mockedUseConsoleMessages = useErrorCount as Mock; |
| const mockedUseGeminiStream = useGeminiStream as Mock; |
| const mockedUseVim = useVim as Mock; |
| const mockedUseFolderTrust = useFolderTrust as Mock; |
| const mockedUseIdeTrustListener = useIdeTrustListener as Mock; |
| const mockedUseMessageQueue = useMessageQueue as Mock; |
| const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; |
| const mockedUseGitBranchName = useGitBranchName as Mock; |
| const mockedUseConfirmUpdateRequests = useConfirmUpdateRequests as Mock; |
| const mockedUseExtensionUpdates = useExtensionUpdates as Mock; |
| const mockedUseVimMode = useVimMode as Mock; |
| const mockedUseSessionStats = useSessionStats as Mock; |
| const mockedUseTextBuffer = useTextBuffer as Mock; |
| const mockedUseLogger = useLogger as Mock; |
| const mockedUseLoadingIndicator = useLoadingIndicator as Mock; |
| const mockedUseSuspend = useSuspend as Mock; |
| const mockedUseInputHistoryStore = useInputHistoryStore as Mock; |
| const mockedUseHookDisplayState = useHookDisplayState as Mock; |
| const mockedUseTerminalTheme = useTerminalTheme as Mock; |
| const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; |
| const mockedUseFocusState = useFocus as Mock; |
|
|
| const DEFAULT_GEMINI_STREAM_MOCK = { |
| streamingState: 'idle', |
| submitQuery: vi.fn(), |
| initError: null, |
| pendingHistoryItems: [], |
| thought: null, |
| cancelOngoingRequest: vi.fn(), |
| handleApprovalModeChange: vi.fn(), |
| activePtyId: null, |
| loopDetectionConfirmationRequest: null, |
| backgroundTaskCount: 0, |
| isBackgroundTaskVisible: false, |
| toggleBackgroundTasks: vi.fn(), |
| backgroundCurrentExecution: vi.fn(), |
| backgroundTasks: new Map(), |
| registerBackgroundTask: vi.fn(), |
| dismissBackgroundTask: vi.fn(), |
| }; |
|
|
| beforeEach(() => { |
| persistentStateMock.reset(); |
| vi.clearAllMocks(); |
|
|
| mockIdeClient.getInstance.mockReturnValue(new Promise(() => {})); |
|
|
| |
|
|
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| capturedUIState = null!; |
|
|
| |
| mockedUseQuotaAndFallback.mockReturnValue({ |
| proQuotaRequest: null, |
| handleProQuotaChoice: vi.fn(), |
| }); |
| mockedUseHistory.mockReturnValue({ |
| history: [], |
| addItem: vi.fn(), |
| updateItem: vi.fn(), |
| clearItems: vi.fn(), |
| loadHistory: vi.fn(), |
| }); |
| mockedUseThemeCommand.mockReturnValue({ |
| isThemeDialogOpen: false, |
| openThemeDialog: vi.fn(), |
| handleThemeSelect: vi.fn(), |
| handleThemeHighlight: vi.fn(), |
| }); |
| mockedUseAuthCommand.mockReturnValue({ |
| authState: 'authenticated', |
| setAuthState: vi.fn(), |
| authError: null, |
| onAuthError: vi.fn(), |
| }); |
| mockedUseEditorSettings.mockReturnValue({ |
| isEditorDialogOpen: false, |
| openEditorDialog: vi.fn(), |
| handleEditorSelect: vi.fn(), |
| exitEditorDialog: vi.fn(), |
| }); |
| mockedUseSettingsCommand.mockReturnValue({ |
| isSettingsDialogOpen: false, |
| openSettingsDialog: vi.fn(), |
| closeSettingsDialog: vi.fn(), |
| }); |
| mockedUseModelCommand.mockReturnValue({ |
| isModelDialogOpen: false, |
| openModelDialog: vi.fn(), |
| closeModelDialog: vi.fn(), |
| }); |
| mockedUseSlashCommandProcessor.mockReturnValue({ |
| handleSlashCommand: vi.fn(), |
| slashCommands: [], |
| pendingHistoryItems: [], |
| commandContext: {}, |
| shellConfirmationRequest: null, |
| confirmationRequest: null, |
| }); |
| mockedUseConsoleMessages.mockReturnValue({ |
| errorCount: 0, |
| handleNewMessage: vi.fn(), |
| clearErrorCount: vi.fn(), |
| }); |
| mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); |
| mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); |
| mockedUseFolderTrust.mockReturnValue({ |
| isFolderTrustDialogOpen: false, |
| handleFolderTrustSelect: vi.fn(), |
| isRestarting: false, |
| }); |
| mockedUseIdeTrustListener.mockReturnValue({ |
| needsRestart: false, |
| restartReason: 'NONE', |
| }); |
| mockedUseMessageQueue.mockReturnValue({ |
| messageQueue: [], |
| addMessage: vi.fn(), |
| clearQueue: vi.fn(), |
| getQueuedMessagesText: vi.fn().mockReturnValue(''), |
| }); |
| mockedUseApprovalModeIndicator.mockReturnValue(false); |
| mockedUseGitBranchName.mockReturnValue('main'); |
| mockedUseVimMode.mockReturnValue({ |
| isVimEnabled: false, |
| toggleVimEnabled: vi.fn(), |
| }); |
| mockedUseSessionStats.mockReturnValue({ stats: {} }); |
| mockedUseTextBuffer.mockReturnValue({ |
| text: '', |
| setText: vi.fn(), |
| lines: [''], |
| cursor: [0, 0], |
| handleInput: vi.fn().mockReturnValue(false), |
| }); |
| mockedUseLogger.mockReturnValue({ |
| getPreviousUserMessages: vi.fn().mockResolvedValue([]), |
| }); |
| mockedUseInputHistoryStore.mockReturnValue({ |
| inputHistory: [], |
| addInput: vi.fn(), |
| initializeFromLogger: vi.fn(), |
| }); |
| mockedUseLoadingIndicator.mockReturnValue({ |
| elapsedTime: '0.0s', |
| currentLoadingPhrase: '', |
| }); |
| mockedUseSuspend.mockReturnValue({ |
| handleSuspend: vi.fn(), |
| }); |
| mockedUseHookDisplayState.mockReturnValue([]); |
| mockedUseTerminalTheme.mockReturnValue(undefined); |
| mockedUseShellInactivityStatus.mockReturnValue({ |
| shouldShowFocusHint: false, |
| inactivityStatus: 'none', |
| }); |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: true, |
| hasReceivedFocusEvent: true, |
| }); |
| mockedUseConfirmUpdateRequests.mockReturnValue({ |
| addConfirmUpdateExtensionRequest: vi.fn(), |
| confirmUpdateExtensionRequests: [], |
| }); |
| mockedUseExtensionUpdates.mockReturnValue({ |
| extensionsUpdateState: new Map(), |
| extensionsUpdateStateInternal: new Map(), |
| dispatchExtensionStateUpdate: vi.fn(), |
| }); |
|
|
| |
| mockConfig = makeFakeConfig(); |
| vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false); |
|
|
| |
| vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); |
| vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); |
| vi.spyOn(mockConfig, 'getDebugMode').mockReturnValue(false); |
|
|
| mockExtensionManager = { |
| getExtensions: vi.fn().mockReturnValue([]), |
| setRequestConsent: vi.fn(), |
| setRequestSetting: vi.fn(), |
| start: vi.fn(), |
| } as unknown as MockedObject<ExtensionManager>; |
| vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue( |
| mockExtensionManager, |
| ); |
|
|
| |
| mockSettings = createMockSettings({ |
| hideBanner: false, |
| hideTips: false, |
| hideFooter: false, |
| showMemoryUsage: false, |
| theme: 'default', |
| ui: { |
| showStatusInTitle: false, |
| hideWindowTitle: false, |
| useAlternateBuffer: false, |
| }, |
| }); |
|
|
| |
| mockInitResult = { |
| themeError: null, |
| authError: null, |
| shouldOpenAuthDialog: false, |
| geminiMdFileCount: 0, |
| } as InitializationResult; |
| }); |
|
|
| afterEach(() => { |
| cleanup(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe('Basic Rendering', () => { |
| it('renders without crashing with minimal props', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('renders with startup warnings', async () => { |
| const startupWarnings: StartupWarning[] = [ |
| { |
| id: 'w1', |
| message: 'Warning 1', |
| priority: WarningPriority.High, |
| }, |
| { |
| id: 'w2', |
| message: 'Warning 2', |
| priority: WarningPriority.High, |
| }, |
| ]; |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ startupWarnings }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('shows full UI details by default', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState.cleanUiDetailsVisible).toBe(true); |
| unmount(); |
| }); |
|
|
| it('starts in minimal UI mode when Focus UI preference is persisted', async () => { |
| persistentStateMock.get.mockReturnValueOnce(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettings, |
| }), |
| ); |
|
|
| expect(capturedUIState.cleanUiDetailsVisible).toBe(false); |
| expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); |
| unmount(); |
| }); |
| }); |
|
|
| describe('State Initialization', () => { |
| it('calls validateAuthMethod and onAuthError if validation fails', async () => { |
| const mockOnAuthError = vi.fn(); |
| mockedUseAuthCommand.mockReturnValue({ |
| authState: 'authenticated', |
| setAuthState: vi.fn(), |
| authError: null, |
| onAuthError: mockOnAuthError, |
| }); |
| vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed'); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: createMockSettings({ |
| merged: { |
| security: { |
| auth: { selectedType: 'oauth-personal', useExternal: false }, |
| }, |
| }, |
| }), |
| }), |
| ); |
|
|
| await waitFor(() => { |
| expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal'); |
| expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed'); |
| }); |
|
|
| unmount(); |
| }); |
|
|
| it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: false, |
| hasReceivedFocusEvent: true, |
| }); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| callId: 'call-1', |
| name: 'run_shell_command', |
| description: 'Run command', |
| resultDisplay: undefined, |
| status: CoreToolCallStatus.AwaitingApproval, |
| confirmationDetails: { |
| type: 'exec', |
| title: 'Run shell command', |
| command: 'ls', |
| rootCommand: 'ls', |
| rootCommands: ['ls'], |
| }, |
| }, |
| ], |
| }, |
| ], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); |
| expect( |
| terminalNotificationsMocks.buildRunEventNotificationContent, |
| ).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'attention', |
| }), |
| ); |
|
|
| unmount(); |
| }); |
|
|
| it('does not send attention notification when terminal is focused', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: true, |
| hasReceivedFocusEvent: true, |
| }); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| callId: 'call-2', |
| name: 'run_shell_command', |
| description: 'Run command', |
| resultDisplay: undefined, |
| status: CoreToolCallStatus.AwaitingApproval, |
| confirmationDetails: { |
| type: 'exec', |
| title: 'Run shell command', |
| command: 'ls', |
| rootCommand: 'ls', |
| rootCommands: ['ls'], |
| }, |
| }, |
| ], |
| }, |
| ], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect( |
| terminalNotificationsMocks.notifyViaTerminal, |
| ).not.toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('sends attention notification when focus reporting is unavailable', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: true, |
| hasReceivedFocusEvent: false, |
| }); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| callId: 'call-focus-unknown', |
| name: 'run_shell_command', |
| description: 'Run command', |
| resultDisplay: undefined, |
| status: CoreToolCallStatus.AwaitingApproval, |
| confirmationDetails: { |
| type: 'exec', |
| title: 'Run shell command', |
| command: 'ls', |
| rootCommand: 'ls', |
| rootCommands: ['ls'], |
| }, |
| }, |
| ], |
| }, |
| ], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('sends a macOS notification when a response completes while unfocused', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: false, |
| hasReceivedFocusEvent: true, |
| }); |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: currentStreamingState, |
| })); |
|
|
| const { unmount, rerender } = await act(async () => renderAppContainer()); |
|
|
| currentStreamingState = 'idle'; |
| await act(async () => { |
| rerender(getAppContainer()); |
| }); |
|
|
| expect( |
| terminalNotificationsMocks.buildRunEventNotificationContent, |
| ).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'session_complete', |
| detail: 'Gemini CLI finished responding.', |
| }), |
| ); |
| expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('sends completion notification when focus reporting is unavailable', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: true, |
| hasReceivedFocusEvent: false, |
| }); |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: currentStreamingState, |
| })); |
|
|
| const { unmount, rerender } = await act(async () => renderAppContainer()); |
|
|
| currentStreamingState = 'idle'; |
| await act(async () => { |
| rerender(getAppContainer()); |
| }); |
|
|
| expect( |
| terminalNotificationsMocks.buildRunEventNotificationContent, |
| ).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'session_complete', |
| detail: 'Gemini CLI finished responding.', |
| }), |
| ); |
|
|
| unmount(); |
| }); |
|
|
| it('does not send completion notification when another action-required dialog is pending', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: false, |
| hasReceivedFocusEvent: true, |
| }); |
| mockedUseQuotaAndFallback.mockReturnValue({ |
| proQuotaRequest: { kind: 'upgrade' }, |
| handleProQuotaChoice: vi.fn(), |
| }); |
| let currentStreamingState: 'idle' | 'responding' = 'responding'; |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: currentStreamingState, |
| })); |
|
|
| const { unmount, rerender } = await act(async () => renderAppContainer()); |
|
|
| currentStreamingState = 'idle'; |
| await act(async () => { |
| rerender(getAppContainer()); |
| }); |
|
|
| expect( |
| terminalNotificationsMocks.notifyViaTerminal, |
| ).not.toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('can send repeated attention notifications for the same key after pending state clears', async () => { |
| mockedUseFocusState.mockReturnValue({ |
| isFocused: false, |
| hasReceivedFocusEvent: true, |
| }); |
|
|
| let pendingHistoryItems = [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| callId: 'repeat-key-call', |
| name: 'run_shell_command', |
| description: 'Run command', |
| resultDisplay: undefined, |
| status: CoreToolCallStatus.AwaitingApproval, |
| confirmationDetails: { |
| type: 'exec', |
| title: 'Run shell command', |
| command: 'ls', |
| rootCommand: 'ls', |
| rootCommands: ['ls'], |
| }, |
| }, |
| ], |
| }, |
| ]; |
|
|
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems, |
| })); |
|
|
| const { unmount, rerender } = await act(async () => renderAppContainer()); |
|
|
| expect( |
| terminalNotificationsMocks.notifyViaTerminal, |
| ).toHaveBeenCalledTimes(1); |
|
|
| pendingHistoryItems = []; |
| await act(async () => { |
| rerender(getAppContainer()); |
| }); |
|
|
| pendingHistoryItems = [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| callId: 'repeat-key-call', |
| name: 'run_shell_command', |
| description: 'Run command', |
| resultDisplay: undefined, |
| status: CoreToolCallStatus.AwaitingApproval, |
| confirmationDetails: { |
| type: 'exec', |
| title: 'Run shell command', |
| command: 'ls', |
| rootCommand: 'ls', |
| rootCommands: ['ls'], |
| }, |
| }, |
| ], |
| }, |
| ]; |
| await act(async () => { |
| rerender(getAppContainer()); |
| }); |
|
|
| expect( |
| terminalNotificationsMocks.notifyViaTerminal, |
| ).toHaveBeenCalledTimes(2); |
|
|
| unmount(); |
| }); |
|
|
| it('initializes with theme error from initialization result', async () => { |
| const initResultWithError = { |
| ...mockInitResult, |
| themeError: 'Failed to load theme', |
| }; |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| initResult: initResultWithError, |
| }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('handles debug mode state', async () => { |
| const debugConfig = makeFakeConfig(); |
| vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ config: debugConfig }), |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Context Providers', () => { |
| it('provides AppContext with correct values', async () => { |
| const { unmount } = await act(async () => |
| renderAppContainer({ version: '2.0.0' }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
|
|
| |
| unmount(); |
| }); |
|
|
| it('provides UIStateContext with state management', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('provides UIActionsContext with action handlers', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('provides ConfigContext with config object', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Settings Integration', () => { |
| it('handles settings with all display options disabled', async () => { |
| const settingsAllHidden = createMockSettings({ |
| hideBanner: true, |
| hideTips: true, |
| hideFooter: true, |
| showMemoryUsage: false, |
| }); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ settings: settingsAllHidden }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
|
|
| it('handles settings with memory usage enabled', async () => { |
| const settingsWithMemory = createMockSettings({ |
| showMemoryUsage: true, |
| }); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ settings: settingsWithMemory }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Version Handling', () => { |
| it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( |
| 'handles version format: %s', |
| async (version) => { |
| const { unmount } = await act(async () => |
| renderAppContainer({ version }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }, |
| ); |
| }); |
|
|
| describe('Error Handling', () => { |
| it('handles config methods that might throw', async () => { |
| const errorConfig = makeFakeConfig(); |
| vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { |
| throw new Error('Config error'); |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ config: errorConfig }), |
| ); |
| unmount(); |
| }); |
|
|
| it('handles undefined settings gracefully', async () => { |
| const undefinedSettings = createMockSettings(); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ settings: undefinedSettings }), |
| ); |
| expect(capturedUIState).toBeTruthy(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Provider Hierarchy', () => { |
| it('establishes correct provider nesting order', async () => { |
| |
| |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Session Resumption', () => { |
| it('handles resumed session data correctly', async () => { |
| const mockResumedSessionData = { |
| conversation: { |
| sessionId: 'test-session-123', |
| projectHash: 'test-project-hash', |
| startTime: '2024-01-01T00:00:00Z', |
| lastUpdated: '2024-01-01T00:00:01Z', |
| messages: [ |
| { |
| id: 'msg-1', |
| type: 'user' as const, |
| content: 'Hello', |
| timestamp: '2024-01-01T00:00:00Z', |
| }, |
| { |
| id: 'msg-2', |
| type: 'gemini' as const, |
| content: 'Hi there!', |
| role: 'model' as const, |
| parts: [{ text: 'Hi there!' }], |
| timestamp: '2024-01-01T00:00:01Z', |
| }, |
| ], |
| }, |
| filePath: '/tmp/test-session.json', |
| }; |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: mockConfig, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| resumedSessionData: mockResumedSessionData, |
| }), |
| ); |
| unmount(); |
| }); |
|
|
| it('renders without resumed session data', async () => { |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: mockConfig, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| resumedSessionData: undefined, |
| }), |
| ); |
| unmount(); |
| }); |
|
|
| it('initializes chat recording service when config has it', async () => { |
| const mockChatRecordingService = { |
| initialize: vi.fn(), |
| recordMessage: vi.fn(), |
| recordMessageTokens: vi.fn(), |
| recordToolCalls: vi.fn(), |
| }; |
|
|
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => true), |
| resumeChat: vi.fn(), |
| getUserTier: vi.fn(), |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), |
| }; |
|
|
| const configWithRecording = makeFakeConfig(); |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithRecording, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| }), |
| ); |
| unmount(); |
| }); |
| }); |
| describe('Session Recording Integration', () => { |
| it('provides chat recording service configuration', async () => { |
| const mockChatRecordingService = { |
| initialize: vi.fn(), |
| recordMessage: vi.fn(), |
| recordMessageTokens: vi.fn(), |
| recordToolCalls: vi.fn(), |
| getSessionId: vi.fn(() => 'test-session-123'), |
| getCurrentConversation: vi.fn(), |
| }; |
|
|
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => true), |
| resumeChat: vi.fn(), |
| getUserTier: vi.fn(), |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), |
| setHistory: vi.fn(), |
| }; |
|
|
| const configWithRecording = makeFakeConfig(); |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
| vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue( |
| 'test-session-123', |
| ); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithRecording, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| }), |
| ); |
|
|
| |
| expect(configWithRecording.getGeminiClient).toBeDefined(); |
| expect(mockGeminiClient.getChatRecordingService).toBeDefined(); |
| expect(mockChatRecordingService.initialize).toBeDefined(); |
| expect(mockChatRecordingService.recordMessage).toBeDefined(); |
| unmount(); |
| }); |
|
|
| it('handles session recording when messages are added', async () => { |
| const mockRecordMessage = vi.fn(); |
| const mockRecordMessageTokens = vi.fn(); |
|
|
| const mockChatRecordingService = { |
| initialize: vi.fn(), |
| recordMessage: mockRecordMessage, |
| recordMessageTokens: mockRecordMessageTokens, |
| recordToolCalls: vi.fn(), |
| getSessionId: vi.fn(() => 'test-session-123'), |
| }; |
|
|
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => true), |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), |
| getUserTier: vi.fn(), |
| }; |
|
|
| const configWithRecording = makeFakeConfig(); |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithRecording, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| }), |
| ); |
|
|
| |
| |
| expect(mockChatRecordingService.initialize).toBeDefined(); |
| expect(mockChatRecordingService.recordMessage).toBeDefined(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Session Resume Flow', () => { |
| it('accepts resumed session data', async () => { |
| const mockResumeChat = vi.fn(); |
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => true), |
| resumeChat: mockResumeChat, |
| getUserTier: vi.fn(), |
| getChatRecordingService: vi.fn(() => ({ |
| initialize: vi.fn(), |
| recordMessage: vi.fn(), |
| recordMessageTokens: vi.fn(), |
| recordToolCalls: vi.fn(), |
| })), |
| }; |
|
|
| const configWithClient = makeFakeConfig(); |
| vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
|
|
| const resumedData = { |
| conversation: { |
| sessionId: 'resumed-session-456', |
| projectHash: 'project-hash', |
| startTime: '2024-01-01T00:00:00Z', |
| lastUpdated: '2024-01-01T00:01:00Z', |
| messages: [ |
| { |
| id: 'msg-1', |
| type: 'user' as const, |
| content: 'Previous question', |
| timestamp: '2024-01-01T00:00:00Z', |
| }, |
| { |
| id: 'msg-2', |
| type: 'gemini' as const, |
| content: 'Previous answer', |
| role: 'model' as const, |
| parts: [{ text: 'Previous answer' }], |
| timestamp: '2024-01-01T00:00:30Z', |
| tokenCount: { input: 10, output: 20 }, |
| }, |
| ], |
| }, |
| filePath: '/tmp/resumed-session.json', |
| }; |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithClient, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| resumedSessionData: resumedData, |
| }), |
| ); |
|
|
| |
| expect(mockGeminiClient.resumeChat).toBeDefined(); |
| expect(resumedData.conversation.messages).toHaveLength(2); |
| unmount(); |
| }); |
|
|
| it('does not attempt resume when client is not initialized', async () => { |
| const mockResumeChat = vi.fn(); |
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => false), |
| resumeChat: mockResumeChat, |
| getUserTier: vi.fn(), |
| getChatRecordingService: vi.fn(), |
| }; |
|
|
| const configWithClient = makeFakeConfig(); |
| vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
|
|
| const resumedData = { |
| conversation: { |
| sessionId: 'test-session', |
| projectHash: 'project-hash', |
| startTime: '2024-01-01T00:00:00Z', |
| lastUpdated: '2024-01-01T00:01:00Z', |
| messages: [], |
| }, |
| filePath: '/tmp/session.json', |
| }; |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithClient, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| resumedSessionData: resumedData, |
| }), |
| ); |
|
|
| |
| expect(mockResumeChat).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('SessionStart Hook Rendering', () => { |
| it('does not render systemMessage directly (avoids duplicate with HookSystemMessage event)', async () => { |
| const mockAddItem = vi.fn(); |
| mockedUseHistory.mockReturnValue({ |
| history: [], |
| addItem: mockAddItem, |
| updateItem: vi.fn(), |
| clearItems: vi.fn(), |
| loadHistory: vi.fn(), |
| }); |
|
|
| const fireSessionStartEvent = vi.fn().mockResolvedValue({ |
| systemMessage: 'Hello from SessionStart hook', |
| getAdditionalContext: vi.fn(() => undefined), |
| }); |
| vi.spyOn(mockConfig, 'getHookSystem').mockReturnValue({ |
| fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), |
| fireSessionStartEvent, |
| } as unknown as ReturnType<Config['getHookSystem']>); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
| await waitFor(() => expect(fireSessionStartEvent).toHaveBeenCalled()); |
|
|
| |
| |
| |
| const directRenderCall = mockAddItem.mock.calls.find( |
| ([item]) => |
| item?.text === 'Hello from SessionStart hook' && !item?.source, |
| ); |
| expect(directRenderCall).toBeUndefined(); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Token Counting from Session Stats', () => { |
| it('tracks token counts from session messages', async () => { |
| |
| |
| const mockChatRecordingService = { |
| initialize: vi.fn(), |
| recordMessage: vi.fn(), |
| recordMessageTokens: vi.fn(), |
| recordToolCalls: vi.fn(), |
| getSessionId: vi.fn(() => 'test-session-123'), |
| getCurrentConversation: vi.fn(() => ({ |
| sessionId: 'test-session-123', |
| messages: [], |
| totalInputTokens: 150, |
| totalOutputTokens: 350, |
| })), |
| }; |
|
|
| const mockGeminiClient = { |
| isInitialized: vi.fn(() => true), |
| getChatRecordingService: vi.fn(() => mockChatRecordingService), |
| getUserTier: vi.fn(), |
| }; |
|
|
| const configWithRecording = makeFakeConfig(); |
| vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( |
| mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>, |
| ); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| config: configWithRecording, |
| settings: mockSettings, |
| version: '1.0.0', |
| initResult: mockInitResult, |
| }), |
| ); |
|
|
| |
| |
| expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); |
| expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Quota and Fallback Integration', () => { |
| it('passes a null proQuotaRequest to QuotaContext by default', async () => { |
| |
| const { unmount } = await act(async () => renderAppContainer()); |
| |
| expect(capturedQuotaState.proQuotaRequest).toBeNull(); |
| unmount(); |
| }); |
|
|
| it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => { |
| |
| const mockRequest = { |
| failedModel: 'gemini-pro', |
| fallbackModel: 'gemini-flash', |
| resolve: vi.fn(), |
| }; |
| mockedUseQuotaAndFallback.mockReturnValue({ |
| proQuotaRequest: mockRequest, |
| handleProQuotaChoice: vi.fn(), |
| }); |
|
|
| |
| const { unmount } = await act(async () => renderAppContainer()); |
| |
| expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest); |
| unmount(); |
| }); |
|
|
| it('passes the handleProQuotaChoice function to UIActionsContext', async () => { |
| |
| const mockHandler = vi.fn(); |
| mockedUseQuotaAndFallback.mockReturnValue({ |
| proQuotaRequest: null, |
| handleProQuotaChoice: mockHandler, |
| }); |
|
|
| |
| const { unmount } = await act(async () => renderAppContainer()); |
| |
| expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); |
|
|
| |
| act(() => { |
| capturedUIActions.handleProQuotaChoice('retry_later'); |
| }); |
| expect(mockHandler).toHaveBeenCalledWith('retry_later'); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Terminal Title Update Feature', () => { |
| beforeEach(() => { |
| |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
| }); |
|
|
| it('verifies useStdout is mocked', async () => { |
| const { useStdout } = await import('ink'); |
| const { stdout } = useStdout(); |
| expect(stdout).toBe(mocks.mockStdout); |
| }); |
|
|
| it('should update terminal title with Working… when showStatusInTitle is false', async () => { |
| |
| const mockSettingsWithShowStatusFalse = createMockSettings({ |
| ui: { |
| showStatusInTitle: false, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Some thought' }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithShowStatusFalse, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${'✦ Working… (workspace)'.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
|
|
| it('should use legacy terminal title when dynamicWindowTitle is false', async () => { |
| |
| const mockSettingsWithDynamicTitleFalse = createMockSettings({ |
| ui: { |
| dynamicWindowTitle: false, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Some thought' }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithDynamicTitleFalse, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${'Gemini CLI (workspace)'.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
|
|
| it('should not update terminal title when hideWindowTitle is true', async () => { |
| |
| const mockSettingsWithHideTitleTrue = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: true, |
| }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithHideTitleTrue, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(0); |
| unmount(); |
| }); |
|
|
| it('should update terminal title with thought subject when in active state', async () => { |
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| const thoughtSubject = 'Processing request'; |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: thoughtSubject }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${`✦ ${thoughtSubject} (workspace)`.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
|
|
| it('should update terminal title with default text when in Idle state and no thought subject', async () => { |
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${'◇ Ready (workspace)'.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
|
|
| it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => { |
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| const thoughtSubject = 'Confirm tool execution'; |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'waiting_for_confirmation', |
| thought: { subject: thoughtSubject }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
|
|
| describe('Shell Focus Action Required', () => { |
| beforeEach(async () => { |
| vi.useFakeTimers(); |
| |
| const actual = await vi.importActual< |
| typeof import('./hooks/useShellInactivityStatus.js') |
| >('./hooks/useShellInactivityStatus.js'); |
| mockedUseShellInactivityStatus.mockImplementation( |
| actual.useShellInactivityStatus, |
| ); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| it('should show Action Required in title after a delay when shell is awaiting focus', async () => { |
| const startTime = 1000000; |
| vi.setSystemTime(startTime); |
|
|
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Executing shell command' }, |
| pendingToolCalls: [], |
| activePtyId: 'pty-1', |
| lastOutputTime: startTime + 100, |
| retryStatus: null, |
| }); |
|
|
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
| expect(titleWrites[titleWrites.length - 1][0]).toContain( |
| '✦ Executing shell command', |
| ); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(40000); |
| }); |
|
|
| |
| const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( |
| (call) => call[0].includes('\x1b]0;'), |
| ); |
| const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; |
| expect(lastTitle).toContain('✋ Action Required'); |
|
|
| unmount(); |
| }); |
|
|
| it('should show Working… in title for redirected commands after 2 mins', async () => { |
| const startTime = 1000000; |
| vi.setSystemTime(startTime); |
|
|
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Executing shell command' }, |
| pendingToolCalls: [ |
| { |
| request: { |
| name: 'run_shell_command', |
| args: { command: 'ls > out' }, |
| }, |
| status: CoreToolCallStatus.Executing, |
| } as unknown as TrackedToolCall, |
| ], |
| activePtyId: 'pty-1', |
| lastOutputTime: startTime, |
| retryStatus: null, |
| }); |
|
|
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(65000); |
| }); |
|
|
| const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( |
| (call) => call[0].includes('\x1b]0;'), |
| ); |
| expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( |
| '✋ Action Required', |
| ); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(60000); |
| }); |
|
|
| const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( |
| (call) => call[0].includes('\x1b]0;'), |
| ); |
| expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( |
| '⏲ Working…', |
| ); |
|
|
| unmount(); |
| }); |
|
|
| it('should show Working… in title for silent non-redirected commands after 1 min', async () => { |
| const startTime = 1000000; |
| vi.setSystemTime(startTime); |
|
|
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Executing shell command' }, |
| pendingToolCalls: [], |
| activePtyId: 'pty-1', |
| lastOutputTime: startTime, |
| retryStatus: null, |
| }); |
|
|
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(65000); |
| }); |
|
|
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
| const lastTitle = titleWrites[titleWrites.length - 1][0]; |
| |
| expect(lastTitle).toContain('⏲ Working…'); |
|
|
| unmount(); |
| }); |
|
|
| it('should NOT show Action Required in title if shell is streaming output', async () => { |
| const startTime = 1000000; |
| vi.setSystemTime(startTime); |
|
|
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| let lastOutputTime = startTime + 1000; |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Executing shell command' }, |
| activePtyId: 'pty-1', |
| lastOutputTime, |
| })); |
|
|
| vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); |
| vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); |
|
|
| |
| const { unmount, rerender } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(20000); |
| }); |
|
|
| |
| lastOutputTime = startTime + 21000; |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: 'Executing shell command' }, |
| activePtyId: 'pty-1', |
| lastOutputTime, |
| })); |
|
|
| |
| await act(async () => { |
| rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); |
| }); |
|
|
| |
| |
| |
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(20000); |
| }); |
|
|
| const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( |
| (call) => call[0].includes('\x1b]0;'), |
| ); |
| const lastTitle = |
| titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; |
| expect(lastTitle).not.toContain('✋ Action Required'); |
| expect(lastTitle).toContain('✦ Executing shell command'); |
|
|
| |
| await act(async () => { |
| await vi.advanceTimersByTimeAsync(40000); |
| }); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
| const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; |
| expect(lastTitleFinal).toContain('✋ Action Required'); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| it('should pad title to exactly 80 characters', async () => { |
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| const shortTitle = 'Short'; |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: shortTitle }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| const calledWith = titleWrites[0][0]; |
| const expectedTitle = `✦ ${shortTitle} (workspace)`.padEnd(80, ' '); |
| const expectedEscapeSequence = `\x1b]0;${expectedTitle}\x07`; |
| expect(calledWith).toBe(expectedEscapeSequence); |
| unmount(); |
| }); |
|
|
| it('should use correct ANSI escape code format', async () => { |
| |
| const mockSettingsWithTitleEnabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: true, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| const title = 'Test Title'; |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| thought: { subject: title }, |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleEnabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| const expectedEscapeSequence = `\x1b]0;${`✦ ${title} (workspace)`.padEnd(80, ' ')}\x07`; |
| expect(titleWrites[0][0]).toBe(expectedEscapeSequence); |
| unmount(); |
| }); |
|
|
| it('should use CLI_TITLE environment variable when set', async () => { |
| |
| const mockSettingsWithTitleDisabled = createMockSettings({ |
| ui: { |
| showStatusInTitle: false, |
| hideWindowTitle: false, |
| }, |
| }); |
|
|
| |
| vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| }); |
|
|
| |
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: mockSettingsWithTitleDisabled, |
| }), |
| ); |
|
|
| |
| const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => |
| call[0].includes('\x1b]0;'), |
| ); |
|
|
| expect(titleWrites).toHaveLength(1); |
| expect(titleWrites[0][0]).toBe( |
| `\x1b]0;${'✦ Working… (Custom Gemini Title)'.padEnd(80, ' ')}\x07`, |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Queue Error Message', () => { |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| it('should set and clear the queue error message after a timeout', async () => { |
| const { rerender, unmount } = await act(async () => renderAppContainer()); |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
|
|
| expect(capturedUIState.queueErrorMessage).toBeNull(); |
|
|
| act(() => { |
| capturedUIActions.setQueueErrorMessage('Test error'); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBe('Test error'); |
|
|
| act(() => { |
| vi.advanceTimersByTime(3000); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBeNull(); |
| unmount(); |
| }); |
|
|
| it('should reset the timer if a new error message is set', async () => { |
| const { rerender, unmount } = await act(async () => renderAppContainer()); |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
|
|
| act(() => { |
| capturedUIActions.setQueueErrorMessage('First error'); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBe('First error'); |
|
|
| act(() => { |
| vi.advanceTimersByTime(1500); |
| }); |
|
|
| act(() => { |
| capturedUIActions.setQueueErrorMessage('Second error'); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBe('Second error'); |
|
|
| act(() => { |
| vi.advanceTimersByTime(2000); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBe('Second error'); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(1000); |
| }); |
| rerender(getAppContainer()); |
| expect(capturedUIState.queueErrorMessage).toBeNull(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => { |
| let mockHandleSlashCommand: Mock; |
| let mockCancelOngoingRequest: Mock; |
| let rerender: () => void; |
| let unmount: () => void; |
| let stdin: Awaited<ReturnType<typeof render>>['stdin']; |
|
|
| |
| const setupKeypressTest = async () => { |
| const renderResult = await act(async () => renderAppContainer()); |
| stdin = renderResult.stdin; |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
|
|
| rerender = () => { |
| renderResult.rerender(getAppContainer()); |
| }; |
| unmount = renderResult.unmount; |
| }; |
|
|
| const pressKey = (sequence: string, times = 1) => { |
| for (let i = 0; i < times; i++) { |
| act(() => { |
| stdin.write(sequence); |
| }); |
| rerender(); |
| } |
| }; |
|
|
| beforeEach(() => { |
| |
| mockHandleSlashCommand = vi.fn(); |
| mockedUseSlashCommandProcessor.mockReturnValue({ |
| handleSlashCommand: mockHandleSlashCommand, |
| slashCommands: [], |
| pendingHistoryItems: [], |
| commandContext: {}, |
| shellConfirmationRequest: null, |
| confirmationRequest: null, |
| }); |
|
|
| |
| mockCancelOngoingRequest = vi.fn(); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| cancelOngoingRequest: mockCancelOngoingRequest, |
| }); |
|
|
| |
| mockedUseTextBuffer.mockReturnValue({ |
| text: '', |
| setText: vi.fn(), |
| lines: [''], |
| cursor: [0, 0], |
| handleInput: vi.fn().mockReturnValue(false), |
| }); |
|
|
| vi.useFakeTimers(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe('CTRL+C', () => { |
| it('should cancel ongoing request on first press', async () => { |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| cancelOngoingRequest: mockCancelOngoingRequest, |
| }); |
| await setupKeypressTest(); |
|
|
| pressKey('\x03'); |
|
|
| expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
|
|
| it('should quit on second press', async () => { |
| await setupKeypressTest(); |
|
|
| pressKey('\x03', 2); |
|
|
| expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); |
| expect(mockHandleSlashCommand).toHaveBeenCalledWith( |
| '/quit', |
| undefined, |
| undefined, |
| false, |
| ); |
| unmount(); |
| }); |
|
|
| it('should reset press count after a timeout', async () => { |
| await setupKeypressTest(); |
|
|
| pressKey('\x03'); |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); |
| }); |
|
|
| pressKey('\x03'); |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('CTRL+D', () => { |
| it('should quit on second press if buffer is empty', async () => { |
| await setupKeypressTest(); |
|
|
| pressKey('\x04', 2); |
|
|
| expect(mockHandleSlashCommand).toHaveBeenCalledWith( |
| '/quit', |
| undefined, |
| undefined, |
| false, |
| ); |
| unmount(); |
| }); |
|
|
| it('should NOT quit if buffer is not empty', async () => { |
| mockedUseTextBuffer.mockReturnValue({ |
| text: 'some text', |
| setText: vi.fn(), |
| lines: ['some text'], |
| cursor: [0, 9], |
| handleInput: vi.fn().mockReturnValue(false), |
| }); |
| await setupKeypressTest(); |
|
|
| pressKey('\x04'); |
|
|
| |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
|
|
| pressKey('\x04'); |
| |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
|
|
| it('should reset press count after a timeout', async () => { |
| await setupKeypressTest(); |
|
|
| pressKey('\x04'); |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); |
| }); |
|
|
| pressKey('\x04'); |
| expect(mockHandleSlashCommand).not.toHaveBeenCalled(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('CTRL+Z', () => { |
| it('should call handleSuspend', async () => { |
| const handleSuspend = vi.fn(); |
| mockedUseSuspend.mockReturnValue({ handleSuspend }); |
| await setupKeypressTest(); |
|
|
| pressKey('\x1A'); |
|
|
| expect(handleSuspend).toHaveBeenCalledTimes(1); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Focus Handling (Tab / Shift+Tab)', () => { |
| beforeEach(() => { |
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: 1, |
| }); |
| }); |
|
|
| it('should focus shell input on Tab', async () => { |
| await setupKeypressTest(); |
|
|
| pressKey('\t'); |
|
|
| expect(capturedUIState.embeddedShellFocused).toBe(true); |
| unmount(); |
| }); |
|
|
| it('should unfocus shell input on Shift+Tab', async () => { |
| await setupKeypressTest(); |
|
|
| |
| pressKey('\t'); |
| expect(capturedUIState.embeddedShellFocused).toBe(true); |
|
|
| |
| pressKey('\x1b[Z'); |
| expect(capturedUIState.embeddedShellFocused).toBe(false); |
| unmount(); |
| }); |
|
|
| it('should auto-unfocus when activePtyId becomes null', async () => { |
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: 1, |
| }); |
|
|
| const renderResult = await act(async () => render(getAppContainer())); |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
|
|
| |
| act(() => { |
| renderResult.stdin.write('\t'); |
| }); |
| expect(capturedUIState.embeddedShellFocused).toBe(true); |
|
|
| |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: null, |
| }); |
|
|
| |
| await act(async () => { |
| renderResult.rerender(getAppContainer()); |
| }); |
|
|
| expect(capturedUIState.embeddedShellFocused).toBe(false); |
| renderResult.unmount(); |
| }); |
|
|
| it('should focus background shell on Tab when already visible (not toggle it off)', async () => { |
| const mockToggleBackgroundTask = vi.fn(); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: null, |
| isBackgroundTaskVisible: true, |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), |
| toggleBackgroundTasks: mockToggleBackgroundTask, |
| }); |
|
|
| await setupKeypressTest(); |
|
|
| |
| expect(capturedUIState.embeddedShellFocused).toBe(false); |
|
|
| |
| pressKey('\t'); |
|
|
| |
| expect(capturedUIState.embeddedShellFocused).toBe(true); |
| |
| expect(mockToggleBackgroundTask).not.toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Background Shell Toggling (CTRL+B)', () => { |
| it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { |
| const mockToggleBackgroundTask = vi.fn(); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: null, |
| isBackgroundTaskVisible: true, |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), |
| toggleBackgroundTasks: mockToggleBackgroundTask, |
| }); |
|
|
| await setupKeypressTest(); |
|
|
| |
| expect(capturedUIState.embeddedShellFocused).toBe(false); |
|
|
| |
| pressKey('\x02'); |
|
|
| |
| expect(mockToggleBackgroundTask).toHaveBeenCalled(); |
| |
| expect(capturedUIState.embeddedShellFocused).toBe(false); |
|
|
| unmount(); |
| }); |
|
|
| it('should show and focus background shell on Ctrl+B if hidden', async () => { |
| const mockToggleBackgroundTask = vi.fn(); |
| const geminiStreamMock = { |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| activePtyId: null, |
| isBackgroundTaskVisible: false, |
| backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), |
| toggleBackgroundTasks: mockToggleBackgroundTask, |
| }; |
| mockedUseGeminiStream.mockReturnValue(geminiStreamMock); |
|
|
| await setupKeypressTest(); |
|
|
| |
| mockToggleBackgroundTask.mockImplementation(() => { |
| geminiStreamMock.isBackgroundTaskVisible = true; |
| }); |
|
|
| |
| pressKey('\x02'); |
|
|
| |
| expect(mockToggleBackgroundTask).toHaveBeenCalled(); |
| |
| expect(capturedUIState.embeddedShellFocused).toBe(true); |
|
|
| unmount(); |
| }); |
| }); |
| }); |
|
|
| describe('Shortcuts Help Visibility', () => { |
| let handleGlobalKeypress: (key: Key) => boolean; |
| let mockedUseKeypress: Mock; |
| let rerender: () => void; |
| let unmount: () => void; |
|
|
| const setupShortcutsVisibilityTest = async () => { |
| const renderResult = await act(async () => renderAppContainer()); |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
| rerender = () => renderResult.rerender(getAppContainer()); |
| unmount = renderResult.unmount; |
| }; |
|
|
| const pressKey = (key: Partial<Key>) => { |
| act(() => { |
| handleGlobalKeypress({ |
| name: 'r', |
| shift: false, |
| alt: false, |
| ctrl: false, |
| cmd: false, |
| insertable: false, |
| sequence: '', |
| ...key, |
| } as Key); |
| }); |
| rerender(); |
| }; |
|
|
| beforeEach(() => { |
| mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock; |
| mockedUseKeypress.mockImplementation( |
| (callback: (key: Key) => boolean, options: { isActive: boolean }) => { |
| |
| |
| if (options?.isActive) { |
| handleGlobalKeypress = callback; |
| } |
| }, |
| ); |
| vi.useFakeTimers(); |
| }); |
|
|
| afterEach(() => { |
| mockedUseKeypress.mockRestore(); |
| vi.useRealTimers(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| it('dismisses shortcuts help when a registered hotkey is pressed', async () => { |
| await setupShortcutsVisibilityTest(); |
|
|
| act(() => { |
| capturedUIActions.setShortcutsHelpVisible(true); |
| }); |
| rerender(); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); |
|
|
| pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); |
|
|
| unmount(); |
| }); |
|
|
| it('dismisses shortcuts help when streaming starts', async () => { |
| await setupShortcutsVisibilityTest(); |
|
|
| act(() => { |
| capturedUIActions.setShortcutsHelpVisible(true); |
| }); |
| rerender(); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); |
|
|
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: 'responding', |
| }); |
|
|
| await act(async () => { |
| rerender(); |
| }); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); |
|
|
| unmount(); |
| }); |
|
|
| it('dismisses shortcuts help when action-required confirmation appears', async () => { |
| await setupShortcutsVisibilityTest(); |
|
|
| act(() => { |
| capturedUIActions.setShortcutsHelpVisible(true); |
| }); |
| rerender(); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(true); |
|
|
| mockedUseSlashCommandProcessor.mockReturnValue({ |
| handleSlashCommand: vi.fn(), |
| slashCommands: [], |
| pendingHistoryItems: [], |
| commandContext: {}, |
| shellConfirmationRequest: null, |
| confirmationRequest: { |
| prompt: 'Confirm this action?', |
| onConfirm: vi.fn(), |
| }, |
| }); |
|
|
| await act(async () => { |
| rerender(); |
| }); |
| expect(capturedUIState.shortcutsHelpVisible).toBe(false); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Copy Mode (F9)', () => { |
| let rerender: () => void; |
| let unmount: () => void; |
| let stdin: Awaited<ReturnType<typeof render>>['stdin']; |
|
|
| const setupCopyModeTest = async ( |
| isAlternateMode = false, |
| childHandler?: Mock, |
| ) => { |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); |
|
|
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( |
| isAlternateMode, |
| ); |
|
|
| |
| const testSettings = createMockSettings({ |
| ui: { useAlternateBuffer: isAlternateMode }, |
| }); |
|
|
| function TestChild() { |
| useKeypress(childHandler || (() => {}), { |
| isActive: !!childHandler, |
| priority: true, |
| }); |
| return null; |
| } |
|
|
| const getTree = (settings: LoadedSettings) => ( |
| <SettingsContext.Provider value={settings}> |
| <KeypressProvider config={mockConfig}> |
| <OverflowProvider> |
| <AppContainer |
| config={mockConfig} |
| version="1.0.0" |
| initializationResult={mockInitResult} |
| /> |
| <TestChild /> |
| </OverflowProvider> |
| </KeypressProvider> |
| </SettingsContext.Provider> |
| ); |
|
|
| const renderResult = await act(async () => render(getTree(testSettings))); |
| stdin = renderResult.stdin; |
| await act(async () => { |
| vi.advanceTimersByTime(0); |
| }); |
|
|
| rerender = () => renderResult.rerender(getTree(testSettings)); |
| unmount = renderResult.unmount; |
| }; |
|
|
| beforeEach(() => { |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| vi.useFakeTimers(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe.each([ |
| { |
| isAlternateMode: false, |
| shouldEnable: false, |
| modeName: 'Normal Mode', |
| }, |
| { |
| isAlternateMode: true, |
| shouldEnable: true, |
| modeName: 'Alternate Buffer Mode', |
| }, |
| ])('$modeName', ({ isAlternateMode, shouldEnable }) => { |
| it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => { |
| await setupCopyModeTest(isAlternateMode); |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| act(() => { |
| stdin.write('\x1b[20~'); |
| }); |
| rerender(); |
|
|
| if (shouldEnable) { |
| expect(disableMouseEvents).toHaveBeenCalled(); |
| } else { |
| expect(disableMouseEvents).not.toHaveBeenCalled(); |
| } |
| unmount(); |
| }); |
|
|
| if (shouldEnable) { |
| it('should toggle mouse back on when F9 is pressed again', async () => { |
| await setupCopyModeTest(isAlternateMode); |
| (writeToStdout as Mock).mockClear(); |
|
|
| |
| act(() => { |
| stdin.write('\x1b[20~'); |
| }); |
| rerender(); |
| expect(disableMouseEvents).toHaveBeenCalled(); |
|
|
| |
| act(() => { |
| stdin.write('a'); |
| }); |
| rerender(); |
|
|
| expect(enableMouseEvents).toHaveBeenCalled(); |
| unmount(); |
| }); |
|
|
| it('should exit copy mode on non-scroll key press', async () => { |
| await setupCopyModeTest(isAlternateMode); |
|
|
| |
| act(() => { |
| stdin.write('\x1b[20~'); |
| }); |
| rerender(); |
|
|
| (writeToStdout as Mock).mockClear(); |
|
|
| |
| act(() => { |
| stdin.write('a'); |
| }); |
| rerender(); |
|
|
| |
| expect(enableMouseEvents).toHaveBeenCalled(); |
| unmount(); |
| }); |
|
|
| it('should not exit copy mode on PageDown and should pass it through', async () => { |
| const childHandler = vi.fn().mockReturnValue(false); |
| await setupCopyModeTest(true, childHandler); |
|
|
| |
| act(() => { |
| stdin.write('\x13'); |
| }); |
| rerender(); |
| expect(disableMouseEvents).toHaveBeenCalled(); |
|
|
| childHandler.mockClear(); |
| (enableMouseEvents as Mock).mockClear(); |
|
|
| |
| act(() => { |
| stdin.write('\x1b[6~'); |
| }); |
| rerender(); |
|
|
| expect(enableMouseEvents).not.toHaveBeenCalled(); |
| expect(childHandler).toHaveBeenCalled(); |
| expect(childHandler).toHaveBeenCalledWith( |
| expect.objectContaining({ name: 'pagedown' }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should not exit copy mode on Shift+Down and should pass it through', async () => { |
| const childHandler = vi.fn().mockReturnValue(false); |
| await setupCopyModeTest(true, childHandler); |
|
|
| |
| act(() => { |
| stdin.write('\x13'); |
| }); |
| rerender(); |
| expect(disableMouseEvents).toHaveBeenCalled(); |
|
|
| childHandler.mockClear(); |
| (enableMouseEvents as Mock).mockClear(); |
|
|
| act(() => { |
| stdin.write('\x1b[1;2B'); |
| }); |
| rerender(); |
|
|
| expect(enableMouseEvents).not.toHaveBeenCalled(); |
| expect(childHandler).toHaveBeenCalled(); |
| expect(childHandler).toHaveBeenCalledWith( |
| expect.objectContaining({ name: 'down', shift: true }), |
| ); |
| unmount(); |
| }); |
|
|
| it('should have higher priority than other priority listeners when enabled', async () => { |
| |
| |
| const childHandler = vi.fn().mockReturnValue(false); |
| await setupCopyModeTest(true, childHandler); |
|
|
| |
| act(() => { |
| stdin.write('\x1b[20~'); |
| }); |
| rerender(); |
|
|
| |
| expect(disableMouseEvents).toHaveBeenCalled(); |
|
|
| |
| childHandler.mockClear(); |
| |
| childHandler.mockReturnValue(true); |
|
|
| act(() => { |
| stdin.write('a'); |
| }); |
| rerender(); |
|
|
| |
| expect(childHandler).not.toHaveBeenCalled(); |
| expect(enableMouseEvents).toHaveBeenCalled(); |
| unmount(); |
| }); |
| } |
| }); |
| }); |
|
|
| describe('Model Dialog Integration', () => { |
| it('should provide isModelDialogOpen in the UIStateContext', async () => { |
| mockedUseModelCommand.mockReturnValue({ |
| isModelDialogOpen: true, |
| openModelDialog: vi.fn(), |
| closeModelDialog: vi.fn(), |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState.isModelDialogOpen).toBe(true); |
| unmount(); |
| }); |
|
|
| it('should provide model dialog actions in the UIActionsContext', async () => { |
| const mockCloseModelDialog = vi.fn(); |
|
|
| mockedUseModelCommand.mockReturnValue({ |
| isModelDialogOpen: false, |
| openModelDialog: vi.fn(), |
| closeModelDialog: mockCloseModelDialog, |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| |
| act(() => { |
| capturedUIActions.closeModelDialog(); |
| }); |
| expect(mockCloseModelDialog).toHaveBeenCalled(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Agent Configuration Dialog Integration', () => { |
| it('should initialize with dialog closed and no agent selected', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); |
| expect(capturedUIState.selectedAgentName).toBeUndefined(); |
| expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); |
| expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); |
| unmount(); |
| }); |
|
|
| it('should update state when openAgentConfigDialog is called', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| const agentDefinition = { name: 'test-agent' }; |
| act(() => { |
| capturedUIActions.openAgentConfigDialog( |
| 'test-agent', |
| 'Test Agent', |
| agentDefinition as unknown as AgentDefinition, |
| ); |
| }); |
|
|
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); |
| expect(capturedUIState.selectedAgentName).toBe('test-agent'); |
| expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); |
| expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); |
| unmount(); |
| }); |
|
|
| it('should clear state when closeAgentConfigDialog is called', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| const agentDefinition = { name: 'test-agent' }; |
| act(() => { |
| capturedUIActions.openAgentConfigDialog( |
| 'test-agent', |
| 'Test Agent', |
| agentDefinition as unknown as AgentDefinition, |
| ); |
| }); |
|
|
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(true); |
|
|
| act(() => { |
| capturedUIActions.closeAgentConfigDialog(); |
| }); |
|
|
| expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); |
| expect(capturedUIState.selectedAgentName).toBeUndefined(); |
| expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); |
| expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); |
| unmount(); |
| }); |
| }); |
|
|
| describe('CoreEvents Integration', () => { |
| it('subscribes to UserFeedback and drains backlog on mount', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(mockCoreEvents.on).toHaveBeenCalledWith( |
| CoreEvent.UserFeedback, |
| expect.any(Function), |
| ); |
| expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); |
| unmount(); |
| }); |
|
|
| it('unsubscribes from UserFeedback on unmount', async () => { |
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| unmount!(); |
|
|
| expect(mockCoreEvents.off).toHaveBeenCalledWith( |
| CoreEvent.UserFeedback, |
| expect.any(Function), |
| ); |
| }); |
|
|
| it('adds history item when UserFeedback event is received', async () => { |
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| |
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.UserFeedback, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| |
| const payload: UserFeedbackPayload = { |
| severity: 'error', |
| message: 'Test error message', |
| }; |
| act(() => { |
| handler(payload); |
| }); |
|
|
| expect(mockedUseHistory().addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: 'error', |
| text: 'Test error message', |
| }), |
| expect.any(Number), |
| ); |
| unmount!(); |
| }); |
|
|
| it('calls app.rerender() when ExternalEditorClosed event is received in terminalBuffer mode', async () => { |
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(true); |
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(false); |
| vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(false); |
|
|
| mocks.mockRerender.mockClear(); |
|
|
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.ExternalEditorClosed, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| act(() => { |
| handler(); |
| }); |
|
|
| expect(mocks.mockRerender).toHaveBeenCalledTimes(1); |
| unmount!(); |
| }); |
|
|
| it('updates currentModel when ModelChanged event is received', async () => { |
| |
| vi.spyOn(mockConfig, 'getModel').mockReturnValue('initial-model'); |
|
|
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => { |
| expect(capturedUIState?.currentModel).toBe('initial-model'); |
| }); |
|
|
| |
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.ModelChanged, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| |
| |
| vi.spyOn(mockConfig, 'getModel').mockReturnValue('new-model'); |
| act(() => { |
| handler({ model: 'new-model' }); |
| }); |
|
|
| |
| await waitFor(() => { |
| expect(capturedUIState.currentModel).toBe('new-model'); |
| }); |
| unmount!(); |
| }); |
|
|
| it('provides activeHooks from useHookDisplayState', async () => { |
| const mockHooks = [{ name: 'hook1', eventName: 'event1' }]; |
| mockedUseHookDisplayState.mockReturnValue(mockHooks); |
|
|
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| expect(capturedUIState.activeHooks).toEqual(mockHooks); |
| unmount!(); |
| }); |
|
|
| it('handles consent request events', async () => { |
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| const handler = mockCoreEvents.on.mock.calls.find( |
| (call: unknown[]) => call[0] === CoreEvent.ConsentRequest, |
| )?.[1]; |
| expect(handler).toBeDefined(); |
|
|
| const onConfirm = vi.fn(); |
| const payload = { |
| prompt: 'Do you consent?', |
| onConfirm, |
| }; |
|
|
| act(() => { |
| handler(payload); |
| }); |
|
|
| expect(capturedUIState.authConsentRequest).toBeDefined(); |
| expect(capturedUIState.authConsentRequest?.prompt).toBe( |
| 'Do you consent?', |
| ); |
|
|
| act(() => { |
| capturedUIState.authConsentRequest?.onConfirm(true); |
| }); |
|
|
| expect(onConfirm).toHaveBeenCalledWith(true); |
| expect(capturedUIState.authConsentRequest).toBeNull(); |
| unmount!(); |
| }); |
|
|
| it('unsubscribes from ConsentRequest on unmount', async () => { |
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => expect(capturedUIState).toBeTruthy()); |
|
|
| unmount!(); |
|
|
| expect(mockCoreEvents.off).toHaveBeenCalledWith( |
| CoreEvent.ConsentRequest, |
| expect.any(Function), |
| ); |
| }); |
| }); |
|
|
| describe('Banner Text', () => { |
| it('should render placeholder banner text for USE_GEMINI auth type', async () => { |
| const config = makeFakeConfig(); |
| vi.spyOn(config, 'getContentGeneratorConfig').mockReturnValue({ |
| authType: AuthType.USE_GEMINI, |
| apiKey: 'fake-key', |
| }); |
| let unmount: () => void; |
| await act(async () => { |
| const result = await renderAppContainer(); |
| unmount = result.unmount; |
| }); |
| await waitFor(() => { |
| expect(capturedUIState.bannerData.defaultText).toBeDefined(); |
| unmount!(); |
| }); |
| }); |
| }); |
|
|
| describe('onCancelSubmit Behavior', () => { |
| let mockSetText: Mock; |
|
|
| |
| |
| const extractUseGeminiStreamArgs = (args: unknown[]) => ({ |
| onCancelSubmit: args[13] as (shouldRestorePrompt?: boolean) => void, |
| }); |
|
|
| beforeEach(() => { |
| mockSetText = vi.fn(); |
| mockedUseTextBuffer.mockReturnValue({ |
| text: '', |
| setText: mockSetText, |
| }); |
| }); |
|
|
| it('preserves buffer when cancelling, even if empty (user is in control)', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| const { onCancelSubmit } = extractUseGeminiStreamArgs( |
| mockedUseGeminiStream.mock.lastCall!, |
| ); |
|
|
| act(() => { |
| onCancelSubmit(false); |
| }); |
|
|
| |
| expect(mockSetText).not.toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { |
| |
| const promptText = 'What is Python?'; |
| mockedUseTextBuffer.mockReturnValue({ |
| text: promptText, |
| setText: mockSetText, |
| }); |
|
|
| |
| mockedUseInputHistoryStore.mockReturnValue({ |
| inputHistory: [promptText], |
| addInput: vi.fn(), |
| initializeFromLogger: vi.fn(), |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| const { onCancelSubmit } = extractUseGeminiStreamArgs( |
| mockedUseGeminiStream.mock.lastCall!, |
| ); |
|
|
| act(() => { |
| |
| onCancelSubmit(false); |
| }); |
|
|
| |
| expect(mockSetText).not.toHaveBeenCalled(); |
|
|
| unmount(); |
| }); |
|
|
| it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { |
| |
| mockedUseInputHistoryStore.mockReturnValue({ |
| inputHistory: ['previous message'], |
| addInput: vi.fn(), |
| initializeFromLogger: vi.fn(), |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
| expect(capturedInputState.userMessages).toContain('previous message'); |
|
|
| const { onCancelSubmit } = extractUseGeminiStreamArgs( |
| mockedUseGeminiStream.mock.lastCall!, |
| ); |
|
|
| await act(async () => { |
| onCancelSubmit(true); |
| }); |
|
|
| expect(mockSetText).toHaveBeenCalledWith('previous message'); |
|
|
| unmount(); |
| }); |
|
|
| it('input history is independent from conversation history (survives /clear)', async () => { |
| |
| |
| const mockAddInput = vi.fn(); |
| mockedUseInputHistoryStore.mockReturnValue({ |
| inputHistory: ['first prompt', 'second prompt'], |
| addInput: mockAddInput, |
| initializeFromLogger: vi.fn(), |
| }); |
|
|
| const { rerender, unmount } = await act(async () => renderAppContainer()); |
|
|
| |
| expect(capturedInputState.userMessages).toContain('first prompt'); |
| expect(capturedInputState.userMessages).toContain('second prompt'); |
|
|
| |
| const mockClearItems = vi.fn(); |
| mockedUseHistory.mockReturnValue({ |
| history: [], |
| addItem: vi.fn(), |
| updateItem: vi.fn(), |
| clearItems: mockClearItems, |
| loadHistory: vi.fn(), |
| }); |
|
|
| await act(async () => { |
| |
| rerender(getAppContainer()); |
| }); |
|
|
| |
| |
| expect(capturedInputState.userMessages).toContain('first prompt'); |
| expect(capturedInputState.userMessages).toContain('second prompt'); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Regression Tests', () => { |
| it('does not refresh static on startup if banner text is empty', async () => { |
| |
| vi.spyOn(mockConfig, 'getBannerTextNoCapacityIssues').mockResolvedValue( |
| '', |
| ); |
| vi.spyOn(mockConfig, 'getBannerTextCapacityIssues').mockResolvedValue(''); |
|
|
| |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| |
| expect(capturedUIState).toBeTruthy(); |
|
|
| |
| await act(async () => { |
| await new Promise((resolve) => setTimeout(resolve, 100)); |
| }); |
|
|
| |
| const clearTerminalCalls = mocks.mockStdout.write.mock.calls.filter( |
| (call: unknown[]) => call[0] === ansiEscapes.clearTerminal, |
| ); |
|
|
| expect(clearTerminalCalls).toHaveLength(0); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Submission Handling', () => { |
| it('resets expansion state on submission when not in alternate buffer', async () => { |
| const { checkPermissions } = await import( |
| './hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(checkPermissions).mockResolvedValue([]); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: createMockSettings({ ui: { useAlternateBuffer: false } }), |
| }), |
| ); |
|
|
| expect(capturedUIActions).toBeTruthy(); |
|
|
| |
| act(() => capturedUIActions.setConstrainHeight(false)); |
| expect(capturedUIState.constrainHeight).toBe(false); |
|
|
| |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| |
| await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); |
|
|
| |
| expect(capturedUIState.constrainHeight).toBe(true); |
| unmount(); |
| }); |
|
|
| it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { |
| const { checkPermissions } = await import( |
| './hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(checkPermissions).mockResolvedValue([]); |
|
|
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); |
|
|
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: createMockSettings({ ui: { useAlternateBuffer: true } }), |
| }), |
| ); |
|
|
| expect(capturedUIActions).toBeTruthy(); |
|
|
| |
| act(() => capturedUIActions.setConstrainHeight(false)); |
| expect(capturedUIState.constrainHeight).toBe(false); |
|
|
| |
| mocks.mockStdout.write.mockClear(); |
| (disableMouseEvents as import('vitest').Mock).mockClear(); |
|
|
| |
| await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); |
|
|
| |
| expect(capturedUIState.constrainHeight).toBe(true); |
| |
| expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( |
| ansiEscapes.clearTerminal, |
| ); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Overflow Hint Handling', () => { |
| beforeEach(() => { |
| vi.useFakeTimers(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id'); |
| }); |
|
|
| await waitFor(() => { |
| |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(100); |
| }); |
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(false); |
| }); |
|
|
| unmount(); |
| }); |
|
|
| it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id-1'); |
| }); |
|
|
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id-2'); |
| }); |
|
|
| |
| |
| act(() => { |
| vi.advanceTimersByTime(1); |
| }); |
|
|
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| |
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); |
| }); |
| |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); |
| }); |
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(false); |
| }); |
|
|
| unmount(); |
| }); |
|
|
| it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { |
| const { stdin, unmount } = await act(async () => renderAppContainer()); |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); |
|
|
| |
| expect(capturedUIState.constrainHeight).toBe(true); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id'); |
| }); |
|
|
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| stdin.write('\x0f'); |
| }); |
|
|
| |
| expect(capturedUIState.constrainHeight).toBe(false); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); |
| }); |
|
|
| |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); |
| }); |
|
|
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(false); |
| }); |
|
|
| unmount(); |
| }); |
|
|
| it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { |
| const { stdin, unmount } = await act(async () => renderAppContainer()); |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); |
|
|
| |
| expect(capturedUIState.constrainHeight).toBe(true); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id'); |
| }); |
|
|
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| stdin.write('\x0f'); |
| }); |
| expect(capturedUIState.constrainHeight).toBe(false); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(1000); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| stdin.write('\x0f'); |
| }); |
| expect(capturedUIState.constrainHeight).toBe(true); |
|
|
| |
| act(() => { |
| vi.advanceTimersByTime(1000); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| act(() => { |
| stdin.write('\x0f'); |
| }); |
| expect(capturedUIState.constrainHeight).toBe(false); |
|
|
| |
| |
| act(() => { |
| vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); |
| }); |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
|
|
| |
| |
| act(() => { |
| vi.advanceTimersByTime(100); |
| }); |
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(false); |
| }); |
|
|
| unmount(); |
| }); |
|
|
| it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { |
| const settingsWithAlternateBuffer = createMockSettings({ |
| ui: { useAlternateBuffer: true }, |
| }); |
|
|
| vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); |
|
|
| vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); |
|
|
| const { unmount } = await act(async () => |
| renderAppContainer({ |
| settings: settingsWithAlternateBuffer, |
| }), |
| ); |
| await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); |
|
|
| |
| act(() => { |
| capturedOverflowActions.addOverflowingId('test-id'); |
| }); |
|
|
| |
| await waitFor(() => { |
| expect(capturedUIState.showIsExpandableHint).toBe(true); |
| }); |
|
|
| unmount(); |
| }); |
| }); |
|
|
| describe('Permission Handling', () => { |
| it('shows permission dialog when checkPermissions returns paths', async () => { |
| const { checkPermissions } = await import( |
| './hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIActions).toBeTruthy(); |
|
|
| await act(async () => |
| capturedUIActions.handleFinalSubmit('read @file.txt'), |
| ); |
|
|
| expect(capturedUIState.permissionConfirmationRequest).not.toBeNull(); |
| expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ |
| '/test/file.txt', |
| ]); |
| unmount(); |
| }); |
|
|
| it.each([true, false])( |
| 'handles permissions when allowed is %s', |
| async (allowed) => { |
| const { checkPermissions } = await import( |
| './hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); |
| const addReadOnlyPathSpy = vi.spyOn( |
| mockConfig.getWorkspaceContext(), |
| 'addReadOnlyPath', |
| ); |
| const { submitQuery } = mockedUseGeminiStream(); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIActions).toBeTruthy(); |
|
|
| await act(async () => |
| capturedUIActions.handleFinalSubmit('read @file.txt'), |
| ); |
|
|
| await act(async () => |
| capturedUIState.permissionConfirmationRequest?.onComplete({ |
| allowed, |
| }), |
| ); |
|
|
| if (allowed) { |
| expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt'); |
| } else { |
| expect(addReadOnlyPathSpy).not.toHaveBeenCalled(); |
| } |
| expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); |
| expect(capturedUIState.permissionConfirmationRequest).toBeNull(); |
| unmount(); |
| }, |
| ); |
| }); |
|
|
| describe('Plan Mode Availability', () => { |
| it('should allow plan mode when enabled and idle', async () => { |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState).toBeTruthy(); |
| expect(capturedUIState.allowPlanMode).toBe(true); |
| unmount(); |
| }); |
|
|
| it('should NOT allow plan mode when disabled in config', async () => { |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState).toBeTruthy(); |
| expect(capturedUIState.allowPlanMode).toBe(false); |
| unmount(); |
| }); |
|
|
| it('should NOT allow plan mode when streaming', async () => { |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: StreamingState.Responding, |
| pendingHistoryItems: [], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState).toBeTruthy(); |
| expect(capturedUIState.allowPlanMode).toBe(false); |
| unmount(); |
| }); |
|
|
| it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { |
| vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); |
| mockedUseGeminiStream.mockReturnValue({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| streamingState: StreamingState.Idle, |
| pendingHistoryItems: [ |
| { |
| type: 'tool_group', |
| tools: [ |
| { |
| name: 'test_tool', |
| status: CoreToolCallStatus.AwaitingApproval, |
| }, |
| ], |
| }, |
| ], |
| }); |
|
|
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| expect(capturedUIState).toBeTruthy(); |
| expect(capturedUIState.allowPlanMode).toBe(false); |
| unmount(); |
| }); |
| }); |
|
|
| describe('Compression Queuing', () => { |
| beforeEach(async () => { |
| const { checkPermissions } = await import( |
| './hooks/atCommandProcessor.js' |
| ); |
| vi.mocked(checkPermissions).mockResolvedValue([]); |
|
|
| vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true); |
|
|
| const actual = await vi.importActual('./hooks/useMessageQueue.js'); |
| |
| const { useMessageQueue: realUseMessageQueue } = actual as any; |
| mockedUseMessageQueue.mockImplementation(realUseMessageQueue); |
|
|
| |
| mockedUseGeminiStream.mockImplementation(() => ({ |
| ...DEFAULT_GEMINI_STREAM_MOCK, |
| pendingHistoryItems: [ |
| { |
| type: MessageType.COMPRESSION, |
| compression: { |
| isPending: true, |
| originalTokenCount: null, |
| newTokenCount: null, |
| compressionStatus: null, |
| }, |
| }, |
| ], |
| })); |
| }); |
|
|
| it('queues messages during compression instead of handling as steering hints', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| |
| expect(capturedUIState.streamingState).toBe(StreamingState.Idle); |
|
|
| |
| await act(async () => |
| capturedUIActions.handleFinalSubmit('follow up message'), |
| ); |
|
|
| |
| expect(capturedUIState.messageQueue).toContain('follow up message'); |
|
|
| unmount(); |
| }); |
|
|
| it('executes slash commands immediately during compression', async () => { |
| const { unmount } = await act(async () => renderAppContainer()); |
|
|
| |
| await act(async () => capturedUIActions.handleFinalSubmit('/help')); |
|
|
| |
| expect(capturedUIState.messageQueue).not.toContain('/help'); |
|
|
| unmount(); |
| }); |
| }); |
| }); |
|
|