mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 09:30:58 -07:00
550 lines
15 KiB
TypeScript
550 lines
15 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright 2025 Google LLC
|
||
|
|
* SPDX-License-Identifier: Apache-2.0
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { render } 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 { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||
|
|
import { SettingScope } from '../../../config/settings.js';
|
||
|
|
|
||
|
|
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||
|
|
useUIState: () => ({
|
||
|
|
mainAreaWidth: 100,
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
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 = (): 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',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
describe('BaseSettingsDialog', () => {
|
||
|
|
let mockOnItemToggle: ReturnType<typeof vi.fn>;
|
||
|
|
let mockOnEditCommit: ReturnType<typeof vi.fn>;
|
||
|
|
let mockOnItemClear: ReturnType<typeof vi.fn>;
|
||
|
|
let mockOnClose: ReturnType<typeof vi.fn>;
|
||
|
|
let mockOnScopeChange: ReturnType<typeof vi.fn>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
mockOnItemToggle = vi.fn();
|
||
|
|
mockOnEditCommit = vi.fn();
|
||
|
|
mockOnItemClear = vi.fn();
|
||
|
|
mockOnClose = vi.fn();
|
||
|
|
mockOnScopeChange = vi.fn();
|
||
|
|
});
|
||
|
|
|
||
|
|
const renderDialog = (props: Partial<BaseSettingsDialogProps> = {}) => {
|
||
|
|
const defaultProps: BaseSettingsDialogProps = {
|
||
|
|
title: 'Test Settings',
|
||
|
|
items: createMockItems(),
|
||
|
|
selectedScope: SettingScope.User,
|
||
|
|
maxItemsToShow: 8,
|
||
|
|
onItemToggle: mockOnItemToggle,
|
||
|
|
onEditCommit: mockOnEditCommit,
|
||
|
|
onItemClear: mockOnItemClear,
|
||
|
|
onClose: mockOnClose,
|
||
|
|
...props,
|
||
|
|
};
|
||
|
|
|
||
|
|
return render(
|
||
|
|
<KeypressProvider>
|
||
|
|
<BaseSettingsDialog {...defaultProps} />
|
||
|
|
</KeypressProvider>,
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('rendering', () => {
|
||
|
|
it('should render the dialog with title', () => {
|
||
|
|
const { lastFrame } = renderDialog();
|
||
|
|
expect(lastFrame()).toContain('Test Settings');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should render all items', () => {
|
||
|
|
const { lastFrame } = renderDialog();
|
||
|
|
const frame = lastFrame();
|
||
|
|
|
||
|
|
expect(frame).toContain('Boolean Setting');
|
||
|
|
expect(frame).toContain('String Setting');
|
||
|
|
expect(frame).toContain('Number Setting');
|
||
|
|
expect(frame).toContain('Enum Setting');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should render help text with Ctrl+L for reset', () => {
|
||
|
|
const { lastFrame } = 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');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should render scope selector when showScopeSelector is true', () => {
|
||
|
|
const { lastFrame } = renderDialog({
|
||
|
|
showScopeSelector: true,
|
||
|
|
onScopeChange: mockOnScopeChange,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(lastFrame()).toContain('Apply To');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should not render scope selector when showScopeSelector is false', () => {
|
||
|
|
const { lastFrame } = renderDialog({
|
||
|
|
showScopeSelector: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(lastFrame()).not.toContain('Apply To');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should render footer content when provided', () => {
|
||
|
|
const { lastFrame } = renderDialog({
|
||
|
|
footerContent: <Text>Custom Footer</Text>,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(lastFrame()).toContain('Custom Footer');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('keyboard navigation', () => {
|
||
|
|
it('should close dialog on Escape', async () => {
|
||
|
|
const { stdin } = renderDialog();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ESCAPE);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnClose).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should navigate down with arrow key', async () => {
|
||
|
|
const { lastFrame, stdin } = 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);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should navigate up with arrow key', async () => {
|
||
|
|
const { stdin } = renderDialog();
|
||
|
|
|
||
|
|
// Press down then up
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.DOWN_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.UP_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should be back at first item
|
||
|
|
await waitFor(() => {
|
||
|
|
// First item should be active again
|
||
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should wrap around when navigating past last item', async () => {
|
||
|
|
const items = createMockItems().slice(0, 2); // Only 2 items
|
||
|
|
const { stdin } = renderDialog({ items });
|
||
|
|
|
||
|
|
// Press down twice to go past the last item
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.DOWN_ARROW);
|
||
|
|
stdin.write(TerminalKeys.DOWN_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should wrap to first item - verify no crash
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should wrap around when navigating before first item', async () => {
|
||
|
|
const { stdin } = renderDialog();
|
||
|
|
|
||
|
|
// Press up at first item
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.UP_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should wrap to last item - verify no crash
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should switch focus with Tab when scope selector is shown', async () => {
|
||
|
|
const { lastFrame, stdin } = 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 waitFor(() => {
|
||
|
|
expect(lastFrame()).toContain('> Apply To');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('item interactions', () => {
|
||
|
|
it('should call onItemToggle for boolean items on Enter', async () => {
|
||
|
|
const { stdin } = renderDialog();
|
||
|
|
|
||
|
|
// Press Enter on first item (boolean)
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||
|
|
'boolean-setting',
|
||
|
|
expect.objectContaining({ type: 'boolean' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should call onItemToggle for enum items on Enter', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
// Move enum to first position
|
||
|
|
const enumItem = items.find((i) => i.type === 'enum')!;
|
||
|
|
const { stdin } = renderDialog({ items: [enumItem] });
|
||
|
|
|
||
|
|
// Press Enter on enum item
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnItemToggle).toHaveBeenCalledWith(
|
||
|
|
'enum-setting',
|
||
|
|
expect.objectContaining({ type: 'enum' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should enter edit mode for string items on Enter', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const stringItem = items.find((i) => i.type === 'string')!;
|
||
|
|
const { lastFrame, stdin } = renderDialog({ items: [stringItem] });
|
||
|
|
|
||
|
|
// Press Enter to start editing
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should enter edit mode for number items on Enter', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const numberItem = items.find((i) => i.type === 'number')!;
|
||
|
|
const { lastFrame, stdin } = renderDialog({ items: [numberItem] });
|
||
|
|
|
||
|
|
// Press Enter to start editing
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should show the edit buffer
|
||
|
|
await waitFor(() => {
|
||
|
|
const frame = lastFrame();
|
||
|
|
expect(frame).toContain('42');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should call onItemClear on Ctrl+L', async () => {
|
||
|
|
const { stdin } = renderDialog();
|
||
|
|
|
||
|
|
// Press Ctrl+L to reset
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.CTRL_L);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnItemClear).toHaveBeenCalledWith(
|
||
|
|
'boolean-setting',
|
||
|
|
expect.objectContaining({ type: 'boolean' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('edit mode', () => {
|
||
|
|
it('should commit edit on Enter', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const stringItem = items.find((i) => i.type === 'string')!;
|
||
|
|
const { stdin } = renderDialog({ items: [stringItem] });
|
||
|
|
|
||
|
|
// Enter edit mode
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Type some characters
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('x');
|
||
|
|
});
|
||
|
|
|
||
|
|
// Commit with Enter
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||
|
|
'string-setting',
|
||
|
|
'test-valuex',
|
||
|
|
expect.objectContaining({ type: 'string' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should commit edit on Escape', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const stringItem = items.find((i) => i.type === 'string')!;
|
||
|
|
const { stdin } = renderDialog({ items: [stringItem] });
|
||
|
|
|
||
|
|
// Enter edit mode
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Commit with Escape
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ESCAPE);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should commit edit and navigate on Down arrow', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const stringItem = items.find((i) => i.type === 'string')!;
|
||
|
|
const numberItem = items.find((i) => i.type === 'number')!;
|
||
|
|
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||
|
|
|
||
|
|
// Enter edit mode
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Press Down to commit and navigate
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.DOWN_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should commit edit and navigate on Up arrow', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const stringItem = items.find((i) => i.type === 'string')!;
|
||
|
|
const numberItem = items.find((i) => i.type === 'number')!;
|
||
|
|
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
|
||
|
|
|
||
|
|
// Navigate to second item
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.DOWN_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Enter edit mode
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Press Up to commit and navigate
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.UP_ARROW);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should allow number input for number fields', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const numberItem = items.find((i) => i.type === 'number')!;
|
||
|
|
const { stdin } = renderDialog({ items: [numberItem] });
|
||
|
|
|
||
|
|
// Enter edit mode
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Type numbers one at a time
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('1');
|
||
|
|
});
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('2');
|
||
|
|
});
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('3');
|
||
|
|
});
|
||
|
|
|
||
|
|
// Commit
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||
|
|
'number-setting',
|
||
|
|
'42123',
|
||
|
|
expect.objectContaining({ type: 'number' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should support quick number entry for number fields', async () => {
|
||
|
|
const items = createMockItems();
|
||
|
|
const numberItem = items.find((i) => i.type === 'number')!;
|
||
|
|
const { stdin } = renderDialog({ items: [numberItem] });
|
||
|
|
|
||
|
|
// Type a number directly (without Enter first)
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('5');
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should start editing with that number
|
||
|
|
await waitFor(() => {
|
||
|
|
// Commit to verify
|
||
|
|
act(() => {
|
||
|
|
stdin.write(TerminalKeys.ENTER);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(mockOnEditCommit).toHaveBeenCalledWith(
|
||
|
|
'number-setting',
|
||
|
|
'5',
|
||
|
|
expect.objectContaining({ type: 'number' }),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('custom key handling', () => {
|
||
|
|
it('should call onKeyPress and respect its return value', async () => {
|
||
|
|
const customKeyHandler = vi.fn().mockReturnValue(true);
|
||
|
|
const { stdin } = renderDialog({
|
||
|
|
onKeyPress: customKeyHandler,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Press a key
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write('r');
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(customKeyHandler).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Since handler returned true, default behavior should be blocked
|
||
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('focus management', () => {
|
||
|
|
it('should keep focus on settings when scope selector is hidden', async () => {
|
||
|
|
const { lastFrame, stdin } = renderDialog({
|
||
|
|
showScopeSelector: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Press Tab - should not crash and focus should stay on settings
|
||
|
|
await act(async () => {
|
||
|
|
stdin.write(TerminalKeys.TAB);
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
// Should still show settings as focused
|
||
|
|
expect(lastFrame()).toContain('> Test Settings');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|