/** * @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 '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { VimModeProvider } from '../contexts/VimModeContext.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js'; import { getSettingsSchema, type SettingDefinition, type SettingsSchemaType, } from '../../config/settingsSchema.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; // Mock the VimModeContext const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined); const mockSetVimMode = vi.fn(); vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => ({ terminalWidth: 100, // Fixed width for consistent snapshots }), })); enum TerminalKeys { ENTER = '\u000D', TAB = '\t', UP_ARROW = '\u001B[A', DOWN_ARROW = '\u001B[B', LEFT_ARROW = '\u001B[D', RIGHT_ARROW = '\u001B[C', ESCAPE = '\u001B', BACKSPACE = '\u0008', } vi.mock('../../config/settingsSchema.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, getSettingsSchema: vi.fn(original.getSettingsSchema), SETTING_CATEGORY_ORDER: [ 'General', 'UI', 'Model', 'Context', 'Tools', 'IDE', 'Privacy', 'Extensions', 'Security', 'Experimental', 'Admin', 'Advanced', ], }; }); 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 (importOriginal) => { const original = await importOriginal(); const CATEGORY_ORDER = [ 'General', 'UI', 'Model', 'Context', 'Tools', 'IDE', 'Privacy', 'Extensions', 'Security', 'Experimental', 'Admin', 'Advanced', ]; return { ...original, saveModifiedSettings: vi.fn(), SETTING_CATEGORY_ORDER: CATEGORY_ORDER, getDialogSettingsByCategory: vi.fn(() => { // Use original logic but with our local order to avoid hoisting issues const categories: Record< string, Array > = {}; Object.values(original.getFlattenedSchema()) .filter( (definition: SettingDefinition) => definition.showInDialog !== false, ) .forEach((definition: SettingDefinition & { key: string }) => { const category = definition.category; if (!categories[category]) { categories[category] = []; } categories[category].push(definition); }); const ordered: Record> = {}; CATEGORY_ORDER.forEach((cat) => { if (categories[cat]) ordered[cat] = categories[cat]; }); Object.keys(categories) .sort() .forEach((cat) => { if (!ordered[cat]) ordered[cat] = categories[cat]; }); return ordered; }), }; }); // Shared test schemas enum StringEnum { FOO = 'foo', BAR = 'bar', BAZ = 'baz', } const ENUM_SETTING: SettingDefinition = { type: 'enum', label: 'Theme', options: [ { label: 'Foo', value: StringEnum.FOO, }, { label: 'Bar', value: StringEnum.BAR, }, { label: 'Baz', value: StringEnum.BAZ, }, ], category: 'UI', requiresRestart: false, default: StringEnum.BAR, description: 'The color theme for the UI.', showInDialog: true, }; const ENUM_FAKE_SCHEMA: SettingsSchemaType = { ui: { showInDialog: false, properties: { theme: { ...ENUM_SETTING, }, }, }, } as unknown as SettingsSchemaType; const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = { tools: { type: 'object', label: 'Tools', category: 'Tools', requiresRestart: false, default: {}, description: 'Tool settings.', showInDialog: false, properties: { shell: { type: 'object', label: 'Shell', category: 'Tools', requiresRestart: false, default: {}, description: 'Shell tool settings.', showInDialog: false, properties: { showColor: { type: 'boolean', label: 'Show Color', category: 'Tools', requiresRestart: false, default: false, description: 'Show color in shell output.', showInDialog: true, }, enableInteractiveShell: { type: 'boolean', label: 'Enable Interactive Shell', category: 'Tools', requiresRestart: true, default: true, description: 'Enable interactive shell mode.', showInDialog: true, }, pager: { type: 'string', label: 'Pager', category: 'Tools', requiresRestart: false, default: 'cat', description: 'The pager command to use for shell output.', showInDialog: true, }, }, }, }, }, } as unknown as SettingsSchemaType; // Helper function to render SettingsDialog with standard wrapper const renderDialog = ( settings: LoadedSettings, onSelect: ReturnType, options?: { onRestartRequest?: ReturnType; availableTerminalHeight?: number; }, ) => render( , ); describe('SettingsDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn( terminalCapabilityManager, 'isKittyProtocolEnabled', ).mockReturnValue(true); mockToggleVimEnabled.mockRejectedValue(undefined); }); afterEach(() => { TEST_ONLY.clearFlattenedSchema(); vi.clearAllMocks(); vi.resetAllMocks(); }); describe('Initial Rendering', () => { it('should render the settings dialog with default state', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Settings'); expect(output).toContain('Apply To'); // Use regex for more flexible help text matching expect(output).toMatch(/Enter.*select.*Esc.*close/); unmount(); }); it('should accept availableTerminalHeight prop without errors', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, { availableTerminalHeight: 20, }, ); await waitUntilReady(); const output = lastFrame(); // Should still render properly with the height prop expect(output).toContain('Settings'); // Use regex for more flexible help text matching expect(output).toMatch(/Enter.*select.*Esc.*close/); unmount(); }); it('should render settings list with visual indicators', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const renderResult = renderDialog(settings, onSelect); await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); renderResult.unmount(); }); it('should use almost full height of the window but no more when the window height is 25 rows', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); // Render with a fixed height of 25 rows const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, { availableTerminalHeight: 25, }, ); await waitUntilReady(); // Wait for the dialog to render await waitFor(() => { const output = lastFrame(); expect(output).toBeDefined(); const lines = output.trim().split('\n'); expect(lines.length).toBeGreaterThanOrEqual(24); expect(lines.length).toBeLessThanOrEqual(27); }); unmount(); }); }); describe('Setting Descriptions', () => { it('should render descriptions for settings that have them', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); const output = lastFrame(); // 'general.vimMode' has description 'Enable Vim keybindings' in settingsSchema.ts expect(output).toContain('Vim Mode'); expect(output).toContain('Enable Vim keybindings'); // 'general.enableAutoUpdate' has description 'Enable automatic updates.' expect(output).toContain('Auto Update'); expect(output).toContain('Enable automatic updates.'); unmount(); }); }); describe('Settings Navigation', () => { it.each([ { name: 'arrow keys', down: TerminalKeys.DOWN_ARROW, up: TerminalKeys.UP_ARROW, }, { name: 'vim keys (j/k)', down: 'j', up: 'k', }, ])('should navigate with $name', async ({ down, up }) => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); const initialFrame = lastFrame(); expect(initialFrame).toContain('Vim Mode'); // Navigate down await act(async () => { stdin.write(down); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('Auto Update'); }); // Navigate up await act(async () => { stdin.write(up); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); unmount(); }); it('wraps around when at the top of the list', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Try to go up from first item await act(async () => { stdin.write(TerminalKeys.UP_ARROW); }); await waitUntilReady(); await waitFor(() => { // Should wrap to last setting (without relying on exact bullet character) expect(lastFrame()).toContain('Hook Notifications'); }); unmount(); }); }); describe('Settings Toggling', () => { it('should toggle setting with Enter key', async () => { vi.mocked(saveModifiedSettings).mockClear(); const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render and verify we're on Vim Mode (first setting) await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); // Toggle the setting (Vim Mode is the first setting now) await act(async () => { stdin.write(TerminalKeys.ENTER as string); }); await waitUntilReady(); // Wait for the setting change to be processed await waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); }); // Wait for the mock to be called await waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith( new Set(['general.vimMode']), expect.objectContaining({ general: expect.objectContaining({ vimMode: true, }), }), expect.any(LoadedSettings), SettingScope.User, ); unmount(); }); describe('enum values', () => { it.each([ { name: 'toggles to next value', initialValue: undefined, expectedValue: StringEnum.BAZ, }, { name: 'loops back to first value when at end', initialValue: StringEnum.BAZ, expectedValue: StringEnum.FOO, }, ])('$name', async ({ initialValue, expectedValue }) => { vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA); const settings = createMockSettings(); if (initialValue !== undefined) { settings.setValue(SettingScope.User, 'ui.theme', initialValue); } const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.ENTER as string); }); await waitUntilReady(); await waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith( new Set(['ui.theme']), expect.objectContaining({ ui: expect.objectContaining({ theme: expectedValue, }), }), expect.any(LoadedSettings), SettingScope.User, ); unmount(); }); }); it('should handle vim mode setting specially', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // 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 await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter key }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Switch to scope focus await act(async () => { stdin.write(TerminalKeys.TAB); // Tab key // Select different scope (numbers 1-3 typically available) stdin.write('2'); // Select second scope option }); await waitUntilReady(); unmount(); }); it('should reset to settings focus when scope is selected', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); // The UI should show the settings section is active and scope section is inactive expect(lastFrame()).toContain('Vim Mode'); // Settings section active expect(lastFrame()).toContain('Apply To'); // Scope section (don't rely on exact spacing) // This test validates the initial state - scope selection behavior // is complex due to keypress handling, so we focus on state validation unmount(); }); }); describe('Restart Prompt', () => { it('should show restart prompt for restart-required settings', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { unmount, waitUntilReady } = renderDialog(settings, vi.fn(), { onRestartRequest, }); await waitUntilReady(); // This test would need to trigger a restart-required setting change // The exact steps depend on which settings require restart unmount(); }); it('should handle restart request when r is pressed', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, vi.fn(), { onRestartRequest, }, ); await waitUntilReady(); // Press 'r' key (this would only work if restart prompt is showing) await act(async () => { stdin.write('r'); }); await waitUntilReady(); // 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 { lastFrame, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); // Verify the dialog is rendered properly expect(lastFrame()).toContain('Settings'); expect(lastFrame()).toContain('Apply To'); // This test validates rendering - escape key behavior depends on complex // keypress handling that's difficult to test reliably in this environment unmount(); }); }); describe('Settings Persistence', () => { it('should persist settings across scope changes', async () => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Switch to scope selector and change scope await act(async () => { stdin.write(TerminalKeys.TAB as string); // Tab stdin.write('2'); // Select workspace scope }); await waitUntilReady(); // Settings should be reloaded for new scope unmount(); }); it('should show different values for different scopes', async () => { const settings = createMockSettings({ user: { settings: { vimMode: true }, originalSettings: { vimMode: true }, path: '', }, system: { settings: { vimMode: false }, originalSettings: { vimMode: false }, path: '', }, workspace: { settings: { autoUpdate: false }, originalSettings: { autoUpdate: false }, path: '', }, }); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Should show user scope values initially const output = lastFrame(); expect(output).toContain('Settings'); unmount(); }); }); 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Try to toggle a setting (this might trigger vim mode toggle) await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Toggle a setting, then toggle another setting await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); // Down }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Navigate down many times to test scrolling await act(async () => { for (let i = 0; i < 10; i++) { stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow } }); await waitUntilReady(); 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, waitUntilReady } = render( , ); await waitUntilReady(); // Navigate to and toggle vim mode setting // This would require knowing the exact position of vim mode setting await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); unmount(); }); }); describe('Specific Settings Behavior', () => { it('should show correct display values for settings with different states', async () => { const settings = createMockSettings({ user: { settings: { vimMode: true, tips: true }, originalSettings: { vimMode: true, tips: true }, path: '', }, system: { settings: { windowTitle: false }, originalSettings: { windowTitle: false }, path: '', }, workspace: { settings: { ideMode: false }, originalSettings: { ideMode: false }, path: '', }, }); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); const output = lastFrame(); // Should contain settings labels expect(output).toContain('Settings'); unmount(); }); it('should handle immediate settings save for non-restart-required settings', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Toggle a non-restart-required setting (like tips) await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter - toggle current setting }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // 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 // Should not show restart prompt initially await waitFor(() => { 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, waitUntilReady } = renderDialog(settings, onSelect); await waitUntilReady(); // Restart prompt should be cleared when switching scopes unmount(); }); }); describe('Settings Display Values', () => { it('should show correct values for inherited settings', async () => { const settings = createMockSettings({ system: { settings: { vimMode: true, windowTitle: true }, originalSettings: { vimMode: true, windowTitle: true }, path: '', }, }); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); const output = lastFrame(); // Settings should show inherited values expect(output).toContain('Settings'); unmount(); }); it('should show override indicator for overridden settings', async () => { const settings = createMockSettings({ user: { settings: { vimMode: false }, originalSettings: { vimMode: false }, path: '', }, system: { settings: { vimMode: true }, originalSettings: { vimMode: true }, path: '', }, }); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); const output = lastFrame(); // Should show settings with override indicators expect(output).toContain('Settings'); unmount(); }); }); describe('Race Condition Regression Tests', () => { it.each([ { name: 'not reset sibling settings when toggling a nested setting multiple times', toggleCount: 5, shellSettings: { showColor: false, enableInteractiveShell: true, }, expectedSiblings: { enableInteractiveShell: true, }, }, { name: 'preserve multiple sibling settings in nested objects during rapid toggles', toggleCount: 3, shellSettings: { showColor: false, enableInteractiveShell: true, pager: 'less', }, expectedSiblings: { enableInteractiveShell: true, pager: 'less', }, }, ])( 'should $name', async ({ toggleCount, shellSettings, expectedSiblings }) => { vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); const settings = createMockSettings({ tools: { shell: shellSettings, }, }); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); for (let i = 0; i < toggleCount; i++) { await act(async () => { stdin.write(TerminalKeys.ENTER as string); }); await waitUntilReady(); } await waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); }); const calls = vi.mocked(saveModifiedSettings).mock.calls; calls.forEach((call) => { const [modifiedKeys, pendingSettings] = call; if (modifiedKeys.has('tools.shell.showColor')) { const shellSettings = pendingSettings.tools?.shell as | Record | undefined; Object.entries(expectedSiblings).forEach(([key, value]) => { expect(shellSettings?.[key]).toBe(value); expect(modifiedKeys.has(`tools.shell.${key}`)).toBe(false); }); expect(modifiedKeys.size).toBe(1); } }); expect(calls.length).toBeGreaterThan(0); unmount(); }, ); }); describe('Keyboard Shortcuts Edge Cases', () => { it('should handle rapid key presses gracefully', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Rapid navigation await act(async () => { for (let i = 0; i < 5; i++) { stdin.write(TerminalKeys.DOWN_ARROW as string); stdin.write(TerminalKeys.UP_ARROW as string); } }); await waitUntilReady(); // Should not crash unmount(); }); it.each([ { key: 'Ctrl+C', code: '\u0003' }, { key: 'Ctrl+L', code: '\u000C' }, ])( 'should handle $key to reset current setting to default', async ({ code }) => { const settings = createMockSettings({ vimMode: true }); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); await act(async () => { stdin.write(code); }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Try to navigate when potentially at bounds await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.UP_ARROW as string); }); await waitUntilReady(); unmount(); }); it('should properly handle Tab navigation between sections', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); // Verify initial state: settings section active, scope section inactive expect(lastFrame()).toContain('Vim Mode'); // Settings section active expect(lastFrame()).toContain('Apply To'); // Scope section (don't rely on exact spacing) // This test validates the rendered UI structure for tab navigation // Actual tab behavior testing is complex due to keypress handling unmount(); }); }); describe('Error Recovery', () => { it('should handle malformed settings gracefully', async () => { // Create settings with potentially problematic values const settings = createMockSettings({ user: { settings: { vimMode: null as unknown as boolean }, originalSettings: { vimMode: null as unknown as boolean }, path: '', }, }); const onSelect = vi.fn(); const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Should still render without crashing expect(lastFrame()).toContain('Settings'); unmount(); }); it('should handle missing setting definitions gracefully', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); // Should not crash even if some settings are missing definitions const { lastFrame, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); expect(lastFrame()).toContain('Settings'); unmount(); }); }); describe('Complex User Interactions', () => { it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); // Verify the complete UI is rendered with all necessary sections expect(lastFrame()).toContain('Settings'); // Title expect(lastFrame()).toContain('Vim Mode'); // Active setting expect(lastFrame()).toContain('Apply To'); // Scope section expect(lastFrame()).toContain('User Settings'); // Scope options (no numbers when settings focused) // Use regex for more flexible help text matching expect(lastFrame()).toMatch(/Enter.*select.*Tab.*focus.*Esc.*close/); // This test validates the complete UI structure is available for user workflow // Individual interactions are tested in focused unit tests unmount(); }); it('should allow changing multiple settings without losing pending changes', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Toggle multiple settings await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); // Down }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); // Down }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Enter }); await waitUntilReady(); // 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, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Multiple scope changes await act(async () => { stdin.write(TerminalKeys.TAB as string); // Tab to scope }); await waitUntilReady(); await act(async () => { stdin.write('2'); // Workspace }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.TAB as string); // Tab to settings }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.TAB as string); // Tab to scope }); await waitUntilReady(); await act(async () => { stdin.write('1'); // User }); await waitUntilReady(); // Should maintain consistent state unmount(); }); it('should handle restart workflow correctly', async () => { const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { stdin, unmount, waitUntilReady } = renderDialog( settings, vi.fn(), { onRestartRequest, }, ); await waitUntilReady(); // This would test the restart workflow if we could trigger it await act(async () => { stdin.write('r'); // Try restart key }); await waitUntilReady(); // Without restart prompt showing, this should have no effect expect(onRestartRequest).not.toHaveBeenCalled(); unmount(); }); }); describe('Restart and Search Conflict Regression', () => { it('should prioritize restart request over search text box when showRestartPrompt is true', async () => { vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); const settings = createMockSettings(); const onRestartRequest = vi.fn(); const { stdin, lastFrame, unmount, waitUntilReady } = renderDialog( settings, vi.fn(), { onRestartRequest, }, ); await waitUntilReady(); // Wait for initial render await waitFor(() => expect(lastFrame()).toContain('Show Color')); // Navigate to "Enable Interactive Shell" (second item in TOOLS_SHELL_FAKE_SCHEMA) await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Wait for navigation to complete await waitFor(() => expect(lastFrame()).toContain('● Enable Interactive Shell'), ); // Toggle it to trigger restart required await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain( 'To see changes, Gemini CLI must be restarted', ); }); // Press 'r' - it should call onRestartRequest, NOT be handled by search await act(async () => { stdin.write('r'); }); await waitUntilReady(); await waitFor(() => { expect(onRestartRequest).toHaveBeenCalled(); }); unmount(); }); it('should hide search box when showRestartPrompt is true', async () => { vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); const settings = createMockSettings(); const { stdin, lastFrame, unmount, waitUntilReady } = renderDialog( settings, vi.fn(), ); await waitUntilReady(); // Search box should be visible initially (searchPlaceholder) expect(lastFrame()).toContain('Search to filter'); // Navigate to "Enable Interactive Shell" and toggle it await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await waitFor(() => expect(lastFrame()).toContain('● Enable Interactive Shell'), ); await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain( 'To see changes, Gemini CLI must be restarted', ); }); // Search box should now be hidden expect(lastFrame()).not.toContain('Search to filter'); unmount(); }); }); describe('String Settings Editing', () => { it('should allow editing and committing a string setting', async () => { let settings = createMockSettings({ 'general.sessionCleanup.maxAge': 'initial', }); const onSelect = vi.fn(); const { stdin, unmount, rerender, waitUntilReady } = render( , ); await waitUntilReady(); // Search for 'chat history' to filter the list await act(async () => { stdin.write('chat history'); }); await waitUntilReady(); // Press Down Arrow to focus the list await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Press Enter to start editing, type new value, and commit await act(async () => { stdin.write('\r'); // Start editing }); await waitUntilReady(); await act(async () => { stdin.write('new value'); }); await waitUntilReady(); await act(async () => { stdin.write('\r'); // Commit }); await waitUntilReady(); settings = createMockSettings({ user: { settings: { 'general.sessionCleanup.maxAge': 'new value' }, originalSettings: { 'general.sessionCleanup.maxAge': 'new value' }, path: '', }, }); await act(async () => { rerender( , ); }); await waitUntilReady(); // Press Escape to exit await act(async () => { stdin.write('\u001B'); }); await waitUntilReady(); await waitFor(() => { expect(onSelect).toHaveBeenCalledWith(undefined, 'User'); }); unmount(); }); }); describe('Search Functionality', () => { it('should display text entered in search', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Wait for initial render and verify that search is not active await waitFor(() => { expect(lastFrame()).not.toContain('> Search:'); }); expect(lastFrame()).toContain('Search to filter'); // Press '/' to enter search mode await act(async () => { stdin.write('/'); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('/'); expect(lastFrame()).not.toContain('Search to filter'); }); unmount(); }); it('should show search query and filter settings as user types', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); await act(async () => { stdin.write('yolo'); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('yolo'); expect(lastFrame()).toContain('YOLO Mode'); }); unmount(); }); it('should exit search settings when Escape is pressed', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); await act(async () => { stdin.write('vim'); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('vim'); }); // Press Escape await act(async () => { stdin.write(TerminalKeys.ESCAPE); }); await waitUntilReady(); await waitFor(() => { // onSelect is called with (settingName, scope). // undefined settingName means "close dialog" expect(onSelect).toHaveBeenCalledWith(undefined, expect.anything()); }); unmount(); }); it('should handle backspace to modify search query', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); await act(async () => { stdin.write('vimm'); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('vimm'); }); // Press backspace await act(async () => { stdin.write(TerminalKeys.BACKSPACE); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('vim'); expect(lastFrame()).toContain('Vim Mode'); expect(lastFrame()).not.toContain('Hook Notifications'); }); unmount(); }); it('should display nothing when search yields no results', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, unmount, waitUntilReady } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Type a search query that won't match any settings await act(async () => { stdin.write('nonexistentsetting'); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('nonexistentsetting'); expect(lastFrame()).not.toContain('Vim Mode'); // Should not contain any settings expect(lastFrame()).not.toContain('Auto Update'); // Should not contain any settings }); unmount(); }); }); describe('Snapshot Tests', () => { /** * Snapshot tests for SettingsDialog component using ink-testing-library. * These tests capture the visual output of the component in various states. * The snapshots help ensure UI consistency and catch unintended visual changes. */ it.each([ { name: 'default state', userSettings: {}, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'various boolean settings enabled', userSettings: { general: { vimMode: true, enableAutoUpdate: false, debugKeystrokeLogging: true, }, ui: { windowTitle: false, tips: false, showMemoryUsage: true, showLineNumbers: true, showCitations: true, accessibility: { enableLoadingPhrases: false, screenReader: true, }, }, ide: { enabled: true, }, context: { loadMemoryFromIncludeDirectories: true, fileFiltering: { respectGitIgnore: true, respectGeminiIgnore: true, enableRecursiveFileSearch: true, enableFuzzySearch: true, }, }, tools: { enableInteractiveShell: true, useRipgrep: true, }, security: { folderTrust: { enabled: true, }, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'mixed boolean and number settings', userSettings: { general: { vimMode: false, enableAutoUpdate: false, }, ui: { showMemoryUsage: true, windowTitle: true, }, tools: { truncateToolOutputThreshold: 50000, }, context: { discoveryMaxDirs: 500, }, model: { maxSessionTurns: 100, nextSpeakerCheck: true, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'focused on scope selector', userSettings: {}, systemSettings: {}, workspaceSettings: {}, stdinActions: async ( stdin: { write: (data: string) => void }, waitUntilReady: () => Promise, ) => { await act(async () => { stdin.write('\t'); }); await waitUntilReady(); }, }, { name: 'accessibility settings enabled', userSettings: { ui: { accessibility: { enableLoadingPhrases: false, screenReader: true, }, showMemoryUsage: true, showLineNumbers: true, }, general: { vimMode: true, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'file filtering settings configured', userSettings: { context: { fileFiltering: { respectGitIgnore: false, respectGeminiIgnore: true, enableRecursiveFileSearch: false, enableFuzzySearch: false, }, loadMemoryFromIncludeDirectories: true, discoveryMaxDirs: 100, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'tools and security settings', userSettings: { tools: { enableInteractiveShell: true, useRipgrep: true, truncateToolOutputThreshold: 25000, }, security: { folderTrust: { enabled: true, }, }, model: { maxSessionTurns: 50, nextSpeakerCheck: false, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'all boolean settings disabled', userSettings: { general: { vimMode: false, enableAutoUpdate: true, debugKeystrokeLogging: false, }, ui: { windowTitle: true, tips: true, showMemoryUsage: false, showLineNumbers: false, showCitations: false, accessibility: { enableLoadingPhrases: true, screenReader: false, }, }, ide: { enabled: false, }, context: { loadMemoryFromIncludeDirectories: false, fileFiltering: { respectGitIgnore: false, respectGeminiIgnore: false, enableRecursiveFileSearch: false, enableFuzzySearch: true, }, }, tools: { enableInteractiveShell: false, useRipgrep: false, }, security: { folderTrust: { enabled: false, }, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, ])( 'should render $name correctly', async ({ userSettings, systemSettings, workspaceSettings, stdinActions, }) => { const settings = createMockSettings({ user: { settings: userSettings, originalSettings: userSettings, path: '', }, system: { settings: systemSettings, originalSettings: systemSettings, path: '', }, workspace: { settings: workspaceSettings, originalSettings: workspaceSettings, path: '', }, }); const onSelect = vi.fn(); const renderResult = renderDialog(settings, onSelect); await renderResult.waitUntilReady(); if (stdinActions) { await stdinActions(renderResult.stdin, renderResult.waitUntilReady); } await expect(renderResult).toMatchSvgSnapshot(); renderResult.unmount(); }, ); }); });