Files
gemini-cli/packages/cli/src/ui/components/SettingsDialog.test.tsx

1875 lines
52 KiB
TypeScript

/**
* @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<typeof import('../../config/settingsSchema.js')>();
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<typeof createMockSettings>,
onSelect: ReturnType<typeof vi.fn>,
options?: {
onRestartRequest?: ReturnType<typeof vi.fn>;
availableTerminalHeight?: number;
},
) =>
render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>
</KeypressProvider>
</SettingsContext.Provider>,
);
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(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
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(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
// 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<void>,
) => {
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();
},
);
});
});