/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; import { AppContainer } from './AppContainer.js'; import { type Config, makeFakeConfig } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; import { useContext } from 'react'; // Helper component will read the context values provided by AppContainer // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; return null; } vi.mock('./App.js', () => ({ App: TestContextConsumer, })); vi.mock('ink', async (importOriginal) => { const original = await importOriginal(); return { ...original, measureElement: vi.fn(), }; }); vi.mock('./hooks/useQuotaAndFallback.js'); vi.mock('./hooks/useHistoryManager.js'); vi.mock('./hooks/useThemeCommand.js'); vi.mock('./auth/useAuth.js'); vi.mock('./hooks/useEditorSettings.js'); vi.mock('./hooks/useSettingsCommand.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/useKeypress.js'); vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useAutoAcceptIndicator.js'); vi.mock('./hooks/useWorkspaceMigration.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); // Mock external utilities 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 { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useGitBranchName } from './hooks/useGitBranchName.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 { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; describe('AppContainer State Management', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockInitResult: InitializationResult; // Create typed mocks for all hooks 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 mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; const mockedUseConsoleMessages = useConsoleMessages as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; const mockedUseMessageQueue = useMessageQueue as Mock; const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock; const mockedUseWorkspaceMigration = useWorkspaceMigration as Mock; const mockedUseGitBranchName = useGitBranchName 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; beforeEach(() => { vi.clearAllMocks(); capturedUIState = null!; capturedUIActions = null!; // **Provide a default return value for EVERY mocked hook.** 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(), }); mockedUseSlashCommandProcessor.mockReturnValue({ handleSlashCommand: vi.fn(), slashCommands: [], pendingHistoryItems: [], commandContext: {}, shellConfirmationRequest: null, confirmationRequest: null, }); mockedUseConsoleMessages.mockReturnValue({ consoleMessages: [], handleNewMessage: vi.fn(), clearConsoleMessages: vi.fn(), }); mockedUseGeminiStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), }); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); mockedUseFolderTrust.mockReturnValue({ isFolderTrustDialogOpen: false, handleFolderTrustSelect: vi.fn(), isRestarting: false, }); mockedUseMessageQueue.mockReturnValue({ messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), }); mockedUseAutoAcceptIndicator.mockReturnValue(false); mockedUseWorkspaceMigration.mockReturnValue({ showWorkspaceMigrationDialog: false, workspaceExtensions: [], onWorkspaceMigrationDialogOpen: vi.fn(), onWorkspaceMigrationDialogClose: vi.fn(), }); mockedUseGitBranchName.mockReturnValue('main'); mockedUseVimMode.mockReturnValue({ isVimEnabled: false, toggleVimEnabled: vi.fn(), }); mockedUseSessionStats.mockReturnValue({ stats: {} }); mockedUseTextBuffer.mockReturnValue({ text: '', setText: vi.fn(), // Add other properties if AppContainer uses them }); mockedUseLogger.mockReturnValue({ getPreviousUserMessages: vi.fn().mockResolvedValue([]), }); mockedUseLoadingIndicator.mockReturnValue({ elapsedTime: '0.0s', currentLoadingPhrase: '', }); // Mock Config mockConfig = makeFakeConfig(); // Mock LoadedSettings mockSettings = { merged: { hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: false, theme: 'default', }, } as unknown as LoadedSettings; // Mock InitializationResult mockInitResult = { themeError: null, authError: null, shouldOpenAuthDialog: false, geminiMdFileCount: 0, } as InitializationResult; }); afterEach(() => { cleanup(); }); describe('Basic Rendering', () => { it('renders without crashing with minimal props', () => { expect(() => { render( , ); }).not.toThrow(); }); it('renders with startup warnings', () => { const startupWarnings = ['Warning 1', 'Warning 2']; expect(() => { render( , ); }).not.toThrow(); }); }); describe('State Initialization', () => { it('initializes with theme error from initialization result', () => { const initResultWithError = { ...mockInitResult, themeError: 'Failed to load theme', }; expect(() => { render( , ); }).not.toThrow(); }); it('handles debug mode state', () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); expect(() => { render( , ); }).not.toThrow(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', () => { const { unmount } = render( , ); // Should render and unmount cleanly expect(() => unmount()).not.toThrow(); }); it('provides UIStateContext with state management', () => { expect(() => { render( , ); }).not.toThrow(); }); it('provides UIActionsContext with action handlers', () => { expect(() => { render( , ); }).not.toThrow(); }); it('provides ConfigContext with config object', () => { expect(() => { render( , ); }).not.toThrow(); }); }); describe('Settings Integration', () => { it('handles settings with all display options disabled', () => { const settingsAllHidden = { merged: { hideBanner: true, hideFooter: true, hideTips: true, showMemoryUsage: false, }, } as unknown as LoadedSettings; expect(() => { render( , ); }).not.toThrow(); }); it('handles settings with memory usage enabled', () => { const settingsWithMemory = { merged: { hideBanner: false, hideFooter: false, hideTips: false, showMemoryUsage: true, }, } as unknown as LoadedSettings; expect(() => { render( , ); }).not.toThrow(); }); }); describe('Version Handling', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', (version) => { expect(() => { render( , ); }).not.toThrow(); }, ); }); describe('Error Handling', () => { it('handles config methods that might throw', () => { const errorConfig = makeFakeConfig(); vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { throw new Error('Config error'); }); // Should still render without crashing - errors should be handled internally expect(() => { render( , ); }).not.toThrow(); }); it('handles undefined settings gracefully', () => { const undefinedSettings = { merged: {}, } as LoadedSettings; expect(() => { render( , ); }).not.toThrow(); }); }); describe('Provider Hierarchy', () => { it('establishes correct provider nesting order', () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies const { unmount } = render( , ); expect(() => unmount()).not.toThrow(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', () => { // The default mock from beforeEach already sets proQuotaRequest to null render( , ); // Assert that the context value is as expected expect(capturedUIState.proQuotaRequest).toBeNull(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => { // Arrange: Create a mock request object that a UI dialog would receive const mockRequest = { failedModel: 'gemini-pro', fallbackModel: 'gemini-flash', resolve: vi.fn(), }; mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: mockRequest, handleProQuotaChoice: vi.fn(), }); // Act: Render the container render( , ); // Assert: The mock request is correctly passed through the context expect(capturedUIState.proQuotaRequest).toEqual(mockRequest); }); it('passes the handleProQuotaChoice function to UIActionsContext', () => { // Arrange: Create a mock handler function const mockHandler = vi.fn(); mockedUseQuotaAndFallback.mockReturnValue({ proQuotaRequest: null, handleProQuotaChoice: mockHandler, }); // Act: Render the container render( , ); // Assert: The action in the context is the mock handler we provided expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); // You can even verify that the plumbed function is callable capturedUIActions.handleProQuotaChoice('auth'); expect(mockHandler).toHaveBeenCalledWith('auth'); }); }); describe('Terminal Height Calculation', () => { const mockedMeasureElement = measureElement as Mock; const mockedUseTerminalSize = useTerminalSize as Mock; it('should prevent terminal height from being less than 1', () => { const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty'); // Arrange: Simulate a small terminal and a large footer mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen mockedUseGeminiStream.mockReturnValue({ streamingState: 'idle', submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], thought: null, cancelOngoingRequest: vi.fn(), activePtyId: 'some-id', }); render( , ); // Assert: The shell should be resized to a minimum height of 1, not a negative number. // The old code would have tried to set a negative height. expect(resizePtySpy).toHaveBeenCalled(); const lastCall = resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1]; // Check the height argument specifically expect(lastCall[2]).toBe(1); }); }); });