mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 23:51:16 -07:00
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { render as inkRender } from 'ink-testing-library';
|
|
import { Box } from 'ink';
|
|
import type React from 'react';
|
|
import { vi } from 'vitest';
|
|
import { act, useState } from 'react';
|
|
import os from 'node:os';
|
|
import { LoadedSettings } from '../config/settings.js';
|
|
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
|
|
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
|
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
|
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
|
|
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
|
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
|
import { MouseProvider } from '../ui/contexts/MouseContext.js';
|
|
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
|
|
import { StreamingContext } from '../ui/contexts/StreamingContext.js';
|
|
import {
|
|
type UIActions,
|
|
UIActionsContext,
|
|
} from '../ui/contexts/UIActionsContext.js';
|
|
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
|
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
|
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
|
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
|
|
|
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
|
import { FakePersistentState } from './persistentStateFake.js';
|
|
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
|
|
import { createMockSettings } from './settings.js';
|
|
|
|
export const persistentStateMock = new FakePersistentState();
|
|
|
|
vi.mock('../utils/persistentState.js', () => ({
|
|
persistentState: persistentStateMock,
|
|
}));
|
|
|
|
vi.mock('../ui/utils/terminalUtils.js', () => ({
|
|
isLowColorDepth: vi.fn(() => false),
|
|
getColorDepth: vi.fn(() => 24),
|
|
isITerm2: vi.fn(() => false),
|
|
}));
|
|
|
|
// Wrapper around ink-testing-library's render that ensures act() is called
|
|
export const render = (
|
|
tree: React.ReactElement,
|
|
terminalWidth?: number,
|
|
): ReturnType<typeof inkRender> => {
|
|
let renderResult: ReturnType<typeof inkRender> =
|
|
undefined as unknown as ReturnType<typeof inkRender>;
|
|
act(() => {
|
|
renderResult = inkRender(tree);
|
|
});
|
|
|
|
if (terminalWidth !== undefined && renderResult?.stdout) {
|
|
// Override the columns getter on the stdout instance provided by ink-testing-library
|
|
Object.defineProperty(renderResult.stdout, 'columns', {
|
|
get: () => terminalWidth,
|
|
configurable: true,
|
|
});
|
|
|
|
// Trigger a rerender so Ink can pick up the new terminal width
|
|
act(() => {
|
|
renderResult.rerender(tree);
|
|
});
|
|
}
|
|
|
|
const originalUnmount = renderResult.unmount;
|
|
const originalRerender = renderResult.rerender;
|
|
|
|
return {
|
|
...renderResult,
|
|
unmount: () => {
|
|
act(() => {
|
|
originalUnmount();
|
|
});
|
|
},
|
|
rerender: (newTree: React.ReactElement) => {
|
|
act(() => {
|
|
originalRerender(newTree);
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
export const simulateClick = async (
|
|
stdin: ReturnType<typeof inkRender>['stdin'],
|
|
col: number,
|
|
row: number,
|
|
button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right
|
|
) => {
|
|
// Terminal mouse events are 1-based, so convert if necessary.
|
|
const mouseEventString = `\x1b[<${button};${col};${row}M`;
|
|
await act(async () => {
|
|
stdin.write(mouseEventString);
|
|
});
|
|
};
|
|
|
|
let mockConfigInternal: Config | undefined;
|
|
|
|
const getMockConfigInternal = (): Config => {
|
|
if (!mockConfigInternal) {
|
|
mockConfigInternal = makeFakeConfig({
|
|
targetDir: os.tmpdir(),
|
|
enableEventDrivenScheduler: true,
|
|
});
|
|
}
|
|
return mockConfigInternal;
|
|
};
|
|
|
|
const configProxy = new Proxy({} as Config, {
|
|
get(_target, prop) {
|
|
if (prop === 'getTargetDir') {
|
|
return () =>
|
|
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
|
|
}
|
|
const internal = getMockConfigInternal();
|
|
if (prop in internal) {
|
|
return internal[prop as keyof typeof internal];
|
|
}
|
|
throw new Error(`mockConfig does not have property ${String(prop)}`);
|
|
},
|
|
});
|
|
|
|
export const mockSettings = new LoadedSettings(
|
|
{ path: '', settings: {}, originalSettings: {} },
|
|
{ path: '', settings: {}, originalSettings: {} },
|
|
{ path: '', settings: {}, originalSettings: {} },
|
|
{ path: '', settings: {}, originalSettings: {} },
|
|
true,
|
|
[],
|
|
);
|
|
|
|
// A minimal mock UIState to satisfy the context provider.
|
|
// Tests that need specific UIState values should provide their own.
|
|
const baseMockUiState = {
|
|
renderMarkdown: true,
|
|
streamingState: StreamingState.Idle,
|
|
terminalWidth: 120,
|
|
terminalHeight: 40,
|
|
currentModel: 'gemini-pro',
|
|
terminalBackgroundColor: undefined,
|
|
activePtyId: undefined,
|
|
backgroundShells: new Map(),
|
|
backgroundShellHeight: 0,
|
|
};
|
|
|
|
export const mockAppState: AppState = {
|
|
version: '1.2.3',
|
|
startupWarnings: [],
|
|
};
|
|
|
|
const mockUIActions: UIActions = {
|
|
handleThemeSelect: vi.fn(),
|
|
closeThemeDialog: vi.fn(),
|
|
handleThemeHighlight: vi.fn(),
|
|
handleAuthSelect: vi.fn(),
|
|
setAuthState: vi.fn(),
|
|
onAuthError: vi.fn(),
|
|
handleEditorSelect: vi.fn(),
|
|
exitEditorDialog: vi.fn(),
|
|
exitPrivacyNotice: vi.fn(),
|
|
closeSettingsDialog: vi.fn(),
|
|
closeModelDialog: vi.fn(),
|
|
openAgentConfigDialog: vi.fn(),
|
|
closeAgentConfigDialog: vi.fn(),
|
|
openPermissionsDialog: vi.fn(),
|
|
openSessionBrowser: vi.fn(),
|
|
closeSessionBrowser: vi.fn(),
|
|
handleResumeSession: vi.fn(),
|
|
handleDeleteSession: vi.fn(),
|
|
closePermissionsDialog: vi.fn(),
|
|
setShellModeActive: vi.fn(),
|
|
vimHandleInput: vi.fn(),
|
|
handleIdePromptComplete: vi.fn(),
|
|
handleFolderTrustSelect: vi.fn(),
|
|
setConstrainHeight: vi.fn(),
|
|
onEscapePromptChange: vi.fn(),
|
|
refreshStatic: vi.fn(),
|
|
handleFinalSubmit: vi.fn(),
|
|
handleClearScreen: vi.fn(),
|
|
handleProQuotaChoice: vi.fn(),
|
|
handleValidationChoice: vi.fn(),
|
|
setQueueErrorMessage: vi.fn(),
|
|
popAllMessages: vi.fn(),
|
|
handleApiKeySubmit: vi.fn(),
|
|
handleApiKeyCancel: vi.fn(),
|
|
setBannerVisible: vi.fn(),
|
|
setEmbeddedShellFocused: vi.fn(),
|
|
dismissBackgroundShell: vi.fn(),
|
|
setActiveBackgroundShellPid: vi.fn(),
|
|
setIsBackgroundShellListOpen: vi.fn(),
|
|
setAuthContext: vi.fn(),
|
|
handleWarning: vi.fn(),
|
|
handleRestart: vi.fn(),
|
|
handleNewAgentsSelect: vi.fn(),
|
|
};
|
|
|
|
export const renderWithProviders = (
|
|
component: React.ReactElement,
|
|
{
|
|
shellFocus = true,
|
|
settings = mockSettings,
|
|
uiState: providedUiState,
|
|
width,
|
|
mouseEventsEnabled = false,
|
|
config = configProxy as unknown as Config,
|
|
useAlternateBuffer = true,
|
|
uiActions,
|
|
persistentState,
|
|
appState = mockAppState,
|
|
}: {
|
|
shellFocus?: boolean;
|
|
settings?: LoadedSettings;
|
|
uiState?: Partial<UIState>;
|
|
width?: number;
|
|
mouseEventsEnabled?: boolean;
|
|
config?: Config;
|
|
useAlternateBuffer?: boolean;
|
|
uiActions?: Partial<UIActions>;
|
|
persistentState?: {
|
|
get?: typeof persistentStateMock.get;
|
|
set?: typeof persistentStateMock.set;
|
|
};
|
|
appState?: AppState;
|
|
} = {},
|
|
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
|
|
const baseState: UIState = new Proxy(
|
|
{ ...baseMockUiState, ...providedUiState },
|
|
{
|
|
get(target, prop) {
|
|
if (prop in target) {
|
|
return target[prop as keyof typeof target];
|
|
}
|
|
// For properties not in the base mock or provided state,
|
|
// we'll check the original proxy to see if it's a defined but
|
|
// unprovided property, and if not, throw.
|
|
if (prop in baseMockUiState) {
|
|
return baseMockUiState[prop as keyof typeof baseMockUiState];
|
|
}
|
|
throw new Error(`mockUiState does not have property ${String(prop)}`);
|
|
},
|
|
},
|
|
) as UIState;
|
|
|
|
if (persistentState?.get) {
|
|
persistentStateMock.get.mockImplementation(persistentState.get);
|
|
}
|
|
if (persistentState?.set) {
|
|
persistentStateMock.set.mockImplementation(persistentState.set);
|
|
}
|
|
|
|
persistentStateMock.mockClear();
|
|
|
|
const terminalWidth = width ?? baseState.terminalWidth;
|
|
let finalSettings = settings;
|
|
if (useAlternateBuffer !== undefined) {
|
|
finalSettings = createMockSettings({
|
|
...settings.merged,
|
|
ui: {
|
|
...settings.merged.ui,
|
|
useAlternateBuffer,
|
|
},
|
|
});
|
|
}
|
|
|
|
const mainAreaWidth = terminalWidth;
|
|
|
|
const finalUiState = {
|
|
...baseState,
|
|
terminalWidth,
|
|
mainAreaWidth,
|
|
};
|
|
|
|
const finalUIActions = { ...mockUIActions, ...uiActions };
|
|
|
|
const allToolCalls = (finalUiState.pendingHistoryItems || [])
|
|
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
|
|
.flatMap((item) => item.tools);
|
|
|
|
const renderResult = render(
|
|
<AppContext.Provider value={appState}>
|
|
<ConfigContext.Provider value={config}>
|
|
<SettingsContext.Provider value={finalSettings}>
|
|
<UIStateContext.Provider value={finalUiState}>
|
|
<VimModeProvider settings={finalSettings}>
|
|
<ShellFocusContext.Provider value={shellFocus}>
|
|
<StreamingContext.Provider value={finalUiState.streamingState}>
|
|
<UIActionsContext.Provider value={finalUIActions}>
|
|
<ToolActionsProvider
|
|
config={config}
|
|
toolCalls={allToolCalls}
|
|
>
|
|
<AskUserActionsProvider
|
|
request={null}
|
|
onSubmit={vi.fn()}
|
|
onCancel={vi.fn()}
|
|
>
|
|
<KeypressProvider>
|
|
<MouseProvider
|
|
mouseEventsEnabled={mouseEventsEnabled}
|
|
>
|
|
<TerminalProvider>
|
|
<ScrollProvider>
|
|
<Box
|
|
width={terminalWidth}
|
|
flexShrink={0}
|
|
flexGrow={0}
|
|
flexDirection="column"
|
|
>
|
|
{component}
|
|
</Box>
|
|
</ScrollProvider>
|
|
</TerminalProvider>
|
|
</MouseProvider>
|
|
</KeypressProvider>
|
|
</AskUserActionsProvider>
|
|
</ToolActionsProvider>
|
|
</UIActionsContext.Provider>
|
|
</StreamingContext.Provider>
|
|
</ShellFocusContext.Provider>
|
|
</VimModeProvider>
|
|
</UIStateContext.Provider>
|
|
</SettingsContext.Provider>
|
|
</ConfigContext.Provider>
|
|
</AppContext.Provider>,
|
|
terminalWidth,
|
|
);
|
|
|
|
return { ...renderResult, simulateClick };
|
|
};
|
|
|
|
export function renderHook<Result, Props>(
|
|
renderCallback: (props: Props) => Result,
|
|
options?: {
|
|
initialProps?: Props;
|
|
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
|
},
|
|
): {
|
|
result: { current: Result };
|
|
rerender: (props?: Props) => void;
|
|
unmount: () => void;
|
|
} {
|
|
const result = { current: undefined as unknown as Result };
|
|
let currentProps = options?.initialProps as Props;
|
|
|
|
function TestComponent({
|
|
renderCallback,
|
|
props,
|
|
}: {
|
|
renderCallback: (props: Props) => Result;
|
|
props: Props;
|
|
}) {
|
|
result.current = renderCallback(props);
|
|
return null;
|
|
}
|
|
|
|
const Wrapper = options?.wrapper || (({ children }) => <>{children}</>);
|
|
|
|
let inkRerender: (tree: React.ReactElement) => void = () => {};
|
|
let unmount: () => void = () => {};
|
|
|
|
act(() => {
|
|
const renderResult = render(
|
|
<Wrapper>
|
|
<TestComponent renderCallback={renderCallback} props={currentProps} />
|
|
</Wrapper>,
|
|
);
|
|
inkRerender = renderResult.rerender;
|
|
unmount = renderResult.unmount;
|
|
});
|
|
|
|
function rerender(props?: Props) {
|
|
if (arguments.length > 0) {
|
|
currentProps = props as Props;
|
|
}
|
|
act(() => {
|
|
inkRerender(
|
|
<Wrapper>
|
|
<TestComponent renderCallback={renderCallback} props={currentProps} />
|
|
</Wrapper>,
|
|
);
|
|
});
|
|
}
|
|
|
|
return { result, rerender, unmount };
|
|
}
|
|
|
|
export function renderHookWithProviders<Result, Props>(
|
|
renderCallback: (props: Props) => Result,
|
|
options: {
|
|
initialProps?: Props;
|
|
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
|
// Options for renderWithProviders
|
|
shellFocus?: boolean;
|
|
settings?: LoadedSettings;
|
|
uiState?: Partial<UIState>;
|
|
width?: number;
|
|
mouseEventsEnabled?: boolean;
|
|
config?: Config;
|
|
useAlternateBuffer?: boolean;
|
|
} = {},
|
|
): {
|
|
result: { current: Result };
|
|
rerender: (props?: Props) => void;
|
|
unmount: () => void;
|
|
} {
|
|
const result = { current: undefined as unknown as Result };
|
|
|
|
let setPropsFn: ((props: Props) => void) | undefined;
|
|
let forceUpdateFn: (() => void) | undefined;
|
|
|
|
function TestComponent({ initialProps }: { initialProps: Props }) {
|
|
const [props, setProps] = useState(initialProps);
|
|
const [, forceUpdate] = useState(0);
|
|
setPropsFn = setProps;
|
|
forceUpdateFn = () => forceUpdate((n) => n + 1);
|
|
result.current = renderCallback(props);
|
|
return null;
|
|
}
|
|
|
|
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
|
|
|
|
let renderResult: ReturnType<typeof render>;
|
|
|
|
act(() => {
|
|
renderResult = renderWithProviders(
|
|
<Wrapper>
|
|
<TestComponent initialProps={options.initialProps as Props} />
|
|
</Wrapper>,
|
|
options,
|
|
);
|
|
});
|
|
|
|
function rerender(newProps?: Props) {
|
|
act(() => {
|
|
if (arguments.length > 0 && setPropsFn) {
|
|
setPropsFn(newProps as Props);
|
|
} else if (forceUpdateFn) {
|
|
forceUpdateFn();
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
result,
|
|
rerender,
|
|
unmount: () => {
|
|
act(() => {
|
|
renderResult.unmount();
|
|
});
|
|
},
|
|
};
|
|
}
|