/** * @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 * - 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 { SettingScope } from '../../config/settings.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { TEST_ONLY } from '../../utils/settingsUtils.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import { getSettingsSchema, type SettingDefinition, type SettingsSchemaType, } from '../../config/settingsSchema.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; 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', CTRL_P = '\u0010', CTRL_N = '\u000E', } vi.mock('../../config/settingsSchema.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, getSettingsSchema: vi.fn(original.getSettingsSchema), }; }); // 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 ARRAY_FAKE_SCHEMA: SettingsSchemaType = { context: { type: 'object', label: 'Context', category: 'Context', requiresRestart: false, default: {}, description: 'Context settings.', showInDialog: false, properties: { fileFiltering: { type: 'object', label: 'File Filtering', category: 'Context', requiresRestart: false, default: {}, description: 'File filtering settings.', showInDialog: false, properties: { customIgnoreFilePaths: { type: 'array', label: 'Custom Ignore File Paths', category: 'Context', requiresRestart: false, default: [] as string[], description: 'Additional ignore file paths.', showInDialog: true, items: { type: 'string' }, }, }, }, }, }, security: { type: 'object', label: 'Security', category: 'Security', requiresRestart: false, default: {}, description: 'Security settings.', showInDialog: false, properties: { allowedExtensions: { type: 'array', label: 'Extension Source Regex Allowlist', category: 'Security', requiresRestart: false, default: [] as string[], description: 'Allowed extension source regex patterns.', showInDialog: true, items: { type: 'string' }, }, }, }, } 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: ReturnType, onSelect: ReturnType, options?: { onRestartRequest?: ReturnType; availableTerminalHeight?: number; }, ) => render( , ); describe('SettingsDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn( terminalCapabilityManager, 'isKittyProtocolEnabled', ).mockReturnValue(true); }); 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(25); }); 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('Enable 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: 'emacs keys (Ctrl+P/N)', down: TerminalKeys.CTRL_N, up: TerminalKeys.CTRL_P, }, ])('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('Enable Auto Update'); }); // Navigate up await act(async () => { stdin.write(up); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); unmount(); }); it('should allow j and k characters to be typed in search without triggering navigation', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); const { lastFrame, stdin, waitUntilReady, unmount } = renderDialog( settings, onSelect, ); await waitUntilReady(); // Enter 'j' and 'k' in search await act(async () => stdin.write('j')); await waitUntilReady(); await act(async () => stdin.write('k')); await waitUntilReady(); await waitFor(() => { const frame = lastFrame(); // The search box should contain 'jk' expect(frame).toContain('jk'); // Since 'jk' doesn't match any setting labels, it should say "No matches found." expect(frame).toContain('No matches found.'); }); 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 () => { const settings = createMockSettings(); const setValueSpy = vi.spyOn(settings, 'setValue'); 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); }); // Wait for setValue to be called await waitFor(() => { expect(setValueSpy).toHaveBeenCalled(); }); expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'general.vimMode', true, ); 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(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA); const settings = createMockSettings(); if (initialValue !== undefined) { settings.setValue(SettingScope.User, 'ui.theme', initialValue); } const setValueSpy = vi.spyOn(settings, 'setValue'); 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(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'ui.theme', expectedValue, ); }); 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('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('Specific Settings Behavior', () => { it('should show correct display values for settings with different states', async () => { const settings = createMockSettings({ user: { settings: { vimMode: true, hideTips: false }, originalSettings: { vimMode: true, hideTips: false }, path: '', }, system: { settings: { hideWindowTitle: true }, originalSettings: { hideWindowTitle: true }, 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 hideTips) 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( 'Changes that require a restart have been modified', ); }); 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, hideWindowTitle: false }, originalSettings: { vimMode: true, hideWindowTitle: false }, 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 }) => { vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); const settings = createMockSettings({ tools: { shell: shellSettings, }, }); const setValueSpy = vi.spyOn(settings, 'setValue'); const onSelect = vi.fn(); const { stdin, unmount } = renderDialog(settings, onSelect); for (let i = 0; i < toggleCount; i++) { act(() => { stdin.write(TerminalKeys.ENTER as string); }); } await waitFor(() => { expect(setValueSpy).toHaveBeenCalled(); }); // With the store pattern, setValue is called atomically per key. // Sibling preservation is handled by LoadedSettings internally. const calls = setValueSpy.mock.calls; expect(calls.length).toBeGreaterThan(0); calls.forEach((call) => { // Each call should target only 'tools.shell.showColor' expect(call[1]).toBe('tools.shell.showColor'); }); 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( 'Changes that require a restart have been modified', ); }); // 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( 'Changes that require a restart have been modified', ); }); // 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: '', }, }); rerender( , ); // Press Escape to exit await act(async () => { stdin.write('\u001B'); }); await waitUntilReady(); await waitFor(() => { expect(onSelect).toHaveBeenCalledWith(undefined, 'User'); }); unmount(); }); }); describe('Array Settings Editing', () => { const typeInput = async ( stdin: { write: (data: string) => void }, input: string, ) => { for (const ch of input) { await act(async () => { stdin.write(ch); }); } }; it('should parse comma-separated input as string arrays', async () => { vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA); const settings = createMockSettings(); const setValueSpy = vi.spyOn(settings, 'setValue'); const { stdin, unmount } = renderDialog(settings, vi.fn()); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Start editing first array setting }); await typeInput(stdin, 'first/path, second/path,third/path'); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Commit }); await waitFor(() => { expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'context.fileFiltering.customIgnoreFilePaths', ['first/path', 'second/path', 'third/path'], ); }); unmount(); }); it('should parse JSON array input for allowedExtensions', async () => { vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA); const settings = createMockSettings(); const setValueSpy = vi.spyOn(settings, 'setValue'); const { stdin, unmount } = renderDialog(settings, vi.fn()); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW as string); // Move to second array setting }); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Start editing }); await typeInput(stdin, '["^github\\\\.com/.*$", "^gitlab\\\\.com/.*$"]'); await act(async () => { stdin.write(TerminalKeys.ENTER as string); // Commit }); await waitFor(() => { expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, 'security.allowedExtensions', ['^github\\.com/.*$', '^gitlab\\.com/.*$'], ); }); 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('Disable 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('Enable 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: { hideWindowTitle: true, hideTips: true, 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, hideWindowTitle: false, }, tools: { truncateToolOutputThreshold: 50000, }, context: { discoveryMaxDirs: 500, }, model: { maxSessionTurns: 100, skipNextSpeakerCheck: false, }, }, 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, skipNextSpeakerCheck: true, }, }, systemSettings: {}, workspaceSettings: {}, stdinActions: undefined, }, { name: 'all boolean settings disabled', userSettings: { general: { vimMode: false, enableAutoUpdate: true, debugKeystrokeLogging: false, }, ui: { hideWindowTitle: false, hideTips: false, 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(); }, ); }); });