| |
| |
| |
| |
| |
|
|
| import { |
| render as inkRenderDirect, |
| type Instance as InkInstance, |
| type RenderOptions, |
| } from 'ink'; |
| import { EventEmitter } from 'node:events'; |
| import { Box } from 'ink'; |
| import { Terminal } from '@xterm/headless'; |
| import { vi } from 'vitest'; |
| import stripAnsi from 'strip-ansi'; |
| import type React from 'react'; |
| import { act, useState } from 'react'; |
| import type { 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 { |
| OverflowProvider, |
| useOverflowActions, |
| useOverflowState, |
| type OverflowActions, |
| type OverflowState, |
| } from '../ui/contexts/OverflowContext.js'; |
|
|
| import { makeFakeConfig } from '@google/gemini-cli-core'; |
| import { 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'; |
| import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; |
| import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; |
| import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; |
| import { pickDefaultThemeName } from '../ui/themes/theme.js'; |
| import { generateSvgForTerminal } from './svg.js'; |
|
|
| export const persistentStateMock = new FakePersistentState(); |
|
|
| if (process.env['NODE_ENV'] === 'test') { |
| |
| |
| |
| |
| |
| process.env['NODE_ENV'] = 'development'; |
| } |
|
|
| vi.mock('../utils/persistentState.js', () => ({ |
| get persistentState() { |
| return persistentStateMock; |
| }, |
| })); |
|
|
| vi.mock('../ui/utils/terminalUtils.js', () => ({ |
| isLowColorDepth: vi.fn(() => false), |
| getColorDepth: vi.fn(() => 24), |
| isITerm2: vi.fn(() => false), |
| })); |
|
|
| type TerminalState = { |
| terminal: Terminal; |
| cols: number; |
| rows: number; |
| }; |
|
|
| type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0]; |
|
|
| interface InkRenderMetrics extends RenderMetrics { |
| output: string; |
| staticOutput?: string; |
| } |
|
|
| function isInkRenderMetrics( |
| metrics: RenderMetrics, |
| ): metrics is InkRenderMetrics { |
| const m = metrics as Record<string, unknown>; |
| return ( |
| typeof m === 'object' && |
| m !== null && |
| 'output' in m && |
| |
| typeof m['output'] === 'string' |
| ); |
| } |
|
|
| class XtermStdout extends EventEmitter { |
| private state: TerminalState; |
| private pendingWrites = 0; |
| private renderCount = 0; |
| private queue: { promise: Promise<void> }; |
| isTTY = true; |
|
|
| getColorDepth(): number { |
| return 24; |
| } |
|
|
| private lastRenderOutput: string | undefined = undefined; |
| private lastRenderStaticContent: string | undefined = undefined; |
|
|
| constructor(state: TerminalState, queue: { promise: Promise<void> }) { |
| super(); |
| this.state = state; |
| this.queue = queue; |
| } |
|
|
| get columns() { |
| return this.state.terminal.cols; |
| } |
|
|
| get rows() { |
| return this.state.terminal.rows; |
| } |
|
|
| get frames(): string[] { |
| return []; |
| } |
|
|
| write = (data: string) => { |
| this.pendingWrites++; |
| this.queue.promise = this.queue.promise.then(async () => { |
| await new Promise<void>((resolve) => |
| this.state.terminal.write(data, resolve), |
| ); |
| this.pendingWrites--; |
| }); |
| }; |
|
|
| clear = () => { |
| this.state.terminal.reset(); |
| this.lastRenderOutput = undefined; |
| this.lastRenderStaticContent = undefined; |
| }; |
|
|
| dispose = () => { |
| this.state.terminal.dispose(); |
| }; |
|
|
| onRender = (staticContent: string, output: string) => { |
| this.renderCount++; |
| this.lastRenderStaticContent = staticContent; |
| this.lastRenderOutput = output; |
| this.emit('render'); |
| }; |
|
|
| private normalizeFrame = (text: string): string => |
| text.replace(/\r\n/g, '\n'); |
|
|
| generateSvg = (): string => generateSvgForTerminal(this.state.terminal); |
|
|
| lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => { |
| const result = |
| (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); |
|
|
| const normalized = this.normalizeFrame(result); |
|
|
| if (normalized === '' && !options.allowEmpty) { |
| throw new Error( |
| 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' + |
| 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', |
| ); |
| } |
|
|
| return normalized; |
| }; |
|
|
| lastFrame = (options: { allowEmpty?: boolean } = {}) => { |
| const buffer = this.state.terminal.buffer.active; |
| const allLines: string[] = []; |
| for (let i = 0; i < buffer.length; i++) { |
| allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); |
| } |
|
|
| const trimmed = [...allLines]; |
| while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { |
| trimmed.pop(); |
| } |
| const result = trimmed.join('\n'); |
|
|
| const normalized = this.normalizeFrame(result); |
|
|
| if (normalized === '' && !options.allowEmpty) { |
| throw new Error( |
| 'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' + |
| 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', |
| ); |
| } |
| return normalized === '' ? normalized : normalized + '\n'; |
| }; |
|
|
| async waitUntilReady() { |
| const startRenderCount = this.renderCount; |
| if (!vi.isFakeTimers()) { |
| |
| await new Promise((resolve) => setImmediate(resolve)); |
| } |
| await act(async () => { |
| if (vi.isFakeTimers()) { |
| await vi.advanceTimersByTimeAsync(50); |
| } else { |
| |
| |
| if (this.renderCount === startRenderCount) { |
| const renderPromise = new Promise((resolve) => |
| this.once('render', resolve), |
| ); |
| const timeoutPromise = new Promise((resolve) => |
| setTimeout(resolve, 1000), |
| ); |
| await Promise.race([renderPromise, timeoutPromise]); |
| } |
| } |
| }); |
|
|
| let attempts = 0; |
| const maxAttempts = 50; |
|
|
| let lastCurrent = ''; |
| let lastExpected = ''; |
|
|
| while (attempts < maxAttempts) { |
| |
| await this.queue.promise; |
|
|
| const currentFrame = stripAnsi( |
| this.lastFrame({ allowEmpty: true }), |
| ).trim(); |
| const expectedFrame = this.normalizeFrame( |
| stripAnsi( |
| (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), |
| ), |
| ).trim(); |
|
|
| lastCurrent = currentFrame; |
| lastExpected = expectedFrame; |
|
|
| const isMatch = () => { |
| if (expectedFrame === '...') { |
| |
| |
| |
| |
| |
| return currentFrame !== '' || this.pendingWrites === 0; |
| } |
|
|
| |
| |
| if (expectedFrame === '') { |
| return true; |
| } |
|
|
| if (this.lastRenderOutput === undefined) { |
| return false; |
| } |
|
|
| |
| if (currentFrame === '') { |
| return false; |
| } |
|
|
| |
| |
| |
| return currentFrame.includes(expectedFrame); |
| }; |
|
|
| if (this.pendingWrites === 0 && isMatch()) { |
| return; |
| } |
|
|
| attempts++; |
| await act(async () => { |
| if (vi.isFakeTimers()) { |
| await vi.advanceTimersByTimeAsync(10); |
| } else { |
| await new Promise((resolve) => setTimeout(resolve, 10)); |
| } |
| }); |
| } |
|
|
| throw new Error( |
| `waitUntilReady() timed out after ${maxAttempts} attempts.\n` + |
| `Expected content (stripped ANSI):\n"${lastExpected}"\n` + |
| `Actual content (stripped ANSI):\n"${lastCurrent}"\n` + |
| `Pending writes: ${this.pendingWrites}\n` + |
| `Render count: ${this.renderCount}`, |
| ); |
| } |
| } |
|
|
| class XtermStderr extends EventEmitter { |
| private state: TerminalState; |
| private pendingWrites = 0; |
| private queue: { promise: Promise<void> }; |
| isTTY = true; |
|
|
| constructor(state: TerminalState, queue: { promise: Promise<void> }) { |
| super(); |
| this.state = state; |
| this.queue = queue; |
| } |
|
|
| write = (data: string) => { |
| this.pendingWrites++; |
| this.queue.promise = this.queue.promise.then(async () => { |
| await new Promise<void>((resolve) => |
| this.state.terminal.write(data, resolve), |
| ); |
| this.pendingWrites--; |
| }); |
| }; |
|
|
| dispose = () => { |
| this.state.terminal.dispose(); |
| }; |
|
|
| lastFrame = () => ''; |
| } |
|
|
| class XtermStdin extends EventEmitter { |
| isTTY = true; |
| data: string | null = null; |
| constructor(options: { isTTY?: boolean } = {}) { |
| super(); |
| this.isTTY = options.isTTY ?? true; |
| } |
|
|
| write = (data: string) => { |
| this.data = data; |
| this.emit('readable'); |
| this.emit('data', data); |
| }; |
|
|
| setEncoding() {} |
| setRawMode() {} |
| resume() {} |
| pause() {} |
| ref() {} |
| unref() {} |
|
|
| read = () => { |
| const { data } = this; |
| this.data = null; |
| return data; |
| }; |
| } |
|
|
| export type RenderInstance = { |
| rerender: (tree: React.ReactElement) => void; |
| unmount: () => void; |
| cleanup: () => void; |
| stdout: XtermStdout; |
| stderr: XtermStderr; |
| stdin: XtermStdin; |
| frames: string[]; |
| lastFrame: (options?: { allowEmpty?: boolean }) => string; |
| lastFrameRaw: (options?: { allowEmpty?: boolean }) => string; |
| generateSvg: () => string; |
| terminal: Terminal; |
| waitUntilReady: () => Promise<void>; |
| capturedOverflowState: OverflowState | undefined; |
| capturedOverflowActions: OverflowActions | undefined; |
| }; |
|
|
| export type RenderWithProvidersInstance = RenderInstance & { |
| simulateClick: ( |
| col: number, |
| row: number, |
| button?: 0 | 1 | 2, |
| ) => Promise<void>; |
| }; |
|
|
| const instances: InkInstance[] = []; |
|
|
| export const render = async ( |
| tree: React.ReactElement, |
| terminalWidth?: number, |
| ): Promise< |
| Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'> |
| > => { |
| const cols = terminalWidth ?? 100; |
| |
| |
| |
| |
| const rows = 1000; |
| const terminal = new Terminal({ |
| cols, |
| rows, |
| allowProposedApi: true, |
| convertEol: true, |
| }); |
|
|
| const state: TerminalState = { |
| terminal, |
| cols, |
| rows, |
| }; |
| const writeQueue = { promise: Promise.resolve() }; |
| const stdout = new XtermStdout(state, writeQueue); |
| const stderr = new XtermStderr(state, writeQueue); |
| const stdin = new XtermStdin(); |
|
|
| let instance!: InkInstance; |
| stdout.clear(); |
| act(() => { |
| instance = inkRenderDirect(tree, { |
| stdout: stdout as unknown as NodeJS.WriteStream, |
|
|
| stderr: stderr as unknown as NodeJS.WriteStream, |
|
|
| stdin: stdin as unknown as NodeJS.ReadStream, |
| debug: false, |
| exitOnCtrlC: false, |
| patchConsole: false, |
| onRender: (metrics: RenderMetrics) => { |
| const output = isInkRenderMetrics(metrics) ? metrics.output : '...'; |
| const staticOutput = isInkRenderMetrics(metrics) |
| ? (metrics.staticOutput ?? '') |
| : ''; |
| stdout.onRender(staticOutput, output); |
| }, |
| }); |
| }); |
|
|
| instances.push(instance); |
|
|
| await stdout.waitUntilReady(); |
|
|
| return { |
| rerender: (newTree: React.ReactElement) => { |
| act(() => { |
| stdout.clear(); |
| instance.rerender(newTree); |
| }); |
| }, |
| unmount: () => { |
| act(() => { |
| instance.unmount(); |
| }); |
| stdout.dispose(); |
| stderr.dispose(); |
| }, |
| cleanup: instance.cleanup, |
| stdout, |
| stderr, |
| stdin, |
| frames: stdout.frames, |
| lastFrame: stdout.lastFrame, |
| lastFrameRaw: stdout.lastFrameRaw, |
| generateSvg: stdout.generateSvg, |
| terminal: state.terminal, |
| waitUntilReady: () => stdout.waitUntilReady(), |
| }; |
| }; |
|
|
| export const cleanup = () => { |
| for (const instance of instances) { |
| act(() => { |
| instance.unmount(); |
| }); |
| instance.cleanup(); |
| } |
| instances.length = 0; |
| }; |
|
|
| export const simulateClick = async ( |
| stdin: XtermStdin, |
| col: number, |
| row: number, |
| button: 0 | 1 | 2 = 0, |
| ) => { |
| |
| const mouseEventString = `\x1b[<${button};${col};${row}M`; |
| await act(async () => { |
| stdin.write(mouseEventString); |
| }); |
| }; |
|
|
| export const mockSettings = createMockSettings(); |
|
|
| |
| |
| const baseMockUiState = { |
| history: [], |
| renderMarkdown: true, |
| streamingState: StreamingState.Idle, |
| isConfigInitialized: true, |
| isAuthenticating: false, |
| terminalWidth: 100, |
| terminalHeight: 40, |
| currentModel: 'gemini-pro', |
| terminalBackgroundColor: 'black' as const, |
| cleanUiDetailsVisible: false, |
| allowPlanMode: true, |
| activePtyId: undefined, |
| backgroundTasks: new Map(), |
| backgroundTaskHeight: 0, |
| quota: { |
| userTier: undefined, |
| stats: undefined, |
| proQuotaRequest: null, |
| validationRequest: null, |
| }, |
| hintMode: false, |
| hintBuffer: '', |
| bannerData: { |
| defaultText: '', |
| warningText: '', |
| }, |
| bannerVisible: false, |
| nightly: false, |
| updateInfo: null, |
| pendingHistoryItems: [], |
| mainControlsRef: () => {}, |
| rootUiRef: { current: null }, |
| }; |
|
|
| 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(), |
| openVoiceModelDialog: vi.fn(), |
| closeVoiceModelDialog: 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(), |
| setIsPolicyUpdateDialogOpen: vi.fn(), |
| setConstrainHeight: vi.fn(), |
| onEscapePromptChange: vi.fn(), |
| refreshStatic: vi.fn(), |
| handleFinalSubmit: vi.fn(), |
| handleClearScreen: vi.fn(), |
| handleProQuotaChoice: vi.fn(), |
| handleValidationChoice: vi.fn(), |
| handleOverageMenuChoice: vi.fn(), |
| handleEmptyWalletChoice: vi.fn(), |
| setQueueErrorMessage: vi.fn(), |
| addMessage: vi.fn(), |
| popAllMessages: vi.fn(), |
| handleApiKeySubmit: vi.fn(), |
| handleApiKeyCancel: vi.fn(), |
| setBannerVisible: vi.fn(), |
| setShortcutsHelpVisible: vi.fn(), |
| setCleanUiDetailsVisible: vi.fn(), |
| toggleCleanUiDetailsVisible: vi.fn(), |
| revealCleanUiDetailsTemporarily: vi.fn(), |
| handleWarning: vi.fn(), |
| setEmbeddedShellFocused: vi.fn(), |
| dismissBackgroundTask: vi.fn(), |
| setActiveBackgroundTaskPid: vi.fn(), |
| setIsBackgroundTaskListOpen: vi.fn(), |
| setAuthContext: vi.fn(), |
| dismissLoginRestart: vi.fn(), |
| onHintInput: vi.fn(), |
| onHintBackspace: vi.fn(), |
| onHintClear: vi.fn(), |
| onHintSubmit: vi.fn(), |
| handleRestart: vi.fn(), |
| handleNewAgentsSelect: vi.fn(), |
| getPreferredEditor: vi.fn(), |
| clearAccountSuspension: vi.fn(), |
| setVoiceModeEnabled: vi.fn(), |
| }; |
|
|
| import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; |
| import { InputContext, type InputState } from '../ui/contexts/InputContext.js'; |
| import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js'; |
|
|
| let capturedOverflowState: OverflowState | undefined; |
| let capturedOverflowActions: OverflowActions | undefined; |
| const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ |
| children, |
| }) => { |
| capturedOverflowState = useOverflowState(); |
| capturedOverflowActions = useOverflowActions(); |
| return <>{children}</>; |
| }; |
|
|
| export const renderWithProviders = async ( |
| component: React.ReactElement, |
| { |
| shellFocus = true, |
| settings = mockSettings, |
| uiState: providedUiState, |
| quotaState: providedQuotaState, |
| inputState: providedInputState, |
| width, |
| mouseEventsEnabled = false, |
| config, |
| uiActions, |
| toolActions, |
| persistentState, |
| appState = mockAppState, |
| }: { |
| shellFocus?: boolean; |
| settings?: LoadedSettings; |
| uiState?: Partial<UIState>; |
| quotaState?: Partial<QuotaState>; |
| inputState?: Partial<InputState>; |
| width?: number; |
| mouseEventsEnabled?: boolean; |
| config?: Config; |
| uiActions?: Partial<UIActions>; |
| toolActions?: Partial<{ |
| isExpanded: (callId: string) => boolean; |
| toggleExpansion: (callId: string) => void; |
| toggleAllExpansion: (callIds: string[]) => void; |
| }>; |
| persistentState?: { |
| get?: typeof persistentStateMock.get; |
| set?: typeof persistentStateMock.set; |
| }; |
| appState?: AppState; |
| } = {}, |
| ): Promise<RenderWithProvidersInstance> => { |
| const baseState: UIState = new Proxy( |
| { ...baseMockUiState, ...providedUiState }, |
| { |
| get(target, prop) { |
| if (prop in target) { |
| return target[prop as keyof typeof target]; |
| } |
| |
| |
| |
| if (prop in baseMockUiState) { |
| return baseMockUiState[prop as keyof typeof baseMockUiState]; |
| } |
| throw new Error(`mockUiState does not have property ${String(prop)}`); |
| }, |
| }, |
| ) as UIState; |
|
|
| const quotaState: QuotaState = { |
| userTier: undefined, |
| stats: undefined, |
| proQuotaRequest: null, |
| validationRequest: null, |
| overageMenuRequest: null, |
| emptyWalletRequest: null, |
| ...providedQuotaState, |
| }; |
|
|
| const inputState = { |
| buffer: { text: '' } as unknown as TextBuffer, |
| userMessages: [], |
| shellModeActive: false, |
| showEscapePrompt: false, |
| copyModeEnabled: false, |
| inputWidth: 80, |
| suggestionsWidth: 40, |
| ...(providedUiState as unknown as Partial<InputState>), |
| ...providedInputState, |
| }; |
|
|
| if (persistentState?.get) { |
| persistentStateMock.get.mockImplementation(persistentState.get); |
| } |
| if (persistentState?.set) { |
| persistentStateMock.set.mockImplementation(persistentState.set); |
| } |
|
|
| persistentStateMock.mockClear(); |
|
|
| const terminalWidth = width ?? baseState.terminalWidth; |
|
|
| const finalConfig = |
| config || |
| makeFakeConfig({ |
| useAlternateBuffer: settings.merged.ui?.useAlternateBuffer, |
| showMemoryUsage: settings.merged.ui?.showMemoryUsage, |
| accessibility: settings.merged.ui?.accessibility, |
| }); |
|
|
| const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth; |
|
|
| const finalUiState = { |
| ...baseState, |
| terminalWidth, |
| mainAreaWidth, |
| }; |
|
|
| themeManager.setTerminalBackground(baseState.terminalBackgroundColor); |
| const themeName = pickDefaultThemeName( |
| baseState.terminalBackgroundColor, |
| themeManager.getAllThemes(), |
| DEFAULT_THEME.name, |
| DefaultLight.name, |
| ); |
| themeManager.setActiveTheme(themeName); |
|
|
| const finalUIActions = { ...mockUIActions, ...uiActions }; |
|
|
| const allToolCalls = (finalUiState.pendingHistoryItems || []) |
| .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') |
| .flatMap((item) => item.tools); |
|
|
| capturedOverflowState = undefined; |
| capturedOverflowActions = undefined; |
|
|
| const wrapWithProviders = (comp: React.ReactElement) => ( |
| <AppContext.Provider value={appState}> |
| <ConfigContext.Provider value={finalConfig}> |
| <SettingsContext.Provider value={settings}> |
| <QuotaContext.Provider value={quotaState}> |
| <InputContext.Provider value={inputState}> |
| <UIStateContext.Provider value={finalUiState}> |
| <VimModeProvider> |
| <ShellFocusContext.Provider value={shellFocus}> |
| <SessionStatsProvider |
| sessionId={finalConfig.getSessionId()} |
| > |
| <StreamingContext.Provider |
| value={finalUiState.streamingState} |
| > |
| <UIActionsContext.Provider value={finalUIActions}> |
| <OverflowProvider> |
| <ToolActionsProvider |
| config={finalConfig} |
| toolCalls={allToolCalls} |
| isExpanded={ |
| toolActions?.isExpanded ?? |
| vi.fn().mockReturnValue(false) |
| } |
| toggleExpansion={ |
| toolActions?.toggleExpansion ?? vi.fn() |
| } |
| toggleAllExpansion={ |
| toolActions?.toggleAllExpansion ?? vi.fn() |
| } |
| > |
| <AskUserActionsProvider |
| request={null} |
| onSubmit={vi.fn()} |
| onCancel={vi.fn()} |
| > |
| <KeypressProvider> |
| <MouseProvider |
| mouseEventsEnabled={mouseEventsEnabled} |
| > |
| <TerminalProvider> |
| <ScrollProvider> |
| <ContextCapture> |
| <Box |
| width={terminalWidth} |
| flexShrink={0} |
| flexGrow={0} |
| flexDirection="column" |
| > |
| {comp} |
| </Box> |
| </ContextCapture> |
| </ScrollProvider> |
| </TerminalProvider> |
| </MouseProvider> |
| </KeypressProvider> |
| </AskUserActionsProvider> |
| </ToolActionsProvider> |
| </OverflowProvider> |
| </UIActionsContext.Provider> |
| </StreamingContext.Provider> |
| </SessionStatsProvider> |
| </ShellFocusContext.Provider> |
| </VimModeProvider> |
| </UIStateContext.Provider> |
| </InputContext.Provider> |
| </QuotaContext.Provider> |
| </SettingsContext.Provider> |
| </ConfigContext.Provider> |
| </AppContext.Provider> |
| ); |
|
|
| const renderResult = await render( |
| wrapWithProviders(component), |
| terminalWidth, |
| ); |
|
|
| return { |
| ...renderResult, |
| rerender: (newComponent: React.ReactElement) => { |
| renderResult.rerender(wrapWithProviders(newComponent)); |
| }, |
| capturedOverflowState, |
| capturedOverflowActions, |
| simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => |
| simulateClick(renderResult.stdin, col, row, button), |
| }; |
| }; |
|
|
| export async function renderHook<Result, Props>( |
| renderCallback: (props: Props) => Result, |
| options?: { |
| initialProps?: Props; |
| wrapper?: React.ComponentType<{ children: React.ReactNode }>; |
| }, |
| ): Promise<{ |
| result: { current: Result }; |
| rerender: (props?: Props) => void; |
| unmount: () => void; |
| waitUntilReady: () => Promise<void>; |
| generateSvg: () => string; |
| }> { |
| 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 = () => {}; |
| let waitUntilReady: () => Promise<void> = async () => {}; |
| let generateSvg: () => string = () => ''; |
|
|
| const renderResult = await render( |
| <Wrapper> |
| <TestComponent renderCallback={renderCallback} props={currentProps} /> |
| </Wrapper>, |
| ); |
| inkRerender = renderResult.rerender; |
| unmount = renderResult.unmount; |
| waitUntilReady = renderResult.waitUntilReady; |
| generateSvg = renderResult.generateSvg; |
|
|
| 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, waitUntilReady, generateSvg }; |
| } |
|
|
| export async function renderHookWithProviders<Result, Props>( |
| renderCallback: (props: Props) => Result, |
| options: { |
| initialProps?: Props; |
| wrapper?: React.ComponentType<{ children: React.ReactNode }>; |
| |
| shellFocus?: boolean; |
| settings?: LoadedSettings; |
| uiState?: Partial<UIState>; |
| width?: number; |
| mouseEventsEnabled?: boolean; |
| config?: Config; |
| } = {}, |
| ): Promise<{ |
| result: { current: Result }; |
| rerender: (props?: Props) => void; |
| unmount: () => void; |
| waitUntilReady: () => Promise<void>; |
| generateSvg: () => string; |
| }> { |
| 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: RenderWithProvidersInstance; |
|
|
| await act(async () => { |
| renderResult = await 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(); |
| }); |
| }, |
| waitUntilReady: () => renderResult.waitUntilReady(), |
| generateSvg: () => renderResult.generateSvg(), |
| }; |
| } |
|
|