mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
Migrate core render util to use xterm.js as part of the rendering loop. (#19044)
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import React, { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
@@ -42,13 +42,10 @@ const renderWithContext = (
|
||||
width = 120,
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
const contextValue: StreamingState = streamingStateValue;
|
||||
return render(
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>,
|
||||
return renderWithProviders(ui, {
|
||||
uiState: { streamingState: streamingStateValue },
|
||||
width,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
@@ -57,34 +54,37 @@ describe('<LoadingIndicator />', () => {
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()?.trim()).toBe('');
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
|
||||
const { lastFrame, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
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', () => {
|
||||
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
const { lastFrame, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
|
||||
expect(output).toContain('Confirm action');
|
||||
@@ -92,85 +92,121 @@ describe('<LoadingIndicator />', () => {
|
||||
expect(output).not.toContain(', 10s');
|
||||
});
|
||||
|
||||
it('should display the currentLoadingPhrase correctly', () => {
|
||||
it('should display the currentLoadingPhrase correctly', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Processing data...',
|
||||
elapsedTime: 3,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Processing data...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly when Responding', () => {
|
||||
it('should display the elapsedTime correctly when Responding', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 60,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', () => {
|
||||
it('should display the elapsedTime correctly in human-readable format', async () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 125,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render rightContent when provided', () => {
|
||||
it('should render rightContent when provided', async () => {
|
||||
const rightContent = <Text>Extra Info</Text>;
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Extra Info');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should transition correctly between states using rerender', () => {
|
||||
const { lastFrame, rerender, unmount } = renderWithContext(
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
it('should transition correctly between states', async () => {
|
||||
let setStateExternally:
|
||||
| React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
state: StreamingState;
|
||||
phrase?: string;
|
||||
elapsedTime: number;
|
||||
}>
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const TestWrapper = () => {
|
||||
const [config, setConfig] = React.useState<{
|
||||
state: StreamingState;
|
||||
phrase?: string;
|
||||
elapsedTime: number;
|
||||
}>({
|
||||
state: StreamingState.Idle,
|
||||
phrase: undefined,
|
||||
elapsedTime: 5,
|
||||
});
|
||||
setStateExternally = setConfig;
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={config.state}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase={config.phrase}
|
||||
elapsedTime={config.elapsedTime}
|
||||
/>
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
|
||||
<TestWrapper />,
|
||||
);
|
||||
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Responding}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="Now Responding"
|
||||
elapsedTime={2}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.Responding,
|
||||
phrase: 'Now Responding',
|
||||
elapsedTime: 2,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Now Responding');
|
||||
expect(output).toContain('(esc to cancel, 2s)');
|
||||
|
||||
// Transition to WaitingForConfirmation
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.WaitingForConfirmation}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="Please Confirm"
|
||||
elapsedTime={15}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.WaitingForConfirmation,
|
||||
phrase: 'Please Confirm',
|
||||
elapsedTime: 15,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
output = lastFrame();
|
||||
expect(output).toContain('⠏');
|
||||
expect(output).toContain('Please Confirm');
|
||||
@@ -178,31 +214,35 @@ describe('<LoadingIndicator />', () => {
|
||||
expect(output).not.toContain(', 15s');
|
||||
|
||||
// Transition back to Idle
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<LoadingIndicator elapsedTime={5} />
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
|
||||
await act(async () => {
|
||||
setStateExternally?.({
|
||||
state: StreamingState.Idle,
|
||||
phrase: undefined,
|
||||
elapsedTime: 5,
|
||||
});
|
||||
});
|
||||
await waitUntilReady();
|
||||
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Idle with no loading phrase and no spinner
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display fallback phrase if thought is empty', () => {
|
||||
it('should display fallback phrase if thought is empty', async () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Loading...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should display the subject of a thought', () => {
|
||||
it('should display the subject of a thought', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Thinking about something...',
|
||||
@@ -210,10 +250,11 @@ describe('<LoadingIndicator />', () => {
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
@@ -224,7 +265,7 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prioritize thought.subject over currentLoadingPhrase', () => {
|
||||
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'This should be displayed',
|
||||
@@ -233,10 +274,11 @@ describe('<LoadingIndicator />', () => {
|
||||
currentLoadingPhrase: 'This should not be displayed',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('💬');
|
||||
expect(output).toContain('This should be displayed');
|
||||
@@ -244,20 +286,21 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not display thought icon for non-thought loading phrases', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should not display thought icon for non-thought loading phrases', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="some random tip..."
|
||||
elapsedTime={3}
|
||||
/>,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).not.toContain('💬');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should truncate long primary text instead of wrapping', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should truncate long primary text instead of wrapping', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
currentLoadingPhrase={
|
||||
@@ -267,14 +310,15 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('responsive layout', () => {
|
||||
it('should render on a single line on a wide terminal', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should render on a single line on a wide terminal', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
@@ -282,17 +326,18 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
120,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
// Check for single line output
|
||||
expect(output?.includes('\n')).toBe(false);
|
||||
expect(output?.trim().includes('\n')).toBe(false);
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
expect(output).toContain('Right');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render on multiple lines on a narrow terminal', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should render on multiple lines on a narrow terminal', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator
|
||||
{...defaultProps}
|
||||
rightContent={<Text>Right</Text>}
|
||||
@@ -300,8 +345,9 @@ describe('<LoadingIndicator />', () => {
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const output = lastFrame();
|
||||
const lines = output?.split('\n');
|
||||
const lines = output?.trim().split('\n');
|
||||
// Expecting 3 lines:
|
||||
// 1. Spinner + Primary Text
|
||||
// 2. Cancel + Timer
|
||||
@@ -316,22 +362,24 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should use wide layout at 80 columns', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
80,
|
||||
);
|
||||
expect(lastFrame()?.includes('\n')).toBe(false);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.trim().includes('\n')).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use narrow layout at 79 columns', () => {
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
it('should use narrow layout at 79 columns', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
79,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()?.includes('\n')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user