| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; |
| import { act } from 'react'; |
| import { renderWithProviders } from '../../test-utils/render.js'; |
| import { createMockSettings } from '../../test-utils/settings.js'; |
| import { makeFakeConfig } from '@google/gemini-cli-core'; |
| import { waitFor } from '../../test-utils/async.js'; |
| import { AskUserDialog } from './AskUserDialog.js'; |
| import { QuestionType, type Question } from '@google/gemini-cli-core'; |
| import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; |
|
|
| |
| const writeKey = (stdin: { write: (data: string) => void }, key: string) => { |
| act(() => { |
| stdin.write(key); |
| }); |
| }; |
|
|
| describe('AskUserDialog', () => { |
| |
| |
| let mockTime: number; |
| beforeEach(() => { |
| mockTime = 0; |
| vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50)); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| const authQuestion: Question[] = [ |
| { |
| question: 'Which authentication method should we use?', |
| header: 'Auth', |
| type: QuestionType.CHOICE, |
| 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', async () => { |
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| 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', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'TypeScript', description: '' }, |
| { label: 'ESLint', description: '' }, |
| ], |
| multiSelect: true, |
| }, |
| ] as Question[], |
| actions: (stdin: { write: (data: string) => void }) => { |
| writeKey(stdin, '\r'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
| }, |
| expectedSubmit: { '0': 'TypeScript, ESLint' }, |
| }, |
| { |
| name: 'All of the above', |
| questions: [ |
| { |
| question: 'Which features?', |
| header: 'Features', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'TypeScript', description: '' }, |
| { label: 'ESLint', description: '' }, |
| ], |
| multiSelect: true, |
| }, |
| ] as Question[], |
| actions: (stdin: { write: (data: string) => void }) => { |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
| }, |
| 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 } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| actions(stdin); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); |
| }); |
| }); |
| }); |
|
|
| it('verifies "All of the above" visual state with snapshot', async () => { |
| const questions = [ |
| { |
| question: 'Which features?', |
| header: 'Features', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'TypeScript', description: '' }, |
| { label: 'ESLint', description: '' }, |
| ], |
| multiSelect: true, |
| }, |
| ] as Question[]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
|
|
| it('handles custom option in single select with inline typing', async () => { |
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestion} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Enter a custom value'); |
| }); |
|
|
| |
| for (const char of 'API Key') { |
| writeKey(stdin, char); |
| } |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('API Key'); |
| }); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); |
| }); |
| }); |
|
|
| it('supports multi-line input for "Other" option in choice questions', async () => { |
| const authQuestionWithOther: Question[] = [ |
| { |
| question: 'Which authentication method?', |
| header: 'Auth', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'OAuth 2.0', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestionWithOther} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
|
|
| |
| for (const char of 'Line 1') { |
| writeKey(stdin, char); |
| } |
|
|
| |
| writeKey(stdin, '\\'); |
| writeKey(stdin, '\r'); |
|
|
| |
| for (const char of 'Line 2') { |
| writeKey(stdin, char); |
| } |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Line 1'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Line 2'); |
| }); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' }); |
| }); |
| }); |
|
|
| describe.each([ |
| { useAlternateBuffer: true, expectedArrows: true }, |
| { useAlternateBuffer: false, expectedArrows: true }, |
| ])( |
| 'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)', |
| ({ useAlternateBuffer, expectedArrows }) => { |
| it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Choose an option', |
| header: 'Scroll Test', |
| type: QuestionType.CHOICE, |
| options: Array.from({ length: 15 }, (_, i) => ({ |
| label: `Option ${i + 1}`, |
| description: `Description ${i + 1}`, |
| })), |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| availableHeight={10} // Small height to force scrolling |
| />, |
| { |
| config: makeFakeConfig({ useAlternateBuffer }), |
| settings: createMockSettings({ ui: { useAlternateBuffer } }), |
| }, |
| ); |
|
|
| await waitFor(async () => { |
| if (expectedArrows) { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('▲'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('▼'); |
| } else { |
| await waitUntilReady(); |
| expect(lastFrame()).not.toContain('▲'); |
| await waitUntilReady(); |
| expect(lastFrame()).not.toContain('▼'); |
| } |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
| }, |
| ); |
|
|
| it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, 'A'); |
|
|
| await waitFor(async () => { |
| |
| |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('A'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('3. A'); |
| }); |
|
|
| |
| writeKey(stdin, 'P'); |
| writeKey(stdin, 'I'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('API'); |
| }); |
| }); |
|
|
| it('shows progress header for multiple questions', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Which database should we use?', |
| header: 'Database', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'PostgreSQL', description: 'Relational database' }, |
| { label: 'MongoDB', description: 'Document database' }, |
| ], |
| multiSelect: false, |
| }, |
| { |
| question: 'Which ORM do you prefer?', |
| header: 'ORM', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'Prisma', description: 'Type-safe ORM' }, |
| { label: 'Drizzle', description: 'Lightweight ORM' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| it('hides progress header for single question', async () => { |
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| it('shows keyboard hints', async () => { |
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={authQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| it('navigates between questions with arrow keys', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Which testing framework?', |
| header: 'Testing', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Vitest', description: 'Fast unit testing' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Which CI provider?', |
| header: 'CI', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'GitHub Actions', description: 'Built into GitHub' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toContain('Which testing framework?'); |
|
|
| writeKey(stdin, '\x1b[C'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which CI provider?'); |
| }); |
|
|
| writeKey(stdin, '\x1b[D'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which testing framework?'); |
| }); |
| }); |
|
|
| it('preserves answers when navigating back', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Which package manager?', |
| header: 'Package', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'pnpm', description: 'Fast, disk efficient' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Which bundler?', |
| header: 'Bundler', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Vite', description: 'Next generation bundler' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which bundler?'); |
| }); |
|
|
| |
| writeKey(stdin, '\x1b[D'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which package manager?'); |
| }); |
|
|
| |
| writeKey(stdin, '\x1b[C'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which bundler?'); |
| }); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Review your answers:'); |
| }); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); |
| }); |
| }); |
|
|
| it('shows Review tab in progress header for multiple questions', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Which framework?', |
| header: 'Framework', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'React', description: 'Component library' }, |
| { label: 'Vue', description: 'Progressive framework' }, |
| ], |
| multiSelect: false, |
| }, |
| { |
| question: 'Which styling?', |
| header: 'Styling', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'Tailwind', description: 'Utility-first CSS' }, |
| { label: 'CSS Modules', description: 'Scoped styles' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| it('allows navigating to Review tab and back', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Create tests?', |
| header: 'Tests', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: 'Generate test files' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Add documentation?', |
| header: 'Docs', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: 'Generate JSDoc comments' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| writeKey(stdin, '\x1b[C'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Add documentation?'); |
| }); |
|
|
| writeKey(stdin, '\x1b[C'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| writeKey(stdin, '\x1b[D'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Add documentation?'); |
| }); |
| }); |
|
|
| it('shows warning for unanswered questions on Review tab', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Which license?', |
| header: 'License', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'MIT', description: 'Permissive license' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Include README?', |
| header: 'README', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: 'Generate README.md' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[C'); |
| writeKey(stdin, '\x1b[C'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
|
|
| it('submits with unanswered questions when user confirms on Review', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Target Node version?', |
| header: 'Node', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Node 20', description: 'LTS version' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Enable strict mode?', |
| header: 'Strict', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: 'Strict TypeScript' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\r'); |
| |
| writeKey(stdin, '\x1b[C'); |
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); |
| }); |
| }); |
|
|
| describe('Text type questions', () => { |
| it('renders text input for type: "text"', async () => { |
| const textQuestion: Question[] = [ |
| { |
| question: 'What should we name this component?', |
| header: 'Name', |
| type: QuestionType.TEXT, |
| placeholder: 'e.g., UserProfileCard', |
| }, |
| ]; |
|
|
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
|
|
| it('shows default placeholder when none provided', async () => { |
| const textQuestion: Question[] = [ |
| { |
| question: 'Enter the database connection string:', |
| header: 'Database', |
| type: QuestionType.TEXT, |
| }, |
| ]; |
|
|
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| 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, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| for (const char of 'abc') { |
| writeKey(stdin, char); |
| } |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('abc'); |
| }); |
|
|
| writeKey(stdin, '\x7f'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('ab'); |
| await waitUntilReady(); |
| expect(lastFrame()).not.toContain('abc'); |
| }); |
| }); |
|
|
| it('shows correct keyboard hints for text type', async () => { |
| const textQuestion: Question[] = [ |
| { |
| question: 'Enter the variable name:', |
| header: 'Variable', |
| type: QuestionType.TEXT, |
| }, |
| ]; |
|
|
| const { lastFrame } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| 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', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'Yes', description: 'Use async/await' }, |
| { label: 'No', description: 'Synchronous hook' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={mixedQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| for (const char of 'useAuth') { |
| writeKey(stdin, char); |
| } |
|
|
| writeKey(stdin, '\t'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Should it be async?'); |
| }); |
|
|
| writeKey(stdin, '\x1b[D'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| 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', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'CSS Modules', description: 'Scoped CSS' }, |
| { label: 'Tailwind', description: 'Utility classes' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={mixedQuestions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| for (const char of 'DataTable') { |
| writeKey(stdin, char); |
| } |
|
|
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Which styling approach?'); |
| }); |
|
|
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Review your answers:'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Name'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('DataTable'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Style'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('CSS Modules'); |
| }); |
|
|
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ |
| '0': 'DataTable', |
| '1': 'CSS Modules', |
| }); |
| }); |
| }); |
|
|
| it('submits empty text as unanswered', async () => { |
| const textQuestion: Question[] = [ |
| { |
| question: 'Enter the class name:', |
| header: 'Class', |
| type: QuestionType.TEXT, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({}); |
| }); |
| }); |
|
|
| 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, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={textQuestion} |
| onSubmit={vi.fn()} |
| onCancel={onCancel} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| for (const char of 'SomeText') { |
| writeKey(stdin, char); |
| } |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('SomeText'); |
| }); |
|
|
| |
| writeKey(stdin, '\x03'); |
|
|
| await waitFor(async () => { |
| |
| await waitUntilReady(); |
| expect(lastFrame()).not.toContain('SomeText'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('>'); |
| }); |
|
|
| |
| expect(onCancel).not.toHaveBeenCalled(); |
| }); |
|
|
| it('allows immediate arrow navigation after switching away from text input', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Choice Q?', |
| header: 'Choice', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Option 1', description: '' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Text Q?', |
| header: 'Text', |
| type: QuestionType.TEXT, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[C'); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Text Q?'); |
| }); |
|
|
| |
| writeKey(stdin, 'a'); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('a'); |
| }); |
|
|
| |
| |
| |
| writeKey(stdin, '\x1b[D'); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Text Q?'); |
| }); |
|
|
| writeKey(stdin, '\x1b[D'); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Choice Q?'); |
| }); |
|
|
| |
| writeKey(stdin, '\x1b[C'); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Text Q?'); |
| }); |
| }); |
|
|
| it('handles rapid sequential answers correctly (stale closure protection)', async () => { |
| const multiQuestions: Question[] = [ |
| { |
| question: 'Question 1?', |
| header: 'Q1', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'A1', description: '' }], |
| multiSelect: false, |
| }, |
| { |
| question: 'Question 2?', |
| header: 'Q2', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'A2', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={multiQuestions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| act(() => { |
| stdin.write('\r'); |
| }); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Question 2?'); |
| }); |
|
|
| act(() => { |
| stdin.write('\r'); |
| }); |
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Review your answers:'); |
| }); |
|
|
| act(() => { |
| stdin.write('\r'); |
| }); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ |
| '0': 'A1', |
| '1': 'A2', |
| }); |
| }); |
| }); |
| }); |
|
|
| describe('Markdown rendering', () => { |
| it('auto-bolds plain single-line questions', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Which option do you prefer?', |
| header: 'Test', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| availableHeight={40} |
| />, |
| { width: 120 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| expect(frame).toContain('Which option do you prefer?'); |
| }); |
| }); |
|
|
| it('does not auto-bold questions that already have markdown', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Is **this** working?', |
| header: 'Test', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| availableHeight={40} |
| />, |
| { width: 120 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| |
| expect(frame).toContain('Is '); |
| expect(frame).toContain('this'); |
| expect(frame).not.toContain('**this**'); |
| }); |
| }); |
|
|
| it('renders bold markdown in question', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Is **this** working?', |
| header: 'Test', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| availableHeight={40} |
| />, |
| { width: 120 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| expect(frame).toContain('this'); |
| expect(frame).not.toContain('**this**'); |
| }); |
| }); |
|
|
| it('renders inline code markdown in question', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Run `npm start`?', |
| header: 'Test', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Yes', description: '' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={120} |
| availableHeight={40} |
| />, |
| { width: 120 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| expect(frame).toContain('Run npm start?'); |
| expect(frame).not.toContain('`'); |
| }); |
| }); |
| }); |
|
|
| it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Choose an option', |
| header: 'Context Test', |
| type: QuestionType.CHOICE, |
| options: Array.from({ length: 10 }, (_, i) => ({ |
| label: `Option ${i + 1}`, |
| description: `Description ${i + 1}`, |
| })), |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const mockUIState = { |
| availableTerminalHeight: 5, |
| } as UIState; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <UIStateContext.Provider value={mockUIState}> |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| /> |
| </UIStateContext.Provider>, |
| { |
| config: makeFakeConfig({ useAlternateBuffer: false }), |
| settings: createMockSettings({ ui: { useAlternateBuffer: false } }), |
| }, |
| ); |
|
|
| |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('▲'); |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('▼'); |
| }); |
|
|
| it('does NOT truncate the question when in alternate buffer mode even with small height', async () => { |
| const longQuestion = |
| 'This is a very long question ' + 'with many words '.repeat(10); |
| const questions: Question[] = [ |
| { |
| question: longQuestion, |
| header: 'Alternate Buffer Test', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'Option 1', description: 'Desc 1' }], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const mockUIState = { |
| availableTerminalHeight: 5, |
| } as UIState; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <UIStateContext.Provider value={mockUIState}> |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={40} // Small width to force wrapping |
| /> |
| </UIStateContext.Provider>, |
| { |
| config: makeFakeConfig({ useAlternateBuffer: true }), |
| settings: createMockSettings({ ui: { useAlternateBuffer: true } }), |
| }, |
| ); |
|
|
| |
| await waitUntilReady(); |
| expect(lastFrame()).not.toContain('hidden ...'); |
| |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('This is a very long question'); |
| }); |
|
|
| describe('Choice question placeholder', () => { |
| it('uses placeholder for "Other" option when provided', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Select your preferred language:', |
| header: 'Language', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'TypeScript', description: '' }, |
| { label: 'JavaScript', description: '' }, |
| ], |
| placeholder: 'Type another language...', |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| />, |
| { width: 80 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
|
|
| it('uses default placeholder when not provided', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Select your preferred language:', |
| header: 'Language', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'TypeScript', description: '' }, |
| { label: 'JavaScript', description: '' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| />, |
| { width: 80 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
|
|
| it('supports "Other" option for yesno questions', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Is this correct?', |
| header: 'Confirm', |
| type: QuestionType.YESNO, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={80} |
| />, |
| { width: 80 }, |
| ); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\x1b[B'); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Enter a custom value'); |
| }); |
|
|
| |
| for (const char of 'Yes, but with caveats') { |
| writeKey(stdin, char); |
| } |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toContain('Yes, but with caveats'); |
| }); |
|
|
| |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(async () => { |
| expect(onSubmit).toHaveBeenCalledWith({ '0': 'Yes, but with caveats' }); |
| }); |
| }); |
| }); |
|
|
| it('expands paste placeholders in multi-select custom option via Done', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Which features?', |
| header: 'Features', |
| type: QuestionType.CHOICE, |
| options: [{ label: 'TypeScript', description: '' }], |
| multiSelect: true, |
| }, |
| ]; |
|
|
| const onSubmit = vi.fn(); |
| const { stdin } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={onSubmit} |
| onCancel={vi.fn()} |
| width={120} |
| />, |
| { width: 120 }, |
| ); |
|
|
| |
| writeKey(stdin, '\r'); |
| |
| writeKey(stdin, '\x1b[B'); |
|
|
| |
| const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; |
| const ESC = '\x1b'; |
| writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); |
|
|
| |
| writeKey(stdin, '\x1b[B'); |
| writeKey(stdin, '\r'); |
|
|
| await waitFor(() => { |
| expect(onSubmit).toHaveBeenCalledWith({ |
| '0': `TypeScript, ${pastedText}`, |
| }); |
| }); |
| }); |
|
|
| it('shows at least 3 selection options even in small terminal heights', async () => { |
| const questions: Question[] = [ |
| { |
| question: |
| 'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.', |
| header: 'Test', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'Option 1', description: 'Description 1' }, |
| { label: 'Option 2', description: 'Description 2' }, |
| { label: 'Option 3', description: 'Description 3' }, |
| { label: 'Option 4', description: 'Description 4' }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| availableHeight={12} // Very small height |
| />, |
| { width: 80 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| expect(frame).toContain('1. Option 1'); |
| expect(frame).toContain('2. Option 2'); |
| expect(frame).toContain('3. Option 3'); |
| }); |
| }); |
|
|
| it('allows the question to exceed 15 lines in a tall terminal', async () => { |
| const longQuestion = Array.from( |
| { length: 25 }, |
| (_, i) => `Line ${i + 1}`, |
| ).join('\n'); |
| const questions: Question[] = [ |
| { |
| question: longQuestion, |
| header: 'Tall Test', |
| type: QuestionType.CHOICE, |
| options: [ |
| { label: 'Option 1', description: 'D1' }, |
| { label: 'Option 2', description: 'D2' }, |
| { label: 'Option 3', description: 'D3' }, |
| ], |
| multiSelect: false, |
| unconstrainedHeight: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={80} |
| availableHeight={40} // Tall terminal |
| />, |
| { width: 80 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| const frame = lastFrame(); |
| |
| |
| expect(frame).toContain('Line 20'); |
| expect(frame).toContain('Line 25'); |
| |
| expect(frame).toContain('1. Option 1'); |
| }); |
| }); |
|
|
| it('indents multi-line descriptions correctly', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Single choice?', |
| header: 'Indent Test', |
| type: QuestionType.CHOICE, |
| options: [ |
| { |
| label: 'Option 1', |
| description: |
| 'This is a very long description that is expected to wrap onto multiple lines in a narrow terminal. We want to ensure that all lines are correctly indented.', |
| }, |
| ], |
| multiSelect: false, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={40} // Narrow width to force wrapping |
| />, |
| { width: 40 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
|
|
| it('indents multi-line descriptions correctly in multi-select mode', async () => { |
| const questions: Question[] = [ |
| { |
| question: 'Multi-select?', |
| header: 'Indent Test', |
| type: QuestionType.CHOICE, |
| options: [ |
| { |
| label: 'Option 1', |
| description: |
| 'This is a very long description that is expected to wrap onto multiple lines in a narrow terminal. We want to ensure that all lines are correctly indented even with checkboxes.', |
| }, |
| ], |
| multiSelect: true, |
| }, |
| ]; |
|
|
| const { lastFrame, waitUntilReady } = await renderWithProviders( |
| <AskUserDialog |
| questions={questions} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| width={40} // Narrow width to force wrapping |
| />, |
| { width: 40 }, |
| ); |
|
|
| await waitFor(async () => { |
| await waitUntilReady(); |
| expect(lastFrame()).toMatchSnapshot(); |
| }); |
| }); |
| }); |
|
|