/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { Text } from 'ink'; import { BaseSettingsDialog, type BaseSettingsDialogProps, type SettingsDialogItem, } from './BaseSettingsDialog.js'; import { SettingScope } from '../../../config/settings.js'; 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_L = '\u000C', } const createMockItems = (count = 4): SettingsDialogItem[] => { const items: SettingsDialogItem[] = [ { key: 'boolean-setting', label: 'Boolean Setting', description: 'A boolean setting for testing', displayValue: 'true', rawValue: true, type: 'boolean', }, { key: 'string-setting', label: 'String Setting', description: 'A string setting for testing', displayValue: 'test-value', rawValue: 'test-value', type: 'string', }, { key: 'number-setting', label: 'Number Setting', description: 'A number setting for testing', displayValue: '42', rawValue: 42, type: 'number', }, { key: 'enum-setting', label: 'Enum Setting', description: 'An enum setting for testing', displayValue: 'option-a', rawValue: 'option-a', type: 'enum', }, ]; // If count is larger than our base mock items, generate dynamic ones if (count > items.length) { for (let i = items.length; i < count; i++) { items.push({ key: `extra-setting-${i}`, label: `Extra Setting ${i}`, displayValue: `value-${i}`, type: 'string', }); } } return items.slice(0, count); }; describe('BaseSettingsDialog', () => { let mockOnItemToggle: ReturnType; let mockOnEditCommit: ReturnType; let mockOnItemClear: ReturnType; let mockOnClose: ReturnType; let mockOnScopeChange: ReturnType; beforeEach(() => { vi.clearAllMocks(); mockOnItemToggle = vi.fn(); mockOnEditCommit = vi.fn(); mockOnItemClear = vi.fn(); mockOnClose = vi.fn(); mockOnScopeChange = vi.fn(); }); const renderDialog = async (props: Partial = {}) => { const defaultProps: BaseSettingsDialogProps = { title: 'Test Settings', items: createMockItems(), selectedScope: SettingScope.User, maxItemsToShow: 8, onItemToggle: mockOnItemToggle, onEditCommit: mockOnEditCommit, onItemClear: mockOnItemClear, onClose: mockOnClose, ...props, }; const result = renderWithProviders( , ); await result.waitUntilReady(); return result; }; describe('rendering', () => { it('should render the dialog with title', async () => { const { lastFrame, unmount } = await renderDialog(); expect(lastFrame()).toContain('Test Settings'); unmount(); }); it('should render all items', async () => { const { lastFrame, unmount } = await renderDialog(); const frame = lastFrame(); expect(frame).toContain('Boolean Setting'); expect(frame).toContain('String Setting'); expect(frame).toContain('Number Setting'); expect(frame).toContain('Enum Setting'); unmount(); }); it('should render help text with Ctrl+L for reset', async () => { const { lastFrame, unmount } = await renderDialog(); const frame = lastFrame(); expect(frame).toContain('Use Enter to select'); expect(frame).toContain('Ctrl+L to reset'); expect(frame).toContain('Tab to change focus'); expect(frame).toContain('Esc to close'); unmount(); }); it('should render scope selector when showScopeSelector is true', async () => { const { lastFrame, unmount } = await renderDialog({ showScopeSelector: true, onScopeChange: mockOnScopeChange, }); expect(lastFrame()).toContain('Apply To'); unmount(); }); it('should not render scope selector when showScopeSelector is false', async () => { const { lastFrame, unmount } = await renderDialog({ showScopeSelector: false, }); expect(lastFrame({ allowEmpty: true })).not.toContain('Apply To'); unmount(); }); it('should render footer content when provided', async () => { const { lastFrame, unmount } = await renderDialog({ footer: { content: Custom Footer, height: 1, }, }); expect(lastFrame()).toContain('Custom Footer'); unmount(); }); }); describe('keyboard navigation', () => { it('should close dialog on Escape', async () => { const { stdin, waitUntilReady, unmount } = await renderDialog(); await act(async () => { stdin.write(TerminalKeys.ESCAPE); }); // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act await act(async () => { await waitUntilReady(); }); await waitFor(() => { expect(mockOnClose).toHaveBeenCalled(); }); unmount(); }); it('should navigate down with arrow key', async () => { const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog(); // Initially first item is active (indicated by bullet point) const initialFrame = lastFrame(); expect(initialFrame).toContain('Boolean Setting'); // Press down arrow await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Navigation should move to next item await waitFor(() => { const frame = lastFrame(); // The active indicator should now be on a different row expect(frame).toContain('String Setting'); }); unmount(); }); it('should navigate up with arrow key', async () => { const { stdin, waitUntilReady, unmount } = await renderDialog(); // Press down then up await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.UP_ARROW); }); await waitUntilReady(); // Should be back at first item await waitFor(() => { // First item should be active again expect(mockOnClose).not.toHaveBeenCalled(); }); unmount(); }); it('should wrap around when navigating past last item', async () => { const items = createMockItems(2); // Only 2 items const { stdin, waitUntilReady, unmount } = await renderDialog({ items }); // Press down twice to go past the last item await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Should wrap to first item - verify no crash await waitFor(() => { expect(mockOnClose).not.toHaveBeenCalled(); }); unmount(); }); it('should wrap around when navigating before first item', async () => { const { stdin, waitUntilReady, unmount } = await renderDialog(); // Press up at first item await act(async () => { stdin.write(TerminalKeys.UP_ARROW); }); await waitUntilReady(); // Should wrap to last item - verify no crash await waitFor(() => { expect(mockOnClose).not.toHaveBeenCalled(); }); unmount(); }); it('should switch focus with Tab when scope selector is shown', async () => { const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({ showScopeSelector: true, onScopeChange: mockOnScopeChange, }); // Initially settings section is focused (indicated by >) expect(lastFrame()).toContain('> Test Settings'); // Press Tab to switch to scope selector await act(async () => { stdin.write(TerminalKeys.TAB); }); await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('> Apply To'); }); unmount(); }); }); describe('scrolling and resizing list (search filtering)', () => { it('should preserve focus on the active item if it remains in the filtered list', async () => { const items = createMockItems(5); // items 0 to 4 const { rerender, stdin, lastFrame, waitUntilReady, unmount } = await renderDialog({ items, maxItemsToShow: 5, }); // Move focus down to item 2 ("Number Setting") // Separate acts needed so React state updates between keypresses await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Rerender with a filtered list where "Number Setting" is now at index 1 const filteredItems = [items[0], items[2], items[4]]; await act(async () => { rerender( , ); }); // Verify the dialog hasn't crashed and the items are displayed await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Boolean Setting'); expect(frame).toContain('Number Setting'); expect(frame).toContain('Extra Setting 4'); expect(frame).not.toContain('No matches found.'); }); // Press Enter. If focus was preserved, it should be on "Number Setting" (index 1). // Since it's a number, it enters edit mode (mockOnItemToggle is NOT called). await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnItemToggle).not.toHaveBeenCalled(); }); unmount(); }); it('should reset focus to the top if the active item is filtered out', async () => { const items = createMockItems(5); const { rerender, stdin, lastFrame, waitUntilReady, unmount } = await renderDialog({ items, maxItemsToShow: 5, }); // Move focus down to item 2 ("Number Setting") await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Rerender with a filtered list that EXCLUDES "Number Setting" const filteredItems = [items[0], items[1]]; await act(async () => { rerender( , ); }); await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Boolean Setting'); expect(frame).toContain('String Setting'); }); // Press Enter. Since focus reset to index 0 ("Boolean Setting"), it should toggle. await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnItemToggle).toHaveBeenCalledWith( 'boolean-setting', expect.anything(), ); }); unmount(); }); }); describe('item interactions', () => { it('should call onItemToggle for boolean items on Enter', async () => { const { stdin, waitUntilReady, unmount } = await renderDialog(); // Press Enter on first item (boolean) await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnItemToggle).toHaveBeenCalledWith( 'boolean-setting', expect.objectContaining({ type: 'boolean' }), ); }); unmount(); }); it('should call onItemToggle for enum items on Enter', async () => { const items = createMockItems(4); // Move enum to first position const enumItem = items.find((i) => i.type === 'enum')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [enumItem], }); // Press Enter on enum item await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnItemToggle).toHaveBeenCalledWith( 'enum-setting', expect.objectContaining({ type: 'enum' }), ); }); unmount(); }); it('should enter edit mode for string items on Enter', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem], }); // Press Enter to start editing await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Should show the edit buffer with cursor await waitFor(() => { const frame = lastFrame(); // In edit mode, the value should be displayed (possibly with cursor) expect(frame).toContain('test-value'); }); unmount(); }); it('should enter edit mode for number items on Enter', async () => { const items = createMockItems(4); const numberItem = items.find((i) => i.type === 'number')!; const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({ items: [numberItem], }); // Press Enter to start editing await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Should show the edit buffer await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('42'); }); unmount(); }); it('should call onItemClear on Ctrl+L', async () => { const { stdin, waitUntilReady, unmount } = await renderDialog(); // Press Ctrl+L to reset await act(async () => { stdin.write(TerminalKeys.CTRL_L); }); await waitUntilReady(); await waitFor(() => { expect(mockOnItemClear).toHaveBeenCalledWith( 'boolean-setting', expect.objectContaining({ type: 'boolean' }), ); }); unmount(); }); }); describe('edit mode', () => { it('should prioritize editValue over rawValue stringification', async () => { const objectItem: SettingsDialogItem = { key: 'object-setting', label: 'Object Setting', description: 'A complex object setting', displayValue: '{"foo":"bar"}', type: 'object', rawValue: { foo: 'bar' }, editValue: '{"foo":"bar"}', }; const { stdin } = await renderDialog({ items: [objectItem], }); // Enter edit mode and immediately commit await act(async () => { stdin.write(TerminalKeys.ENTER); }); await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalledWith( 'object-setting', '{"foo":"bar"}', expect.objectContaining({ type: 'object' }), ); }); }); it('should commit edit on Enter', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem], }); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Type some characters await act(async () => { stdin.write('x'); }); await waitUntilReady(); // Commit with Enter await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalledWith( 'string-setting', 'test-valuex', expect.objectContaining({ type: 'string' }), ); }); unmount(); }); it('should commit edit on Escape', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem], }); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Commit with Escape await act(async () => { stdin.write(TerminalKeys.ESCAPE); }); // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act await act(async () => { await waitUntilReady(); }); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalled(); }); unmount(); }); it('should commit edit and navigate on Down arrow', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const numberItem = items.find((i) => i.type === 'number')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem, numberItem], }); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Press Down to commit and navigate await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalled(); }); unmount(); }); it('should commit edit and navigate on Up arrow', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const numberItem = items.find((i) => i.type === 'number')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem, numberItem], }); // Navigate to second item await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); await waitUntilReady(); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Press Up to commit and navigate await act(async () => { stdin.write(TerminalKeys.UP_ARROW); }); await waitUntilReady(); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalled(); }); unmount(); }); it('should allow number input for number fields', async () => { const items = createMockItems(4); const numberItem = items.find((i) => i.type === 'number')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [numberItem], }); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Type numbers one at a time await act(async () => { stdin.write('1'); }); await waitUntilReady(); await act(async () => { stdin.write('2'); }); await waitUntilReady(); await act(async () => { stdin.write('3'); }); await waitUntilReady(); // Commit await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalledWith( 'number-setting', '42123', expect.objectContaining({ type: 'number' }), ); }); unmount(); }); it('should support quick number entry for number fields', async () => { const items = createMockItems(4); const numberItem = items.find((i) => i.type === 'number')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [numberItem], }); // Type a number directly (without Enter first) await act(async () => { stdin.write('5'); }); await waitUntilReady(); // Should start editing with that number await waitFor(async () => { // Commit to verify await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); }); await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalledWith( 'number-setting', '5', expect.objectContaining({ type: 'number' }), ); }); unmount(); }); it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; const { stdin, waitUntilReady, unmount } = await renderDialog({ items: [stringItem], }); // Enter edit mode await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // Type 'j' - should appear in field, NOT trigger navigation await act(async () => { stdin.write('j'); }); await waitUntilReady(); // Type 'k' - should appear in field, NOT trigger navigation await act(async () => { stdin.write('k'); }); await waitUntilReady(); // Commit with Enter await act(async () => { stdin.write(TerminalKeys.ENTER); }); await waitUntilReady(); // j and k should be typed into the field await waitFor(() => { expect(mockOnEditCommit).toHaveBeenCalledWith( 'string-setting', 'test-valuejk', // entered value + j and k expect.objectContaining({ type: 'string' }), ); }); unmount(); }); }); describe('custom key handling', () => { it('should call onKeyPress and respect its return value', async () => { const customKeyHandler = vi.fn().mockReturnValue(true); const { stdin, waitUntilReady, unmount } = await renderDialog({ onKeyPress: customKeyHandler, }); // Press a key await act(async () => { stdin.write('r'); }); await waitUntilReady(); await waitFor(() => { expect(customKeyHandler).toHaveBeenCalled(); }); // Since handler returned true, default behavior should be blocked expect(mockOnClose).not.toHaveBeenCalled(); unmount(); }); }); describe('focus management', () => { it('should keep focus on settings when scope selector is hidden', async () => { const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({ showScopeSelector: false, }); // Press Tab - should not crash and focus should stay on settings await act(async () => { stdin.write(TerminalKeys.TAB); }); await waitUntilReady(); await waitFor(() => { // Should still show settings as focused expect(lastFrame()).toContain('> Test Settings'); }); unmount(); }); }); describe('responsiveness', () => { it('should show the scope selector when availableHeight is sufficient (25)', async () => { const { lastFrame, unmount } = await renderDialog({ availableHeight: 25, showScopeSelector: true, }); const frame = lastFrame(); expect(frame).toContain('Apply To'); unmount(); }); it('should hide the scope selector when availableHeight is small (24) to show more items', async () => { const { lastFrame, unmount } = await renderDialog({ availableHeight: 24, showScopeSelector: true, }); const frame = lastFrame(); expect(frame).not.toContain('Apply To'); unmount(); }); it('should reduce the number of visible items based on height', async () => { // At height 25, it should show 2 items (math: (25-4 - (10+5))/3 = 2) const { lastFrame, unmount } = await renderDialog({ availableHeight: 25, items: createMockItems(10), }); const frame = lastFrame(); // Items 0 and 1 should be there expect(frame).toContain('Boolean Setting'); expect(frame).toContain('String Setting'); // Item 2 should NOT be there expect(frame).not.toContain('Number Setting'); unmount(); }); it('should show scroll indicators when list is truncated by height', async () => { const { lastFrame, unmount } = await renderDialog({ availableHeight: 25, items: createMockItems(10), }); const frame = lastFrame(); // Shows both scroll indicators when the list is truncated by height expect(frame).toContain('▼'); expect(frame).toContain('▲'); unmount(); }); }); });