/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; // Helper to write to stdin with proper act() wrapping const writeKey = (stdin: { write: (data: string) => void }, key: string) => { act(() => { stdin.write(key); }); }; describe('AskUserDialog', () => { afterEach(() => { vi.restoreAllMocks(); }); const authQuestion: Question[] = [ { question: 'Which authentication method should we use?', header: 'Auth', options: [ { label: 'OAuth 2.0', description: 'Industry standard, supports SSO' }, { label: 'JWT tokens', description: 'Stateless, good for APIs' }, ], multiSelect: false, }, ]; it('renders question and options', () => { const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); describe.each([ { name: 'Single Select', questions: authQuestion, actions: (stdin: { write: (data: string) => void }) => { writeKey(stdin, '\r'); }, expectedSubmit: { '0': 'OAuth 2.0' }, }, { name: 'Multi-select', questions: [ { question: 'Which features?', header: 'Features', options: [ { label: 'TypeScript', description: '' }, { label: 'ESLint', description: '' }, ], multiSelect: true, }, ] as Question[], actions: (stdin: { write: (data: string) => void }) => { writeKey(stdin, '\r'); // Toggle TS writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\r'); // Toggle ESLint writeKey(stdin, '\x1b[B'); // Down to Other writeKey(stdin, '\x1b[B'); // Down to Done writeKey(stdin, '\r'); // Done }, expectedSubmit: { '0': 'TypeScript, ESLint' }, }, { name: 'Text Input', questions: [ { question: 'Name?', header: 'Name', type: QuestionType.TEXT, }, ] as Question[], actions: (stdin: { write: (data: string) => void }) => { for (const char of 'test-app') { writeKey(stdin, char); } writeKey(stdin, '\r'); }, expectedSubmit: { '0': 'test-app' }, }, ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); const { stdin } = renderWithProviders( , ); actions(stdin); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); }); }); }); it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( , ); // Move down to custom option writeKey(stdin, '\x1b[B'); writeKey(stdin, '\x1b[B'); await waitFor(() => { expect(lastFrame()).toContain('Enter a custom value'); }); // Type directly (inline) for (const char of 'API Key') { writeKey(stdin, char); } await waitFor(() => { expect(lastFrame()).toContain('API Key'); }); // Press Enter to submit the custom value writeKey(stdin, '\r'); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); }); }); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { const { stdin, lastFrame } = renderWithProviders( , ); // Type a character without navigating down writeKey(stdin, 'A'); await waitFor(() => { // Should show the custom input with 'A' // Placeholder is hidden when text is present expect(lastFrame()).toContain('A'); expect(lastFrame()).toContain('3. A'); }); // Continue typing writeKey(stdin, 'P'); writeKey(stdin, 'I'); await waitFor(() => { expect(lastFrame()).toContain('API'); }); }); it('shows progress header for multiple questions', () => { const multiQuestions: Question[] = [ { question: 'Which database should we use?', header: 'Database', options: [ { label: 'PostgreSQL', description: 'Relational database' }, { label: 'MongoDB', description: 'Document database' }, ], multiSelect: false, }, { question: 'Which ORM do you prefer?', header: 'ORM', options: [ { label: 'Prisma', description: 'Type-safe ORM' }, { label: 'Drizzle', description: 'Lightweight ORM' }, ], multiSelect: false, }, ]; const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', () => { const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', () => { const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('navigates between questions with arrow keys', async () => { const multiQuestions: Question[] = [ { question: 'Which testing framework?', header: 'Testing', options: [{ label: 'Vitest', description: 'Fast unit testing' }], multiSelect: false, }, { question: 'Which CI provider?', header: 'CI', options: [ { label: 'GitHub Actions', description: 'Built into GitHub' }, ], multiSelect: false, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow await waitFor(() => { expect(lastFrame()).toContain('Which CI provider?'); }); writeKey(stdin, '\x1b[D'); // Left arrow await waitFor(() => { expect(lastFrame()).toContain('Which testing framework?'); }); }); it('preserves answers when navigating back', async () => { const multiQuestions: Question[] = [ { question: 'Which package manager?', header: 'Package', options: [{ label: 'pnpm', description: 'Fast, disk efficient' }], multiSelect: false, }, { question: 'Which bundler?', header: 'Bundler', options: [{ label: 'Vite', description: 'Next generation bundler' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( , ); // Answer first question (should auto-advance) writeKey(stdin, '\r'); await waitFor(() => { expect(lastFrame()).toContain('Which bundler?'); }); // Navigate back writeKey(stdin, '\x1b[D'); await waitFor(() => { expect(lastFrame()).toContain('Which package manager?'); }); // Navigate forward writeKey(stdin, '\x1b[C'); await waitFor(() => { expect(lastFrame()).toContain('Which bundler?'); }); // Answer second question writeKey(stdin, '\r'); await waitFor(() => { expect(lastFrame()).toContain('Review your answers:'); }); // Submit from Review writeKey(stdin, '\r'); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); }); }); it('shows Review tab in progress header for multiple questions', () => { const multiQuestions: Question[] = [ { question: 'Which framework?', header: 'Framework', options: [ { label: 'React', description: 'Component library' }, { label: 'Vue', description: 'Progressive framework' }, ], multiSelect: false, }, { question: 'Which styling?', header: 'Styling', options: [ { label: 'Tailwind', description: 'Utility-first CSS' }, { label: 'CSS Modules', description: 'Scoped styles' }, ], multiSelect: false, }, ]; const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('allows navigating to Review tab and back', async () => { const multiQuestions: Question[] = [ { question: 'Create tests?', header: 'Tests', options: [{ label: 'Yes', description: 'Generate test files' }], multiSelect: false, }, { question: 'Add documentation?', header: 'Docs', options: [{ label: 'Yes', description: 'Generate JSDoc comments' }], multiSelect: false, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); writeKey(stdin, '\x1b[C'); // Right arrow await waitFor(() => { expect(lastFrame()).toContain('Add documentation?'); }); writeKey(stdin, '\x1b[C'); // Right arrow to Review await waitFor(() => { expect(lastFrame()).toMatchSnapshot(); }); writeKey(stdin, '\x1b[D'); // Left arrow back await waitFor(() => { expect(lastFrame()).toContain('Add documentation?'); }); }); it('shows warning for unanswered questions on Review tab', async () => { const multiQuestions: Question[] = [ { question: 'Which license?', header: 'License', options: [{ label: 'MIT', description: 'Permissive license' }], multiSelect: false, }, { question: 'Include README?', header: 'README', options: [{ label: 'Yes', description: 'Generate README.md' }], multiSelect: false, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); // Navigate directly to Review tab without answering writeKey(stdin, '\x1b[C'); writeKey(stdin, '\x1b[C'); await waitFor(() => { expect(lastFrame()).toMatchSnapshot(); }); }); it('submits with unanswered questions when user confirms on Review', async () => { const multiQuestions: Question[] = [ { question: 'Target Node version?', header: 'Node', options: [{ label: 'Node 20', description: 'LTS version' }], multiSelect: false, }, { question: 'Enable strict mode?', header: 'Strict', options: [{ label: 'Yes', description: 'Strict TypeScript' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin } = renderWithProviders( , ); // Answer only first question writeKey(stdin, '\r'); // Navigate to Review tab writeKey(stdin, '\x1b[C'); // Submit writeKey(stdin, '\r'); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); }); }); describe('Text type questions', () => { it('renders text input for type: "text"', () => { const textQuestion: Question[] = [ { question: 'What should we name this component?', header: 'Name', type: QuestionType.TEXT, placeholder: 'e.g., UserProfileCard', }, ]; const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('shows default placeholder when none provided', () => { const textQuestion: Question[] = [ { question: 'Enter the database connection string:', header: 'Database', type: QuestionType.TEXT, }, ]; const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('supports backspace in text mode', async () => { const textQuestion: Question[] = [ { question: 'Enter the function name:', header: 'Function', type: QuestionType.TEXT, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); for (const char of 'abc') { writeKey(stdin, char); } await waitFor(() => { expect(lastFrame()).toContain('abc'); }); writeKey(stdin, '\x7f'); // Backspace await waitFor(() => { expect(lastFrame()).toContain('ab'); expect(lastFrame()).not.toContain('abc'); }); }); it('shows correct keyboard hints for text type', () => { const textQuestion: Question[] = [ { question: 'Enter the variable name:', header: 'Variable', type: QuestionType.TEXT, }, ]; const { lastFrame } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('preserves text answer when navigating between questions', async () => { const mixedQuestions: Question[] = [ { question: 'What should we name this hook?', header: 'Hook', type: QuestionType.TEXT, }, { question: 'Should it be async?', header: 'Async', options: [ { label: 'Yes', description: 'Use async/await' }, { label: 'No', description: 'Synchronous hook' }, ], multiSelect: false, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); for (const char of 'useAuth') { writeKey(stdin, char); } writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active await waitFor(() => { expect(lastFrame()).toContain('Should it be async?'); }); writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input // Wait, Async question is a CHOICE question, so Left arrow SHOULD work. // But ChoiceQuestionView also captures editing custom option state? // No, only if it is FOCUSING the custom option. await waitFor(() => { expect(lastFrame()).toContain('useAuth'); }); }); it('handles mixed text and choice questions', async () => { const mixedQuestions: Question[] = [ { question: 'What should we name this component?', header: 'Name', type: QuestionType.TEXT, placeholder: 'Enter component name', }, { question: 'Which styling approach?', header: 'Style', options: [ { label: 'CSS Modules', description: 'Scoped CSS' }, { label: 'Tailwind', description: 'Utility classes' }, ], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( , ); for (const char of 'DataTable') { writeKey(stdin, char); } writeKey(stdin, '\r'); await waitFor(() => { expect(lastFrame()).toContain('Which styling approach?'); }); writeKey(stdin, '\r'); await waitFor(() => { expect(lastFrame()).toContain('Review your answers:'); expect(lastFrame()).toContain('Name'); expect(lastFrame()).toContain('DataTable'); expect(lastFrame()).toContain('Style'); expect(lastFrame()).toContain('CSS Modules'); }); writeKey(stdin, '\r'); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'DataTable', '1': 'CSS Modules', }); }); }); it('does not submit empty text', () => { const textQuestion: Question[] = [ { question: 'Enter the class name:', header: 'Class', type: QuestionType.TEXT, }, ]; const onSubmit = vi.fn(); const { stdin } = renderWithProviders( , ); writeKey(stdin, '\r'); // onSubmit should not be called for empty text expect(onSubmit).not.toHaveBeenCalled(); }); it('clears text on Ctrl+C', async () => { const textQuestion: Question[] = [ { question: 'Enter the class name:', header: 'Class', type: QuestionType.TEXT, }, ]; const onCancel = vi.fn(); const { stdin, lastFrame } = renderWithProviders( , ); for (const char of 'SomeText') { writeKey(stdin, char); } await waitFor(() => { expect(lastFrame()).toContain('SomeText'); }); // Send Ctrl+C writeKey(stdin, '\x03'); // Ctrl+C await waitFor(() => { // Text should be cleared expect(lastFrame()).not.toContain('SomeText'); expect(lastFrame()).toContain('>'); }); // Should NOT call onCancel (dialog should stay open) expect(onCancel).not.toHaveBeenCalled(); }); it('allows immediate arrow navigation after switching away from text input', async () => { const multiQuestions: Question[] = [ { question: 'Choice Q?', header: 'Choice', options: [{ label: 'Option 1', description: '' }], multiSelect: false, }, { question: 'Text Q?', header: 'Text', type: QuestionType.TEXT, }, ]; const { stdin, lastFrame } = renderWithProviders( , ); // 1. Move to Text Q (Right arrow works for Choice Q) writeKey(stdin, '\x1b[C'); await waitFor(() => { expect(lastFrame()).toContain('Text Q?'); }); // 2. Type something in Text Q to make isEditingCustomOption true writeKey(stdin, 'a'); await waitFor(() => { expect(lastFrame()).toContain('a'); }); // 3. Move back to Choice Q (Left arrow works because cursor is at left edge) // When typing 'a', cursor is at index 1. // We need to move cursor to index 0 first for Left arrow to work for navigation. writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0 await waitFor(() => { expect(lastFrame()).toContain('Text Q?'); }); writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation await waitFor(() => { expect(lastFrame()).toContain('Choice Q?'); }); // 4. Immediately try Right arrow to go back to Text Q writeKey(stdin, '\x1b[C'); await waitFor(() => { expect(lastFrame()).toContain('Text Q?'); }); }); it('handles rapid sequential answers correctly (stale closure protection)', async () => { const multiQuestions: Question[] = [ { question: 'Question 1?', header: 'Q1', options: [{ label: 'A1', description: '' }], multiSelect: false, }, { question: 'Question 2?', header: 'Q2', options: [{ label: 'A2', description: '' }], multiSelect: false, }, ]; const onSubmit = vi.fn(); const { stdin, lastFrame } = renderWithProviders( , ); // Answer Q1 and Q2 sequentialy act(() => { stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance }); await waitFor(() => { expect(lastFrame()).toContain('Question 2?'); }); act(() => { stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review }); await waitFor(() => { expect(lastFrame()).toContain('Review your answers:'); }); act(() => { stdin.write('\r'); // Submit from Review }); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'A1', '1': 'A2', }); }); }); }); });