/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * * * This test suite covers: * - Initial rendering and display state * - Keyboard navigation (arrows, vim keys, Tab) * - Settings toggling (Enter, Space) * - Focus section switching between settings and scope selector * - Scope selection and settings persistence across scopes * - Restart-required vs immediate settings behavior * - VimModeContext integration * - Complex user interaction workflows * - Error handling and edge cases * - Display values for inherited and overridden settings * */ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { VimModeProvider } from '../contexts/VimModeContext.js'; // Mock the VimModeContext const mockToggleVimEnabled = vi.fn(); const mockSetVimMode = vi.fn(); vi.mock('../contexts/VimModeContext.js', async () => { const actual = await vi.importActual('../contexts/VimModeContext.js'); return { ...actual, useVimMode: () => ({ vimEnabled: false, vimMode: 'INSERT' as const, toggleVimEnabled: mockToggleVimEnabled, setVimMode: mockSetVimMode, }), }; }); vi.mock('../../utils/settingsUtils.js', async () => { const actual = await vi.importActual('../../utils/settingsUtils.js'); return { ...actual, saveModifiedSettings: vi.fn(), }; }); // Mock console.log to avoid noise in tests const originalConsoleLog = console.log; const originalConsoleError = console.error; describe('SettingsDialog', () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); beforeEach(() => { vi.clearAllMocks(); console.log = vi.fn(); console.error = vi.fn(); mockToggleVimEnabled.mockResolvedValue(true); }); afterEach(() => { console.log = originalConsoleLog; console.error = originalConsoleError; }); const createMockSettings = ( userSettings = {}, systemSettings = {}, workspaceSettings = {}, ) => new LoadedSettings( { settings: { customThemes: {}, mcpServers: {}, ...systemSettings }, path: '/system/settings.json', }, { settings: { customThemes: {}, mcpServers: {}, ...userSettings, }, path: '/user/settings.json', }, { settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings }, path: '/workspace/settings.json', }, [], ); describe('Initial Rendering', () => { it('should render the settings dialog with default state', () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); expect(output).toContain('Settings'); expect(output).toContain('Apply To'); expect(output).toContain('Use Enter to select, Tab to change focus'); }); it('should show settings list with default values', () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); // Should show some default settings expect(output).toContain('●'); // Active indicator }); it('should highlight first setting by default', () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); // First item should be highlighted with green color and active indicator expect(output).toContain('●'); }); }); describe('Settings Navigation', () => { it('should navigate down with arrow key', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press down arrow stdin.write('\u001B[B'); // Down arrow await wait(); // The active index should have changed (tested indirectly through behavior) unmount(); }); it('should navigate up with arrow key', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // First go down, then up stdin.write('\u001B[B'); // Down arrow await wait(); stdin.write('\u001B[A'); // Up arrow await wait(); unmount(); }); it('should navigate with vim keys (j/k)', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Navigate with vim keys stdin.write('j'); // Down await wait(); stdin.write('k'); // Up await wait(); unmount(); }); it('should not navigate beyond bounds', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Try to go up from first item stdin.write('\u001B[A'); // Up arrow await wait(); // Should still be on first item unmount(); }); }); describe('Settings Toggling', () => { it('should toggle setting with Enter key', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press Enter to toggle current setting stdin.write('\u000D'); // Enter key await wait(); unmount(); }); it('should toggle setting with Space key', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press Space to toggle current setting stdin.write(' '); // Space key await wait(); unmount(); }); it('should handle vim mode setting specially', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Navigate to vim mode setting and toggle it // This would require knowing the exact position, so we'll just test that the mock is called stdin.write('\u000D'); // Enter key await wait(); // The mock should potentially be called if vim mode was toggled unmount(); }); }); describe('Scope Selection', () => { it('should switch between scopes', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Switch to scope focus stdin.write('\t'); // Tab key await wait(); // Select different scope (numbers 1-3 typically available) stdin.write('2'); // Select second scope option await wait(); unmount(); }); it('should reset to settings focus when scope is selected', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount } = render( , ); // Switch to scope focus stdin.write('\t'); // Tab key await wait(); expect(lastFrame()).toContain('> Apply To'); // Select a scope stdin.write('1'); // Select first scope option await wait(); // Should be back to settings focus expect(lastFrame()).toContain(' Apply To'); unmount(); }); }); describe('Restart Prompt', () => { it('should show restart prompt for restart-required settings', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { unmount } = render( {}} onRestartRequest={onRestartRequest} />, ); // This test would need to trigger a restart-required setting change // The exact steps depend on which settings require restart await wait(); unmount(); }); it('should handle restart request when r is pressed', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { stdin, unmount } = render( {}} onRestartRequest={onRestartRequest} />, ); // Press 'r' key (this would only work if restart prompt is showing) stdin.write('r'); await wait(); // If restart prompt was showing, onRestartRequest should be called unmount(); }); }); describe('Escape Key Behavior', () => { it('should call onSelect with undefined when Escape is pressed', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press Escape key stdin.write('\u001B'); // ESC key await wait(); expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); unmount(); }); }); describe('Settings Persistence', () => { it('should persist settings across scope changes', async () => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Switch to scope selector stdin.write('\t'); // Tab await wait(); // Change scope stdin.write('2'); // Select workspace scope await wait(); // Settings should be reloaded for new scope unmount(); }); it('should show different values for different scopes', () => { const settings = createMockSettings( { vimMode: true }, // User settings { vimMode: false }, // System settings { autoUpdate: false }, // Workspace settings ); const onSelect = vi.fn(); const { lastFrame } = render( , ); // Should show user scope values initially const output = lastFrame(); expect(output).toContain('Settings'); }); }); describe('Error Handling', () => { it('should handle vim mode toggle errors gracefully', async () => { mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed')); const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Try to toggle a setting (this might trigger vim mode toggle) stdin.write('\u000D'); // Enter await wait(); // Should not crash unmount(); }); }); describe('Complex State Management', () => { it('should track modified settings correctly', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Toggle a setting stdin.write('\u000D'); // Enter await wait(); // Toggle another setting stdin.write('\u001B[B'); // Down await wait(); stdin.write('\u000D'); // Enter await wait(); // Should track multiple modified settings unmount(); }); it('should handle scrolling when there are many settings', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Navigate down many times to test scrolling for (let i = 0; i < 10; i++) { stdin.write('\u001B[B'); // Down arrow await wait(10); } unmount(); }); }); describe('VimMode Integration', () => { it('should sync with VimModeContext when vim mode is toggled', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Navigate to and toggle vim mode setting // This would require knowing the exact position of vim mode setting stdin.write('\u000D'); // Enter await wait(); unmount(); }); }); describe('Specific Settings Behavior', () => { it('should show correct display values for settings with different states', () => { const settings = createMockSettings( { vimMode: true, hideTips: false }, // User settings { hideWindowTitle: true }, // System settings { ideMode: false }, // Workspace settings ); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); // Should contain settings labels expect(output).toContain('Settings'); }); it('should handle immediate settings save for non-restart-required settings', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Toggle a non-restart-required setting (like hideTips) stdin.write('\u000D'); // Enter - toggle current setting await wait(); // Should save immediately without showing restart prompt unmount(); }); it('should show restart prompt for restart-required settings', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, unmount } = render( , ); // This test would need to navigate to a specific restart-required setting // Since we can't easily target specific settings, we test the general behavior await wait(); // Should not show restart prompt initially expect(lastFrame()).not.toContain( 'To see changes, Gemini CLI must be restarted', ); unmount(); }); it('should clear restart prompt when switching scopes', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { unmount } = render( , ); // Restart prompt should be cleared when switching scopes unmount(); }); }); describe('Settings Display Values', () => { it('should show correct values for inherited settings', () => { const settings = createMockSettings( {}, // No user settings { vimMode: true, hideWindowTitle: false }, // System settings {}, // No workspace settings ); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); // Settings should show inherited values expect(output).toContain('Settings'); }); it('should show override indicator for overridden settings', () => { const settings = createMockSettings( { vimMode: false }, // User overrides { vimMode: true }, // System default {}, // No workspace settings ); const onSelect = vi.fn(); const { lastFrame } = render( , ); const output = lastFrame(); // Should show settings with override indicators expect(output).toContain('Settings'); }); }); describe('Keyboard Shortcuts Edge Cases', () => { it('should handle rapid key presses gracefully', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Rapid navigation for (let i = 0; i < 5; i++) { stdin.write('\u001B[B'); // Down arrow stdin.write('\u001B[A'); // Up arrow } await wait(100); // Should not crash unmount(); }); it('should handle Ctrl+C to reset current setting to default', async () => { const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press Ctrl+C to reset current setting to default stdin.write('\u0003'); // Ctrl+C await wait(); // Should reset the current setting to its default value unmount(); }); it('should handle Ctrl+L to reset current setting to default', async () => { const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Press Ctrl+L to reset current setting to default stdin.write('\u000C'); // Ctrl+L await wait(); // Should reset the current setting to its default value unmount(); }); it('should handle navigation when only one setting exists', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Try to navigate when potentially at bounds stdin.write('\u001B[B'); // Down await wait(); stdin.write('\u001B[A'); // Up await wait(); unmount(); }); it('should properly handle Tab navigation between sections', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount } = render( , ); // Start in settings section expect(lastFrame()).toContain(' Apply To'); // Tab to scope section stdin.write('\t'); await wait(); expect(lastFrame()).toContain('> Apply To'); // Tab back to settings section stdin.write('\t'); await wait(); expect(lastFrame()).toContain(' Apply To'); unmount(); }); }); describe('Error Recovery', () => { it('should handle malformed settings gracefully', () => { // Create settings with potentially problematic values const settings = createMockSettings( { vimMode: null as unknown as boolean }, // Invalid value {}, {}, ); const onSelect = vi.fn(); const { lastFrame } = render( , ); // Should still render without crashing expect(lastFrame()).toContain('Settings'); }); it('should handle missing setting definitions gracefully', () => { const settings = createMockSettings(); const onSelect = vi.fn(); // Should not crash even if some settings are missing definitions const { lastFrame } = render( , ); expect(lastFrame()).toContain('Settings'); }); }); describe('Complex User Interactions', () => { it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Navigate down a few settings stdin.write('\u001B[B'); // Down await wait(); stdin.write('\u001B[B'); // Down await wait(); // Toggle a setting stdin.write('\u000D'); // Enter await wait(); // Switch to scope selector stdin.write('\t'); // Tab await wait(); // Change scope stdin.write('2'); // Select workspace await wait(); // Go back to settings stdin.write('\t'); // Tab await wait(); // Navigate and toggle another setting stdin.write('\u001B[B'); // Down await wait(); stdin.write(' '); // Space to toggle await wait(); // Exit stdin.write('\u001B'); // Escape await wait(); expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String)); unmount(); }); it('should allow changing multiple settings without losing pending changes', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Toggle first setting (should require restart) stdin.write('\u000D'); // Enter await wait(); // Navigate to next setting and toggle it (should not require restart - e.g., vimMode) stdin.write('\u001B[B'); // Down await wait(); stdin.write('\u000D'); // Enter await wait(); // Navigate to another setting and toggle it (should also require restart) stdin.write('\u001B[B'); // Down await wait(); stdin.write('\u000D'); // Enter await wait(); // The test verifies that all changes are preserved and the dialog still works // This tests the fix for the bug where changing one setting would reset all pending changes unmount(); }); it('should maintain state consistency during complex interactions', async () => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); const { stdin, unmount } = render( , ); // Multiple scope changes stdin.write('\t'); // Tab to scope await wait(); stdin.write('2'); // Workspace await wait(); stdin.write('\t'); // Tab to settings await wait(); stdin.write('\t'); // Tab to scope await wait(); stdin.write('1'); // User await wait(); // Should maintain consistent state unmount(); }); it('should handle restart workflow correctly', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { stdin, unmount } = render( {}} onRestartRequest={onRestartRequest} />, ); // This would test the restart workflow if we could trigger it stdin.write('r'); // Try restart key await wait(); // Without restart prompt showing, this should have no effect expect(onRestartRequest).not.toHaveBeenCalled(); unmount(); }); }); });