/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { render } from 'ink-testing-library'; import { Text } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { vi } from 'vitest'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; // Mock GeminiRespondingSpinner vi.mock('./GeminiRespondingSpinner.js', () => ({ GeminiRespondingSpinner: ({ nonRespondingDisplay, }: { nonRespondingDisplay?: string; }) => { const streamingState = React.useContext(StreamingContext)!; if (streamingState === StreamingState.Responding) { return MockRespondingSpinner; } else if (nonRespondingDisplay) { return {nonRespondingDisplay}; } return null; }, })); vi.mock('../hooks/useTerminalSize.js', () => ({ useTerminalSize: vi.fn(), })); const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); const renderWithContext = ( ui: React.ReactElement, streamingStateValue: StreamingState, width = 120, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); const contextValue: StreamingState = streamingStateValue; return render( {ui} , ); }; describe('', () => { const defaultProps = { currentLoadingPhrase: 'Loading...', elapsedTime: 5, }; it('should not render when streamingState is Idle', () => { const { lastFrame } = renderWithContext( , StreamingState.Idle, ); expect(lastFrame()).toBe(''); }); it('should render spinner, phrase, and time when streamingState is Responding', () => { const { lastFrame } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Loading...'); expect(output).toContain('(esc to cancel, 5s)'); }); it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => { const props = { currentLoadingPhrase: 'Confirm action', elapsedTime: 10, }; const { lastFrame } = renderWithContext( , StreamingState.WaitingForConfirmation, ); const output = lastFrame(); expect(output).toContain('⠏'); // Static char for WaitingForConfirmation expect(output).toContain('Confirm action'); expect(output).not.toContain('(esc to cancel)'); expect(output).not.toContain(', 10s'); }); it('should display the currentLoadingPhrase correctly', () => { const props = { currentLoadingPhrase: 'Processing data...', elapsedTime: 3, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('Processing data...'); }); it('should display the elapsedTime correctly when Responding', () => { const props = { currentLoadingPhrase: 'Working...', elapsedTime: 60, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('(esc to cancel, 1m)'); }); it('should display the elapsedTime correctly in human-readable format', () => { const props = { currentLoadingPhrase: 'Working...', elapsedTime: 125, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('(esc to cancel, 2m 5s)'); }); it('should render rightContent when provided', () => { const rightContent = Extra Info; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('Extra Info'); }); it('should transition correctly between states using rerender', () => { const { lastFrame, rerender } = renderWithContext( , StreamingState.Idle, ); expect(lastFrame()).toBe(''); // Initial: Idle // Transition to Responding rerender( , ); let output = lastFrame(); expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Now Responding'); expect(output).toContain('(esc to cancel, 2s)'); // Transition to WaitingForConfirmation rerender( , ); output = lastFrame(); expect(output).toContain('⠏'); expect(output).toContain('Please Confirm'); expect(output).not.toContain('(esc to cancel)'); expect(output).not.toContain(', 15s'); // Transition back to Idle rerender( , ); expect(lastFrame()).toBe(''); }); it('should display fallback phrase if thought is empty', () => { const props = { thought: null, currentLoadingPhrase: 'Loading...', elapsedTime: 5, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toContain('Loading...'); }); it('should display the subject of a thought', () => { const props = { thought: { subject: 'Thinking about something...', description: 'and other stuff.', }, elapsedTime: 5, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toBeDefined(); if (output) { expect(output).toContain('Thinking about something...'); expect(output).not.toContain('and other stuff.'); } }); it('should prioritize thought.subject over currentLoadingPhrase', () => { const props = { thought: { subject: 'This should be displayed', description: 'A description', }, currentLoadingPhrase: 'This should not be displayed', elapsedTime: 5, }; const { lastFrame } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toContain('This should be displayed'); expect(output).not.toContain('This should not be displayed'); }); describe('responsive layout', () => { it('should render on a single line on a wide terminal', () => { const { lastFrame } = renderWithContext( Right} />, StreamingState.Responding, 120, ); const output = lastFrame(); // Check for single line output expect(output?.includes('\n')).toBe(false); expect(output).toContain('Loading...'); expect(output).toContain('(esc to cancel, 5s)'); expect(output).toContain('Right'); }); it('should render on multiple lines on a narrow terminal', () => { const { lastFrame } = renderWithContext( Right} />, StreamingState.Responding, 79, ); const output = lastFrame(); const lines = output?.split('\n'); // Expecting 3 lines: // 1. Spinner + Primary Text // 2. Cancel + Timer // 3. Right Content expect(lines).toHaveLength(3); if (lines) { expect(lines[0]).toContain('Loading...'); expect(lines[0]).not.toContain('(esc to cancel, 5s)'); expect(lines[1]).toContain('(esc to cancel, 5s)'); expect(lines[2]).toContain('Right'); } }); it('should use wide layout at 80 columns', () => { const { lastFrame } = renderWithContext( , StreamingState.Responding, 80, ); expect(lastFrame()?.includes('\n')).toBe(false); }); it('should use narrow layout at 79 columns', () => { const { lastFrame } = renderWithContext( , StreamingState.Responding, 79, ); expect(lastFrame()?.includes('\n')).toBe(true); }); }); });