diff --git a/packages/a2a-server/vitest.config.ts b/packages/a2a-server/vitest.config.ts index 224666610d..a22fdc31ae 100644 --- a/packages/a2a-server/vitest.config.ts +++ b/packages/a2a-server/vitest.config.ts @@ -11,7 +11,6 @@ export default defineConfig({ test: { include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], exclude: ['**/node_modules/**', '**/dist/**'], - environment: 'jsdom', globals: true, reporters: ['default', 'junit'], silent: true, diff --git a/packages/cli/src/test-utils/async.ts b/packages/cli/src/test-utils/async.ts new file mode 100644 index 0000000000..ad34cb5814 --- /dev/null +++ b/packages/cli/src/test-utils/async.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; + +// The waitFor from vitest doesn't properly wrap in act(), so we have to +// implement our own like the one in @testing-library/react +// or @testing-library/react-native +// The version of waitFor from vitest is still fine to use if you aren't waiting +// for React state updates. +export async function waitFor( + assertion: () => void, + { timeout = 1000, interval = 50 } = {}, +): Promise { + const startTime = Date.now(); + + while (true) { + try { + assertion(); + return; + } catch (error) { + if (Date.now() - startTime > timeout) { + throw error; + } + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, interval)); + }); + } + } +} diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index b705c2a5e1..fcf6f5ec0a 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -6,7 +6,37 @@ import { describe, it, expect, vi } from 'vitest'; import { useState, useEffect } from 'react'; -import { renderHook } from './render.js'; +import { Text } from 'ink'; +import { renderHook, render } from './render.js'; +import { waitFor } from './async.js'; + +describe('render', () => { + it('should render a component', () => { + const { lastFrame } = render(Hello World); + expect(lastFrame()).toBe('Hello World'); + }); + + it('should support rerender', () => { + const { lastFrame, rerender } = render(Hello); + expect(lastFrame()).toBe('Hello'); + + rerender(World); + expect(lastFrame()).toBe('World'); + }); + + it('should support unmount', () => { + const cleanup = vi.fn(); + function TestComponent() { + useEffect(() => cleanup, []); + return Hello; + } + + const { unmount } = render(); + unmount(); + + expect(cleanup).toHaveBeenCalled(); + }); +}); describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { @@ -23,19 +53,19 @@ describe('renderHook', () => { }); expect(result.current.value).toBe(1); - await vi.waitFor(() => expect(result.current.count).toBe(1)); + await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props rerender({ value: 2 }); expect(result.current.value).toBe(2); - await vi.waitFor(() => expect(result.current.count).toBe(2)); + await waitFor(() => expect(result.current.count).toBe(2)); // Rerender without arguments should use previous props (value: 2) // This would previously crash or pass undefined if not fixed rerender(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change - await vi.waitFor(() => expect(result.current.count).toBe(2)); + await waitFor(() => expect(result.current.count).toBe(2)); }); it('should handle initial render without props', () => { diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 1eb00406c5..0413c4a46d 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render as inkRender } from 'ink-testing-library'; import type React from 'react'; import { act } from 'react'; import { LoadedSettings, type Settings } from '../config/settings.js'; @@ -19,6 +19,34 @@ import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { type Config } from '@google/gemini-cli-core'; +// Wrapper around ink-testing-library's render that ensures act() is called +export const render = ( + tree: React.ReactElement, +): ReturnType => { + let renderResult: ReturnType = + undefined as unknown as ReturnType; + act(() => { + renderResult = inkRender(tree); + }); + + const originalUnmount = renderResult.unmount; + const originalRerender = renderResult.rerender; + + return { + ...renderResult, + unmount: () => { + act(() => { + originalUnmount(); + }); + }, + rerender: (newTree: React.ReactElement) => { + act(() => { + originalRerender(newTree); + }); + }, + }; +}; + const mockConfig = { getModel: () => 'gemini-pro', getTargetDir: () => diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 1067945ab6..500e8ce06b 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../test-utils/render.js'; import { Text, useIsScreenReaderEnabled } from 'ink'; import { makeFakeConfig } from '@google/gemini-cli-core'; import { App } from './App.js'; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0337a6bc1a..5e9ce80623 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,9 @@ import { type Mock, type MockedObject, } from 'vitest'; -import { render, cleanup } from 'ink-testing-library'; +import { render } from '../test-utils/render.js'; +import { cleanup } from 'ink-testing-library'; +import { act, useContext } from 'react'; import { AppContainer } from './AppContainer.js'; import { type Config, @@ -47,7 +49,6 @@ import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; -import { useContext } from 'react'; // Mock useStdout to capture terminal title writes let mockStdout: { write: ReturnType }; @@ -323,53 +324,59 @@ describe('AppContainer State Management', () => { }); describe('Basic Rendering', () => { - it('renders without crashing with minimal props', () => { - expect(() => { - render( - , - ); - }).not.toThrow(); + it('renders without crashing with minimal props', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); - it('renders with startup warnings', () => { + it('renders with startup warnings', async () => { const startupWarnings = ['Warning 1', 'Warning 2']; - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); }); describe('State Initialization', () => { - it('initializes with theme error from initialization result', () => { + it('initializes with theme error from initialization result', async () => { const initResultWithError = { ...mockInitResult, themeError: 'Failed to load theme', }; - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); it('handles debug mode state', () => { @@ -390,7 +397,7 @@ describe('AppContainer State Management', () => { }); describe('Context Providers', () => { - it('provides AppContext with correct values', () => { + it('provides AppContext with correct values', async () => { const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Should render and unmount cleanly expect(() => unmount()).not.toThrow(); }); - it('provides UIStateContext with state management', () => { - expect(() => { - render( - , - ); - }).not.toThrow(); + it('provides UIStateContext with state management', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); - it('provides UIActionsContext with action handlers', () => { - expect(() => { - render( - , - ); - }).not.toThrow(); + it('provides UIActionsContext with action handlers', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); - it('provides ConfigContext with config object', () => { - expect(() => { - render( - , - ); - }).not.toThrow(); + it('provides ConfigContext with config object', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); }); describe('Settings Integration', () => { - it('handles settings with all display options disabled', () => { + it('handles settings with all display options disabled', async () => { const settingsAllHidden = { merged: { hideBanner: true, @@ -455,19 +471,21 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); - it('handles settings with memory usage enabled', () => { + it('handles settings with memory usage enabled', async () => { const settingsWithMemory = { merged: { hideBanner: false, @@ -477,72 +495,80 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); }); describe('Version Handling', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', - (version) => { - expect(() => { - render( - , - ); - }).not.toThrow(); + async (version) => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }, ); }); describe('Error Handling', () => { - it('handles config methods that might throw', () => { + it('handles config methods that might throw', async () => { const errorConfig = makeFakeConfig(); vi.spyOn(errorConfig, 'getModel').mockImplementation(() => { throw new Error('Config error'); }); // Should still render without crashing - errors should be handled internally - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); - it('handles undefined settings gracefully', () => { + it('handles undefined settings gracefully', async () => { const undefinedSettings = { merged: {}, } as LoadedSettings; - expect(() => { - render( - , - ); - }).not.toThrow(); + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + unmount(); }); }); @@ -564,9 +590,9 @@ describe('AppContainer State Management', () => { }); describe('Quota and Fallback Integration', () => { - it('passes a null proQuotaRequest to UIStateContext by default', () => { + it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - render( + const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Assert that the context value is as expected expect(capturedUIState.proQuotaRequest).toBeNull(); + unmount(); }); - it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => { + it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { // Arrange: Create a mock request object that a UI dialog would receive const mockRequest = { failedModel: 'gemini-pro', @@ -592,7 +622,7 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - render( + const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Assert: The mock request is correctly passed through the context expect(capturedUIState.proQuotaRequest).toEqual(mockRequest); + unmount(); }); - it('passes the handleProQuotaChoice function to UIActionsContext', () => { + it('passes the handleProQuotaChoice function to UIActionsContext', async () => { // Arrange: Create a mock handler function const mockHandler = vi.fn(); mockedUseQuotaAndFallback.mockReturnValue({ @@ -614,7 +648,7 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - render( + const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Assert: The action in the context is the mock handler we provided expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); // You can even verify that the plumbed function is callable - capturedUIActions.handleProQuotaChoice('auth'); + act(() => { + capturedUIActions.handleProQuotaChoice('auth'); + }); expect(mockHandler).toHaveBeenCalledWith('auth'); + unmount(); }); }); @@ -993,7 +1033,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender } = render( + const { rerender, unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + vi.advanceTimersByTime(0); + }); expect(capturedUIState.queueErrorMessage).toBeNull(); - capturedUIActions.setQueueErrorMessage('Test error'); + act(() => { + capturedUIActions.setQueueErrorMessage('Test error'); + }); rerender( { ); expect(capturedUIState.queueErrorMessage).toBe('Test error'); - vi.advanceTimersByTime(3000); + act(() => { + vi.advanceTimersByTime(3000); + }); rerender( { />, ); expect(capturedUIState.queueErrorMessage).toBeNull(); + unmount(); }); it('should reset the timer if a new error message is set', async () => { - const { rerender } = render( + const { rerender, unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + vi.advanceTimersByTime(0); + }); - capturedUIActions.setQueueErrorMessage('First error'); + act(() => { + capturedUIActions.setQueueErrorMessage('First error'); + }); rerender( { ); expect(capturedUIState.queueErrorMessage).toBe('First error'); - vi.advanceTimersByTime(1500); + act(() => { + vi.advanceTimersByTime(1500); + }); - capturedUIActions.setQueueErrorMessage('Second error'); + act(() => { + capturedUIActions.setQueueErrorMessage('Second error'); + }); rerender( { ); expect(capturedUIState.queueErrorMessage).toBe('Second error'); - vi.advanceTimersByTime(2000); + act(() => { + vi.advanceTimersByTime(2000); + }); rerender( { expect(capturedUIState.queueErrorMessage).toBe('Second error'); // 5. Advance time past the 3 second timeout from the second message - vi.advanceTimersByTime(1000); + act(() => { + vi.advanceTimersByTime(1000); + }); rerender( { />, ); expect(capturedUIState.queueErrorMessage).toBeNull(); + unmount(); }); }); @@ -1090,7 +1152,7 @@ describe('AppContainer State Management', () => { const mockedMeasureElement = measureElement as Mock; const mockedUseTerminalSize = useTerminalSize as Mock; - it('should prevent terminal height from being less than 1', () => { + it('should prevent terminal height from being less than 1', async () => { const resizePtySpy = vi.spyOn(ShellExecutionService, 'resizePty'); // Arrange: Simulate a small terminal and a large footer mockedUseTerminalSize.mockReturnValue({ columns: 80, rows: 5 }); @@ -1106,7 +1168,7 @@ describe('AppContainer State Management', () => { activePtyId: 'some-id', }); - render( + const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Assert: The shell should be resized to a minimum height of 1, not a negative number. // The old code would have tried to set a negative height. @@ -1122,6 +1187,7 @@ describe('AppContainer State Management', () => { resizePtySpy.mock.calls[resizePtySpy.mock.calls.length - 1]; // Check the height argument specifically expect(lastCall[2]).toBe(1); + unmount(); }); }); @@ -1130,10 +1196,11 @@ describe('AppContainer State Management', () => { let mockHandleSlashCommand: Mock; let mockCancelOngoingRequest: Mock; let rerender: () => void; + let unmount: () => void; // Helper function to reduce boilerplate in tests - const setupKeypressTest = () => { - const { rerender: inkRerender } = render( + const setupKeypressTest = async () => { + const renderResult = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + vi.advanceTimersByTime(0); + }); rerender = () => - inkRerender( + renderResult.rerender( { initializationResult={mockInitResult} />, ); + unmount = renderResult.unmount; }; const pressKey = (key: Partial, times = 1) => { for (let i = 0; i < times; i++) { - handleGlobalKeypress({ - name: 'c', - ctrl: false, - meta: false, - shift: false, - ...key, - } as Key); + act(() => { + handleGlobalKeypress({ + name: 'c', + ctrl: false, + meta: false, + shift: false, + ...key, + } as Key); + }); rerender(); } }; @@ -1208,7 +1281,7 @@ describe('AppContainer State Management', () => { }); describe('CTRL+C', () => { - it('should cancel ongoing request on first press', () => { + it('should cancel ongoing request on first press', async () => { mockedUseGeminiStream.mockReturnValue({ streamingState: 'responding', submitQuery: vi.fn(), @@ -1217,136 +1290,91 @@ describe('AppContainer State Management', () => { thought: null, cancelOngoingRequest: mockCancelOngoingRequest, }); - setupKeypressTest(); + await setupKeypressTest(); pressKey({ name: 'c', ctrl: true }); expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); }); - it('should quit on second press', () => { - setupKeypressTest(); + it('should quit on second press', async () => { + await setupKeypressTest(); pressKey({ name: 'c', ctrl: true }, 2); expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2); expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + unmount(); }); - it('should reset press count after a timeout', () => { - setupKeypressTest(); + it('should reset press count after a timeout', async () => { + await setupKeypressTest(); pressKey({ name: 'c', ctrl: true }); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold - vi.advanceTimersByTime(1001); + act(() => { + vi.advanceTimersByTime(1001); + }); pressKey({ name: 'c', ctrl: true }); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); }); }); describe('CTRL+D', () => { - it('should do nothing if text buffer is not empty', () => { + it('should do nothing if text buffer is not empty', async () => { mockedUseTextBuffer.mockReturnValue({ text: 'some text', setText: vi.fn(), }); - setupKeypressTest(); + await setupKeypressTest(); pressKey({ name: 'd', ctrl: true }, 2); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); }); - it('should quit on second press if buffer is empty', () => { - setupKeypressTest(); + it('should quit on second press if buffer is empty', async () => { + await setupKeypressTest(); pressKey({ name: 'd', ctrl: true }, 2); expect(mockHandleSlashCommand).toHaveBeenCalledWith('/quit'); + unmount(); }); - it('should reset press count after a timeout', () => { - setupKeypressTest(); + it('should reset press count after a timeout', async () => { + await setupKeypressTest(); pressKey({ name: 'd', ctrl: true }); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); // Advance timer past the reset threshold - vi.advanceTimersByTime(1001); + act(() => { + vi.advanceTimersByTime(1001); + }); pressKey({ name: 'd', ctrl: true }); expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + unmount(); }); }); }); describe('Model Dialog Integration', () => { - it('should provide isModelDialogOpen in the UIStateContext', () => { + it('should provide isModelDialogOpen in the UIStateContext', async () => { mockedUseModelCommand.mockReturnValue({ isModelDialogOpen: true, openModelDialog: vi.fn(), closeModelDialog: vi.fn(), }); - render( - , - ); - - expect(capturedUIState.isModelDialogOpen).toBe(true); - }); - - it('should provide model dialog actions in the UIActionsContext', () => { - const mockCloseModelDialog = vi.fn(); - - mockedUseModelCommand.mockReturnValue({ - isModelDialogOpen: false, - openModelDialog: vi.fn(), - closeModelDialog: mockCloseModelDialog, - }); - - render( - , - ); - - // Verify that the actions are correctly passed through context - capturedUIActions.closeModelDialog(); - expect(mockCloseModelDialog).toHaveBeenCalled(); - }); - }); - - describe('CoreEvents Integration', () => { - it('subscribes to UserFeedback and drains backlog on mount', () => { - render( - , - ); - - expect(mockCoreEvents.on).toHaveBeenCalledWith( - CoreEvent.UserFeedback, - expect.any(Function), - ); - expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1); - }); - - it('unsubscribes from UserFeedback on unmount', () => { const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(capturedUIState.isModelDialogOpen).toBe(true); + unmount(); + }); + + it('should provide model dialog actions in the UIActionsContext', async () => { + const mockCloseModelDialog = vi.fn(); + + mockedUseModelCommand.mockReturnValue({ + isModelDialogOpen: false, + openModelDialog: vi.fn(), + closeModelDialog: mockCloseModelDialog, + }); + + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Verify that the actions are correctly passed through context + act(() => { + capturedUIActions.closeModelDialog(); + }); + expect(mockCloseModelDialog).toHaveBeenCalled(); + unmount(); + }); + }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on mount', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('unsubscribes from UserFeedback on unmount', async () => { + const { unmount } = render( + , + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); unmount(); @@ -1364,8 +1464,8 @@ describe('AppContainer State Management', () => { ); }); - it('adds history item when UserFeedback event is received', () => { - render( + it('adds history item when UserFeedback event is received', async () => { + const { unmount } = render( { initializationResult={mockInitResult} />, ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Get the registered handler const handler = mockCoreEvents.on.mock.calls.find( @@ -1385,7 +1488,9 @@ describe('AppContainer State Management', () => { severity: 'error', message: 'Test error message', }; - handler(payload); + act(() => { + handler(payload); + }); expect(mockedUseHistory().addItem).toHaveBeenCalledWith( expect.objectContaining({ @@ -1394,6 +1499,7 @@ describe('AppContainer State Management', () => { }), expect.any(Number), ); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index e170afb5e2..80934b6d3d 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { ApiAuthDialog } from './ApiAuthDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 3495a27b4f..7029e92822 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { AnsiOutputText } from './AnsiOutput.js'; import type { AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 6173355c42..78b2654f2e 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { Composer } from './Composer.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index 4cbd2b1b8e..b40fed9a92 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -6,7 +6,8 @@ import { Text } from 'ink'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { act } from 'react'; import { ConsentPrompt } from './ConsentPrompt.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; @@ -32,7 +33,7 @@ describe('ConsentPrompt', () => { it('renders a string prompt with MarkdownDisplay', () => { const prompt = 'Are you sure?'; - render( + const { unmount } = render( { }, undefined, ); + unmount(); }); it('renders a ReactNode prompt directly', () => { const prompt = Are you sure?; - const { lastFrame } = render( + const { lastFrame, unmount } = render( { expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); + unmount(); }); it('calls onConfirm with true when "Yes" is selected', () => { const prompt = 'Are you sure?'; - render( + const { unmount } = render( { ); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; - onSelect(true); + act(() => { + onSelect(true); + }); expect(onConfirm).toHaveBeenCalledWith(true); + unmount(); }); it('calls onConfirm with false when "No" is selected', () => { const prompt = 'Are you sure?'; - render( + const { unmount } = render( { ); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; - onSelect(false); + act(() => { + onSelect(false); + }); expect(onConfirm).toHaveBeenCalledWith(false); + unmount(); }); it('passes correct items to RadioButtonSelect', () => { const prompt = 'Are you sure?'; - render( + const { unmount } = render( { }), undefined, ); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index 0766e6f6da..a61f57aa92 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; @@ -37,17 +37,18 @@ describe('', () => { }; it('should render on a single line on a wide screen', () => { - const { lastFrame } = renderWithWidth(120, baseProps); + const { lastFrame, unmount } = renderWithWidth(120, baseProps); const output = lastFrame()!; expect(output).toContain( 'Using: 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server', ); // Check for absence of newlines expect(output.includes('\n')).toBe(false); + unmount(); }); it('should render on multiple lines on a narrow screen', () => { - const { lastFrame } = renderWithWidth(60, baseProps); + const { lastFrame, unmount } = renderWithWidth(60, baseProps); const output = lastFrame()!; const expectedLines = [ ' Using:', @@ -57,17 +58,26 @@ describe('', () => { ]; const actualLines = output.split('\n'); expect(actualLines).toEqual(expectedLines); + unmount(); }); it('should switch layout at the 80-column breakpoint', () => { // At 80 columns, should be on one line - const { lastFrame: wideFrame } = renderWithWidth(80, baseProps); + const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth( + 80, + baseProps, + ); expect(wideFrame()!.includes('\n')).toBe(false); + unmountWide(); // At 79 columns, should be on multiple lines - const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps); + const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth( + 79, + baseProps, + ); expect(narrowFrame()!.includes('\n')).toBe(true); expect(narrowFrame()!.split('\n').length).toBe(4); + unmountNarrow(); }); it('should not render empty parts', () => { @@ -77,9 +87,10 @@ describe('', () => { contextFileNames: [], mcpServers: {}, }; - const { lastFrame } = renderWithWidth(60, props); + const { lastFrame, unmount } = renderWithWidth(60, props); const expectedLines = [' Using:', ' - 1 open file (ctrl+g to view)']; const actualLines = lastFrame()!.split('\n'); expect(actualLines).toEqual(expectedLines); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 588f39653e..c52da8bd9d 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,6 +5,7 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; @@ -54,12 +55,12 @@ describe('FolderTrustDialog', () => { stdin.write('\u001b[27u'); // Press kitty escape key }); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain( 'A folder trust level must be selected to continue. Exiting since escape was pressed.', ); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedExit).toHaveBeenCalledWith(1); }); expect(onSelect).not.toHaveBeenCalled(); @@ -93,7 +94,7 @@ describe('FolderTrustDialog', () => { stdin.write('r'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedExit).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 95ed3f07a1..f3f10382e2 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Header } from './Header.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; @@ -20,25 +20,35 @@ describe('
', () => { columns: 120, rows: 20, }); - const { lastFrame } = render(
); + const { lastFrame, unmount } = render( +
, + ); expect(lastFrame()).toContain(longAsciiLogo); + unmount(); }); it('renders custom ASCII art when provided', () => { const customArt = 'CUSTOM ART'; - const { lastFrame } = render( + const { lastFrame, unmount } = render(
, ); expect(lastFrame()).toContain(customArt); + unmount(); }); it('displays the version number when nightly is true', () => { - const { lastFrame } = render(
); + const { lastFrame, unmount } = render( +
, + ); expect(lastFrame()).toContain('v1.0.0'); + unmount(); }); it('does not display the version number when nightly is false', () => { - const { lastFrame } = render(
); + const { lastFrame, unmount } = render( +
, + ); expect(lastFrame()).not.toContain('v1.0.0'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index 27f072c8eb..c43c1e71df 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; @@ -44,18 +44,20 @@ const mockCommands: readonly SlashCommand[] = [ describe('Help Component', () => { it('should not render hidden commands', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain('/test'); expect(output).not.toContain('/hidden'); + unmount(); }); it('should not render hidden subcommands', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain('visible-child'); expect(output).not.toContain('hidden-child'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 3da977c409..2eb50aa550 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -5,6 +5,7 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import type { InputPromptProps } from './InputPrompt.js'; import { InputPrompt } from './InputPrompt.js'; @@ -240,7 +241,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => + await waitFor(() => expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled(), ); unmount(); @@ -252,10 +253,10 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[B'); + await waitFor(() => + expect(mockShellHistory.getNextCommand).toHaveBeenCalled(), + ); }); - await vi.waitFor(() => - expect(mockShellHistory.getNextCommand).toHaveBeenCalled(), - ); unmount(); }); @@ -269,7 +270,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled(); expect(props.buffer.setText).toHaveBeenCalledWith('previous command'); }); @@ -284,7 +285,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith( 'ls -l', ); @@ -300,21 +301,19 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); // Up arrow }); - await vi.waitFor(() => - expect(mockInputHistory.navigateUp).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled()); await act(async () => { stdin.write('\u001B[B'); // Down arrow }); - await vi.waitFor(() => + await waitFor(() => expect(mockInputHistory.navigateDown).toHaveBeenCalled(), ); await act(async () => { stdin.write('\r'); // Enter }); - await vi.waitFor(() => + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledWith('some text'), ); @@ -342,14 +341,14 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); // Up arrow }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1), ); await act(async () => { stdin.write('\u0010'); // Ctrl+P }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2), ); expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); @@ -374,14 +373,14 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[B'); // Down arrow }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1), ); await act(async () => { stdin.write('\u000E'); // Ctrl+N }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2), ); expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); @@ -395,32 +394,29 @@ describe('InputPrompt', () => { showSuggestions: false, }); props.buffer.setText('some text'); - const { stdin, unmount } = renderWithProviders(); await act(async () => { stdin.write('\u001B[A'); // Up arrow }); - await vi.waitFor(() => - expect(mockInputHistory.navigateUp).toHaveBeenCalled(), - ); + await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled()); await act(async () => { stdin.write('\u001B[B'); // Down arrow }); - await vi.waitFor(() => + await waitFor(() => expect(mockInputHistory.navigateDown).toHaveBeenCalled(), ); await act(async () => { stdin.write('\u0010'); // Ctrl+P }); - await vi.waitFor(() => {}); await act(async () => { stdin.write('\u000E'); // Ctrl+N }); - await vi.waitFor(() => {}); - expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); - expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled(); + expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled(); + }); unmount(); }); @@ -447,7 +443,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x16'); // Ctrl+V }); - await vi.waitFor(() => { + await waitFor(() => { expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith( props.config.getTargetDir(), @@ -470,7 +466,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x16'); // Ctrl+V }); - await vi.waitFor(() => { + await waitFor(() => { expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled(); }); expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled(); @@ -489,7 +485,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x16'); // Ctrl+V }); - await vi.waitFor(() => { + await waitFor(() => { expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled(); }); expect(mockBuffer.setText).not.toHaveBeenCalled(); @@ -518,7 +514,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x16'); // Ctrl+V }); - await vi.waitFor(() => { + await waitFor(() => { // Should insert at cursor position with spaces expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled(); }); @@ -549,7 +545,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x16'); // Ctrl+V }); - await vi.waitFor(() => { + await waitFor(() => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error handling clipboard image:', expect.any(Error), @@ -577,7 +573,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); // Press Tab }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), ); unmount(); @@ -601,7 +597,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); // Press Tab }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1), ); unmount(); @@ -626,7 +622,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); // Press Tab }); - await vi.waitFor(() => + await waitFor(() => // It should NOT become '/show'. It should correctly become '/memory show'. expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), ); @@ -648,7 +644,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); // Press Tab }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), ); unmount(); @@ -668,7 +664,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => { + await waitFor(() => { // The app should autocomplete the text, NOT submit. expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); }); @@ -700,7 +696,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); // Press Tab for autocomplete }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), ); unmount(); @@ -714,9 +710,10 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); // Press Enter }); - await vi.waitFor(() => {}); - expect(props.onSubmit).not.toHaveBeenCalled(); + await waitFor(() => { + expect(props.onSubmit).not.toHaveBeenCalled(); + }); unmount(); }); @@ -733,9 +730,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => - expect(props.onSubmit).toHaveBeenCalledWith('/clear'), - ); + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledWith('/clear')); unmount(); }); @@ -752,9 +747,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => - expect(props.onSubmit).toHaveBeenCalledWith('/clear'), - ); + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledWith('/clear')); unmount(); }); @@ -772,7 +765,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0), ); expect(props.onSubmit).not.toHaveBeenCalled(); @@ -790,7 +783,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.buffer.backspace).toHaveBeenCalled(); expect(props.buffer.newline).toHaveBeenCalled(); }); @@ -800,13 +793,15 @@ describe('InputPrompt', () => { }); it('should clear the buffer on Ctrl+C if it has text', async () => { - props.buffer.setText('some text to clear'); + await act(async () => { + props.buffer.setText('some text to clear'); + }); const { stdin, unmount } = renderWithProviders(); await act(async () => { stdin.write('\x03'); // Ctrl+C character }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.buffer.setText).toHaveBeenCalledWith(''); expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); }); @@ -821,9 +816,10 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x03'); // Ctrl+C character }); - await vi.waitFor(() => {}); - expect(props.buffer.setText).not.toHaveBeenCalled(); + await waitFor(() => { + expect(props.buffer.setText).not.toHaveBeenCalled(); + }); unmount(); }); @@ -922,7 +918,7 @@ describe('InputPrompt', () => { const { unmount } = renderWithProviders(); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedUseCommandCompletion).toHaveBeenCalledWith( mockBuffer, ['/test/project/src'], @@ -949,7 +945,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('i'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.vimHandleInput).toHaveBeenCalled(); }); expect(mockBuffer.handleInput).not.toHaveBeenCalled(); @@ -965,7 +961,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('i'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.vimHandleInput).toHaveBeenCalled(); expect(mockBuffer.handleInput).toHaveBeenCalled(); }); @@ -982,7 +978,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('i'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.vimHandleInput).toHaveBeenCalled(); expect(mockBuffer.handleInput).toHaveBeenCalled(); }); @@ -1000,7 +996,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B[200~pasted text\x1B[201~'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockBuffer.handleInput).toHaveBeenCalledWith( expect.objectContaining({ paste: true, @@ -1020,7 +1016,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('a'); }); - await vi.waitFor(() => {}); + await waitFor(() => {}); expect(mockBuffer.handleInput).not.toHaveBeenCalled(); unmount(); @@ -1090,7 +1086,7 @@ describe('InputPrompt', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame(); expect(frame).toContain(expected); }); @@ -1147,7 +1143,7 @@ describe('InputPrompt', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame(); expect(frame).toContain(expected); }); @@ -1171,7 +1167,7 @@ describe('InputPrompt', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame(); const lines = frame!.split('\n'); // The line with the cursor should just be an inverted space inside the box border @@ -1203,7 +1199,7 @@ describe('InputPrompt', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame(); // Check that all lines, including the empty one, are rendered. // This implicitly tests that the Box wrapper provides height for the empty line. @@ -1241,7 +1237,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write(`\x1b[200~${pastedText}\x1b[201~`); }); - await vi.waitFor(() => { + await waitFor(() => { // Verify that the buffer's handleInput was called once with the full text expect(props.buffer.handleInput).toHaveBeenCalledTimes(1); expect(props.buffer.handleInput).toHaveBeenCalledWith( @@ -1277,7 +1273,9 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , ); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Simulate a paste operation (this should set the paste protection) await act(async () => { @@ -1288,7 +1286,9 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Verify that onSubmit was NOT called due to recent paste protection expect(props.onSubmit).not.toHaveBeenCalled(); @@ -1304,13 +1304,17 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , ); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Simulate a paste operation (this sets the protection) await act(async () => { stdin.write('\x1b[200~pasted text\x1b[201~'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Advance timers past the protection timeout await act(async () => { @@ -1321,7 +1325,9 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); expect(props.onSubmit).toHaveBeenCalledWith('pasted text'); expect(props.buffer.newline).not.toHaveBeenCalled(); @@ -1349,19 +1355,25 @@ describe('InputPrompt', () => { , { kittyProtocolEnabled: true }, ); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Simulate a paste operation await act(async () => { stdin.write('\x1b[200~some pasted stuff\x1b[201~'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Simulate an Enter key press immediately after paste await act(async () => { stdin.write('\r'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Verify that onSubmit was called expect(props.onSubmit).toHaveBeenCalledWith('pasted command'); @@ -1376,13 +1388,17 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders( , ); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Press Enter without any recent paste await act(async () => { stdin.write('\r'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); // Verify that onSubmit was called normally expect(props.onSubmit).toHaveBeenCalledWith('normal command'); @@ -1404,17 +1420,17 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B'); - }); - await vi.waitFor(() => { - expect(onEscapePromptChange).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(onEscapePromptChange).toHaveBeenCalledWith(false); + }); }); await act(async () => { stdin.write('\x1B'); - }); - await vi.waitFor(() => { - expect(props.buffer.setText).toHaveBeenCalledWith(''); - expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + await waitFor(() => { + expect(props.buffer.setText).toHaveBeenCalledWith(''); + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + }); }); unmount(); }); @@ -1431,18 +1447,16 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B'); - }); - - await vi.waitFor(() => { - expect(onEscapePromptChange).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(onEscapePromptChange).toHaveBeenCalledWith(false); + }); }); await act(async () => { stdin.write('a'); - }); - - await vi.waitFor(() => { - expect(onEscapePromptChange).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(onEscapePromptChange).toHaveBeenCalledWith(false); + }); }); unmount(); }); @@ -1457,10 +1471,10 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B'); + await waitFor(() => + expect(props.setShellModeActive).toHaveBeenCalledWith(false), + ); }); - await vi.waitFor(() => - expect(props.setShellModeActive).toHaveBeenCalledWith(false), - ); unmount(); }); @@ -1479,7 +1493,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x1B'); }); - await vi.waitFor(() => + await waitFor(() => expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(), ); unmount(); @@ -1494,12 +1508,16 @@ describe('InputPrompt', () => { , { kittyProtocolEnabled: false }, ); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); await act(async () => { stdin.write('\x1B'); }); - await vi.runAllTimersAsync(); + await act(async () => { + await vi.runAllTimersAsync(); + }); vi.useRealTimers(); unmount(); @@ -1514,12 +1532,12 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x0C'); }); - await vi.waitFor(() => expect(props.onClearScreen).toHaveBeenCalled()); + await waitFor(() => expect(props.onClearScreen).toHaveBeenCalled()); await act(async () => { stdin.write('\x01'); }); - await vi.waitFor(() => + await waitFor(() => expect(props.buffer.move).toHaveBeenCalledWith('home'), ); unmount(); @@ -1561,7 +1579,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame(); expect(frame).toContain('(r:)'); expect(frame).toContain('echo hello'); @@ -1580,7 +1598,6 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x12'); }); - await vi.waitFor(() => {}); await act(async () => { stdin.write('\x1B'); }); @@ -1588,12 +1605,11 @@ describe('InputPrompt', () => { stdin.write('\u001b[27u'); // Press kitty escape key }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).not.toContain('(r:)'); + expect(stdout.lastFrame()).not.toContain('echo hello'); }); - expect(stdout.lastFrame()).not.toContain('echo hello'); - unmount(); }); @@ -1629,7 +1645,7 @@ describe('InputPrompt', () => { }); // Verify reverse search is active - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).toContain('(r:)'); }); @@ -1637,7 +1653,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\t'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleAutocomplete).toHaveBeenCalledWith(0); expect(props.buffer.setText).toHaveBeenCalledWith('echo hello'); }); @@ -1665,7 +1681,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).toContain('(r:)'); }); @@ -1673,7 +1689,7 @@ describe('InputPrompt', () => { stdin.write('\r'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).not.toContain('(r:)'); }); @@ -1708,7 +1724,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).toContain('(r:)'); }); @@ -1717,7 +1733,7 @@ describe('InputPrompt', () => { stdin.write('\u001b[27u'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).not.toContain('(r:)'); expect(props.buffer.text).toBe(initialText); expect(props.buffer.cursor).toEqual(initialCursor); @@ -1740,7 +1756,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x05'); // Ctrl+E }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.buffer.move).toHaveBeenCalledWith('end'); }); expect(props.buffer.moveToOffset).not.toHaveBeenCalled(); @@ -1759,7 +1775,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x05'); // Ctrl+E }); - await vi.waitFor(() => { + await waitFor(() => { expect(props.buffer.move).toHaveBeenCalledWith('end'); }); expect(props.buffer.moveToOffset).not.toHaveBeenCalled(); @@ -1793,7 +1809,7 @@ describe('InputPrompt', () => { stdin.write('\x12'); // Ctrl+R }); - await vi.waitFor(() => { + await waitFor(() => { const frame = stdout.lastFrame() ?? ''; expect(frame).toContain('(r:)'); expect(frame).toContain('git commit'); @@ -1822,14 +1838,14 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(clean(stdout.lastFrame())).toContain('→'); }); await act(async () => { stdin.write('\u001B[C'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(clean(stdout.lastFrame())).toContain('←'); }); expect(stdout.lastFrame()).toMatchSnapshot( @@ -1839,7 +1855,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[D'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(clean(stdout.lastFrame())).toContain('→'); }); expect(stdout.lastFrame()).toMatchSnapshot( @@ -1871,7 +1887,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).toMatchSnapshot( 'command-search-render-collapsed-match', ); @@ -1880,7 +1896,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[C'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).toMatchSnapshot( 'command-search-render-expanded-match', ); @@ -1909,7 +1925,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\x12'); }); - await vi.waitFor(() => { + await waitFor(() => { const frame = clean(stdout.lastFrame()); // Ensure it rendered the search mode expect(frame).toContain('(r:)'); @@ -1933,7 +1949,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; await act(async () => { @@ -1957,7 +1973,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => + await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled(), ); expect(mockPopAllMessages).not.toHaveBeenCalled(); @@ -1976,7 +1992,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; await act(async () => { callback(undefined); @@ -2002,7 +2018,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); unmount(); }); @@ -2018,7 +2034,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; await act(async () => { @@ -2041,7 +2057,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); unmount(); }); @@ -2056,7 +2072,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => + await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled(), ); unmount(); @@ -2074,7 +2090,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\u001B[A'); }); - await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); + await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; await act(async () => { @@ -2094,7 +2110,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders( , ); - await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); + await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); unmount(); }); @@ -2103,7 +2119,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders( , ); - await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); + await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); unmount(); }); @@ -2112,7 +2128,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders( , ); - await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); + await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot()); unmount(); }); @@ -2122,7 +2138,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders( , ); - await vi.waitFor(() => { + await waitFor(() => { expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`); // This snapshot is good to make sure there was an input prompt but does // not show the inverted cursor because snapshots do not show colors. @@ -2140,7 +2156,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('a'); }); - await vi.waitFor(() => expect(mockBuffer.handleInput).toHaveBeenCalled()); + await waitFor(() => expect(mockBuffer.handleInput).toHaveBeenCalled()); unmount(); }); describe('command queuing while streaming', () => { @@ -2184,7 +2200,7 @@ describe('InputPrompt', () => { await act(async () => { stdin.write('\r'); }); - await vi.waitFor(() => { + await waitFor(() => { if (shouldSubmit) { expect(props.onSubmit).toHaveBeenCalledWith(bufferText); expect(props.setQueueErrorMessage).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 1d1e89ba7f..db7a410e23 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StreamingContext } from '../contexts/StreamingContext.js'; @@ -96,11 +96,12 @@ describe('', () => { currentLoadingPhrase: 'Processing data...', elapsedTime: 3, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('Processing data...'); + unmount(); }); it('should display the elapsedTime correctly when Responding', () => { @@ -108,11 +109,12 @@ describe('', () => { currentLoadingPhrase: 'Working...', elapsedTime: 60, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('(esc to cancel, 1m)'); + unmount(); }); it('should display the elapsedTime correctly in human-readable format', () => { @@ -120,24 +122,26 @@ describe('', () => { currentLoadingPhrase: 'Working...', elapsedTime: 125, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('(esc to cancel, 2m 5s)'); + unmount(); }); it('should render rightContent when provided', () => { const rightContent = Extra Info; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); expect(lastFrame()).toContain('Extra Info'); + unmount(); }); it('should transition correctly between states using rerender', () => { - const { lastFrame, rerender } = renderWithContext( + const { lastFrame, rerender, unmount } = renderWithContext( , StreamingState.Idle, ); @@ -179,6 +183,7 @@ describe('', () => { , ); expect(lastFrame()).toBe(''); + unmount(); }); it('should display fallback phrase if thought is empty', () => { @@ -187,12 +192,13 @@ describe('', () => { currentLoadingPhrase: 'Loading...', elapsedTime: 5, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toContain('Loading...'); + unmount(); }); it('should display the subject of a thought', () => { @@ -203,7 +209,7 @@ describe('', () => { }, elapsedTime: 5, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); @@ -213,6 +219,7 @@ describe('', () => { expect(output).toContain('Thinking about something...'); expect(output).not.toContain('and other stuff.'); } + unmount(); }); it('should prioritize thought.subject over currentLoadingPhrase', () => { @@ -224,17 +231,18 @@ describe('', () => { currentLoadingPhrase: 'This should not be displayed', elapsedTime: 5, }; - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, ); const output = lastFrame(); expect(output).toContain('This should be displayed'); expect(output).not.toContain('This should not be displayed'); + unmount(); }); it('should truncate long primary text instead of wrapping', () => { - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( ', () => { ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); describe('responsive layout', () => { it('should render on a single line on a wide terminal', () => { - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( Right} @@ -264,10 +273,11 @@ describe('', () => { 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 } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( Right} @@ -288,24 +298,27 @@ describe('', () => { expect(lines[1]).toContain('(esc to cancel, 5s)'); expect(lines[2]).toContain('Right'); } + unmount(); }); it('should use wide layout at 80 columns', () => { - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, 80, ); expect(lastFrame()?.includes('\n')).toBe(false); + unmount(); }); it('should use narrow layout at 79 columns', () => { - const { lastFrame } = renderWithContext( + const { lastFrame, unmount } = renderWithContext( , StreamingState.Responding, 79, ); expect(lastFrame()?.includes('\n')).toBe(true); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 1bcfb5c75f..752106ecce 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render, cleanup } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { cleanup } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { DEFAULT_GEMINI_FLASH_LITE_MODEL, @@ -80,16 +81,17 @@ describe('', () => { }); it('renders the title and help text', () => { - const { lastFrame } = renderComponent(); + const { lastFrame, unmount } = renderComponent(); expect(lastFrame()).toContain('Select Model'); expect(lastFrame()).toContain('(Press Esc to close)'); expect(lastFrame()).toContain( '> To use a specific Gemini model, use the --model flag.', ); + unmount(); }); it('passes all model options to DescriptiveRadioButtonSelect', () => { - renderComponent(); + const { unmount } = renderComponent(); expect(mockedSelect).toHaveBeenCalledTimes(1); const props = mockedSelect.mock.calls[0][0]; @@ -99,11 +101,12 @@ describe('', () => { expect(props.items[2].value).toBe(DEFAULT_GEMINI_FLASH_MODEL); expect(props.items[3].value).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); expect(props.showNumbers).toBe(true); + unmount(); }); it('initializes with the model from ConfigContext', () => { const mockGetModel = vi.fn(() => DEFAULT_GEMINI_FLASH_MODEL); - renderComponent({}, { getModel: mockGetModel }); + const { unmount } = renderComponent({}, { getModel: mockGetModel }); expect(mockGetModel).toHaveBeenCalled(); expect(mockedSelect).toHaveBeenCalledWith( @@ -112,10 +115,11 @@ describe('', () => { }), undefined, ); + unmount(); }); it('initializes with "auto" model if context is not provided', () => { - renderComponent({}, undefined); + const { unmount } = renderComponent({}, undefined); expect(mockedSelect).toHaveBeenCalledWith( expect.objectContaining({ @@ -123,13 +127,14 @@ describe('', () => { }), undefined, ); + unmount(); }); it('initializes with "auto" model if getModel returns undefined', () => { const mockGetModel = vi.fn(() => undefined); // @ts-expect-error This test validates component robustness when getModel // returns an unexpected undefined value. - renderComponent({}, { getModel: mockGetModel }); + const { unmount } = renderComponent({}, { getModel: mockGetModel }); expect(mockGetModel).toHaveBeenCalled(); @@ -142,10 +147,11 @@ describe('', () => { undefined, ); expect(mockedSelect).toHaveBeenCalledTimes(1); + unmount(); }); it('calls config.setModel and onClose when DescriptiveRadioButtonSelect.onSelect is triggered', () => { - const { props, mockConfig } = renderComponent({}, {}); // Pass empty object for contextValue + const { props, mockConfig, unmount } = renderComponent({}, {}); // Pass empty object for contextValue const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; expect(childOnSelect).toBeDefined(); @@ -155,17 +161,19 @@ describe('', () => { // Assert against the default mock provided by renderComponent expect(mockConfig?.setModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL); expect(props.onClose).toHaveBeenCalledTimes(1); + unmount(); }); it('does not pass onHighlight to DescriptiveRadioButtonSelect', () => { - renderComponent(); + const { unmount } = renderComponent(); const childOnHighlight = mockedSelect.mock.calls[0][0].onHighlight; expect(childOnHighlight).toBeUndefined(); + unmount(); }); it('calls onClose prop when "escape" key is pressed', () => { - const { props } = renderComponent(); + const { props, unmount } = renderComponent(); expect(mockedUseKeypress).toHaveBeenCalled(); @@ -193,11 +201,12 @@ describe('', () => { sequence: '', }); expect(props.onClose).toHaveBeenCalledTimes(1); + unmount(); }); it('updates initialIndex when config context changes', () => { const mockGetModel = vi.fn(() => DEFAULT_GEMINI_MODEL_AUTO); - const { rerender } = render( + const { rerender, unmount } = render( @@ -219,5 +228,6 @@ describe('', () => { // Should be called at least twice: initial render + re-render after context change expect(mockedSelect).toHaveBeenCalledTimes(2); expect(mockedSelect.mock.calls[1][0].initialIndex).toBe(3); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index e0072b8fa4..12493d9846 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index 4cf24614fa..bc065ded42 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import { act } from 'react'; @@ -68,7 +69,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Modify Trust Level'); expect(lastFrame()).toContain('Folder: /test/dir'); expect(lastFrame()).toContain('Current Level: DO_NOT_TRUST'); @@ -90,7 +91,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain( 'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.', ); @@ -112,7 +113,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain( 'Note: This folder behaves as a trusted folder because the connected IDE workspace is trusted.', ); @@ -124,7 +125,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Trust this folder (dir)'); expect(lastFrame()).toContain('Trust parent folder (test)'); }); @@ -136,13 +137,13 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => { stdin.write('\u001b[27u'); // Kitty escape key }); - await vi.waitFor(() => { + await waitFor(() => { expect(onExit).toHaveBeenCalled(); }); }); @@ -167,11 +168,11 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => stdin.write('r')); // Press 'r' to restart - await vi.waitFor(() => { + await waitFor(() => { expect(mockCommitTrustLevelChange).toHaveBeenCalled(); expect(mockRelaunchApp).toHaveBeenCalled(); expect(onExit).toHaveBeenCalled(); @@ -197,11 +198,11 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => stdin.write('\u001b[27u')); // Press kitty escape key - await vi.waitFor(() => { + await waitFor(() => { expect(mockCommitTrustLevelChange).not.toHaveBeenCalled(); expect(onExit).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/components/PrepareLabel.test.tsx b/packages/cli/src/ui/components/PrepareLabel.test.tsx index d6e004def8..c34090661c 100644 --- a/packages/cli/src/ui/components/PrepareLabel.test.tsx +++ b/packages/cli/src/ui/components/PrepareLabel.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js'; describe('PrepareLabel', () => { @@ -13,7 +13,7 @@ describe('PrepareLabel', () => { const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, ''); it('renders plain label when no match (short label)', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('truncates long label when collapsed and no match', () => { const long = 'x'.repeat(MAX_WIDTH + 25); - const { lastFrame } = render( + const { lastFrame, unmount } = render( { expect(f.endsWith('...')).toBe(true); expect(f.length).toBe(MAX_WIDTH + 3); expect(out).toMatchSnapshot(); + unmount(); }); it('shows full long label when expanded and no match', () => { const long = 'y'.repeat(MAX_WIDTH + 25); - const { lastFrame } = render( + const { lastFrame, unmount } = render( { const f = flat(out); expect(f.length).toBe(long.length); expect(out).toMatchSnapshot(); + unmount(); }); it('highlights matched substring when expanded (text only visible)', () => { const label = 'run: git commit -m "feat: add search"'; const userInput = 'commit'; const matchedIndex = label.indexOf(userInput); - const { lastFrame } = render( + const { lastFrame, unmount } = render( { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('creates centered window around match when collapsed', () => { @@ -80,7 +84,7 @@ describe('PrepareLabel', () => { const suffix = '/and/then/some/more/components/'.repeat(3); const label = prefix + core + suffix; const matchedIndex = prefix.length; - const { lastFrame } = render( + const { lastFrame, unmount } = render( { expect(f.startsWith('...')).toBe(true); expect(f.endsWith('...')).toBe(true); expect(out).toMatchSnapshot(); + unmount(); }); it('truncates match itself when match is very long', () => { @@ -103,7 +108,7 @@ describe('PrepareLabel', () => { const suffix = ' in this text'; const label = prefix + core + suffix; const matchedIndex = prefix.length; - const { lastFrame } = render( + const { lastFrame, unmount } = render( { expect(f.endsWith('...')).toBe(true); expect(f.length).toBe(MAX_WIDTH + 2); expect(out).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx index 0670af5ebc..da8b1a1463 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { act } from 'react'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; @@ -20,7 +21,7 @@ describe('ProQuotaDialog', () => { }); it('should render with correct title and options', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( { }), undefined, ); + unmount(); }); it('should call onChoice with "auth" when "Change auth" is selected', () => { const mockOnChoice = vi.fn(); - render( + const { unmount } = render( { const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; // Simulate the selection - onSelect('auth'); + act(() => { + onSelect('auth'); + }); expect(mockOnChoice).toHaveBeenCalledWith('auth'); + unmount(); }); it('should call onChoice with "continue" when "Continue with flash" is selected', () => { const mockOnChoice = vi.fn(); - render( + const { unmount } = render( { const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect; // Simulate the selection - onSelect('continue'); + act(() => { + onSelect('continue'); + }); expect(mockOnChoice).toHaveBeenCalledWith('continue'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx index 8c5f026495..7829858b9a 100644 --- a/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx +++ b/packages/cli/src/ui/components/QueuedMessageDisplay.test.tsx @@ -5,24 +5,28 @@ */ import { describe, it, expect } from 'vitest'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; describe('QueuedMessageDisplay', () => { it('renders nothing when message queue is empty', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); expect(lastFrame()).toBe(''); + unmount(); }); it('displays single queued message', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); const output = lastFrame(); expect(output).toContain('Queued (press ↑ to edit):'); expect(output).toContain('First message'); + unmount(); }); it('displays multiple queued messages', () => { @@ -32,7 +36,7 @@ describe('QueuedMessageDisplay', () => { 'Third queued message', ]; - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); @@ -41,6 +45,7 @@ describe('QueuedMessageDisplay', () => { expect(output).toContain('First queued message'); expect(output).toContain('Second queued message'); expect(output).toContain('Third queued message'); + unmount(); }); it('shows overflow indicator when more than 3 messages are queued', () => { @@ -52,7 +57,7 @@ describe('QueuedMessageDisplay', () => { 'Message 5', ]; - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); @@ -64,17 +69,19 @@ describe('QueuedMessageDisplay', () => { expect(output).toContain('... (+2 more)'); expect(output).not.toContain('Message 4'); expect(output).not.toContain('Message 5'); + unmount(); }); it('normalizes whitespace in messages', () => { const messageQueue = ['Message with\tmultiple\n whitespace']; - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); const output = lastFrame(); expect(output).toContain('Queued (press ↑ to edit):'); expect(output).toContain('Message with multiple whitespace'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index c5ea398dee..8d7072d94e 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 4baa2825df..501e14e85c 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -21,7 +21,8 @@ * */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; import { LoadedSettings, SettingScope } from '../../config/settings.js'; @@ -321,7 +322,7 @@ describe('SettingsDialog', () => { stdin.write(down as string); }); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Disable Auto Update'); }); @@ -330,7 +331,7 @@ describe('SettingsDialog', () => { stdin.write(up as string); }); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -348,7 +349,7 @@ describe('SettingsDialog', () => { stdin.write(TerminalKeys.UP_ARROW); }); - await vi.waitFor(() => { + await waitFor(() => { // Should wrap to last setting (without relying on exact bullet character) expect(lastFrame()).toContain('Codebase Investigator Max Num Turns'); }); @@ -367,7 +368,7 @@ describe('SettingsDialog', () => { const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect); // Wait for initial render and verify we're on Vim Mode (first setting) - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -375,7 +376,7 @@ describe('SettingsDialog', () => { act(() => { stdin.write(TerminalKeys.DOWN_ARROW as string); }); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Disable Auto Update'); }); @@ -384,14 +385,14 @@ describe('SettingsDialog', () => { stdin.write(TerminalKeys.ENTER as string); }); // Wait for the setting change to be processed - await vi.waitFor(() => { + await waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); }); // Wait for the mock to be called - await vi.waitFor(() => { + await waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); @@ -439,7 +440,7 @@ describe('SettingsDialog', () => { stdin.write(TerminalKeys.ENTER as string); }); - await vi.waitFor(() => { + await waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); @@ -513,7 +514,7 @@ describe('SettingsDialog', () => { const { lastFrame, unmount } = renderDialog(settings, onSelect); // Wait for initial render - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -569,7 +570,7 @@ describe('SettingsDialog', () => { const { lastFrame, unmount } = renderDialog(settings, onSelect); // Wait for initial render - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Hide Window Title'); }); @@ -735,7 +736,7 @@ describe('SettingsDialog', () => { // Since we can't easily target specific settings, we test the general behavior // Should not show restart prompt initially - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).not.toContain( 'To see changes, Gemini CLI must be restarted', ); @@ -836,7 +837,7 @@ describe('SettingsDialog', () => { }); } - await vi.waitFor(() => { + await waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); @@ -928,7 +929,7 @@ describe('SettingsDialog', () => { const { lastFrame, unmount } = renderDialog(settings, onSelect); // Wait for initial render - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -978,7 +979,7 @@ describe('SettingsDialog', () => { const { lastFrame, unmount } = renderDialog(settings, onSelect); // Wait for initial render - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -1096,7 +1097,7 @@ describe('SettingsDialog', () => { stdin.write('\u001B'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(onSelect).toHaveBeenCalledWith(undefined, 'User'); }); @@ -1198,8 +1199,11 @@ describe('SettingsDialog', () => { userSettings: {}, systemSettings: {}, workspaceSettings: {}, - stdinActions: (stdin: { write: (data: string) => void }) => - stdin.write('\t'), + stdinActions: (stdin: { write: (data: string) => void }) => { + act(() => { + stdin.write('\t'); + }); + }, }, { name: 'accessibility settings enabled', diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index 9609bbf378..927b9c7615 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { StatsDisplay } from './StatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx index 0a2f81e858..7fd4b27ec1 100644 --- a/packages/cli/src/ui/components/ThemeDialog.test.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ThemeDialog } from './ThemeDialog.js'; import { LoadedSettings } from '../../config/settings.js'; @@ -126,7 +127,7 @@ describe('ThemeDialog Snapshots', () => { stdin.write('\x1b'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockOnCancel).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx index e8d588061e..435cb0bd30 100644 --- a/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToolStatsDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { ToolStatsDisplay } from './ToolStatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index b30599f965..4e56e5bde3 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -178,10 +178,10 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ -│ > Settings │ +│ Settings │ │ │ │ ▲ │ -│ ● Vim Mode false │ +│ Vim Mode false │ │ │ │ Disable Auto Update false │ │ │ @@ -200,10 +200,10 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ ▼ │ │ │ │ │ -│ Apply To │ -│ ● User Settings │ -│ Workspace Settings │ -│ System Settings │ +│ > Apply To │ +│ ● 1. User Settings │ +│ 2. Workspace Settings │ +│ 3. System Settings │ │ │ │ (Use Enter to select, Tab to change focus, Esc to close) │ │ │ diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx index 1c56a7326a..b6af674c1b 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import type { CompressionDisplayProps } from './CompressionMessage.js'; import { CompressionMessage } from './CompressionMessage.js'; import { CompressionStatus } from '@google/gemini-cli-core'; @@ -27,10 +27,11 @@ describe('', () => { describe('pending state', () => { it('renders pending message when compression is in progress', () => { const props = createCompressionProps({ isPending: true }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain('Compressing chat history'); + unmount(); }); }); @@ -42,13 +43,14 @@ describe('', () => { newTokenCount: 50, compressionStatus: CompressionStatus.COMPRESSED, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain('✦'); expect(output).toContain( 'Chat history compressed from 100 to 50 tokens.', ); + unmount(); }); it('renders success message for large successful compressions', () => { @@ -57,14 +59,16 @@ describe('', () => { { original: 700000, new: 350000 }, // Very large compression ]; - testCases.forEach(({ original, new: newTokens }) => { + for (const { original, new: newTokens } of testCases) { const props = createCompressionProps({ isPending: false, originalTokenCount: original, newTokenCount: newTokens, compressionStatus: CompressionStatus.COMPRESSED, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); const output = lastFrame(); expect(output).toContain('✦'); @@ -73,7 +77,8 @@ describe('', () => { ); expect(output).not.toContain('Skipping compression'); expect(output).not.toContain('did not reduce size'); - }); + unmount(); + } }); }); @@ -86,13 +91,14 @@ describe('', () => { compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain('✦'); expect(output).toContain( 'Compression was not beneficial for this history size.', ); + unmount(); }); it('renders skip message when token counts are equal', () => { @@ -103,12 +109,13 @@ describe('', () => { compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); const output = lastFrame(); expect(output).toContain( 'Compression was not beneficial for this history size.', ); + unmount(); }); }); @@ -132,18 +139,21 @@ describe('', () => { }, ]; - testCases.forEach(({ original, new: newTokens, expected }) => { + for (const { original, new: newTokens, expected } of testCases) { const props = createCompressionProps({ isPending: false, originalTokenCount: original, newTokenCount: newTokens, compressionStatus: CompressionStatus.COMPRESSED, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); const output = lastFrame(); expect(output).toContain(expected); - }); + unmount(); + } }); it('shows skip message for small histories when new tokens >= original tokens', () => { @@ -153,7 +163,7 @@ describe('', () => { { original: 49999, new: 50000 }, // Just under 50k threshold ]; - testCases.forEach(({ original, new: newTokens }) => { + for (const { original, new: newTokens } of testCases) { const props = createCompressionProps({ isPending: false, originalTokenCount: original, @@ -161,14 +171,17 @@ describe('', () => { compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); const output = lastFrame(); expect(output).toContain( 'Compression was not beneficial for this history size.', ); expect(output).not.toContain('compressed from'); - }); + unmount(); + } }); it('shows compression failure message for large histories when new tokens >= original tokens', () => { @@ -178,7 +191,7 @@ describe('', () => { { original: 100000, new: 100000 }, // Large history, same count ]; - testCases.forEach(({ original, new: newTokens }) => { + for (const { original, new: newTokens } of testCases) { const props = createCompressionProps({ isPending: false, originalTokenCount: original, @@ -186,13 +199,16 @@ describe('', () => { compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }); - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); const output = lastFrame(); expect(output).toContain('compression did not reduce size'); expect(output).not.toContain('compressed from'); expect(output).not.toContain('Compression was not beneficial'); - }); + unmount(); + } }); }); }); diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx index 9629b94ba1..1ed5e36ab3 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.test.tsx @@ -5,7 +5,7 @@ */ import { OverflowProvider } from '../../contexts/OverflowContext.js'; -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { DiffRenderer } from './DiffRenderer.js'; import * as CodeColorizer from '../../utils/CodeColorizer.js'; import { vi } from 'vitest'; diff --git a/packages/cli/src/ui/components/messages/Todo.test.tsx b/packages/cli/src/ui/components/messages/Todo.test.tsx index bae8f694fe..85ea38750f 100644 --- a/packages/cli/src/ui/components/messages/Todo.test.tsx +++ b/packages/cli/src/ui/components/messages/Todo.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { Box } from 'ink'; import { TodoTray } from './Todo.js'; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index 69b246e9bb..1b09aa4d04 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { Text } from 'ink'; import type React from 'react'; @@ -98,10 +98,11 @@ describe('', () => { describe('Golden Snapshots', () => { it('renders single successful tool call', () => { const toolCalls = [createToolCall()]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders multiple tool calls with different statuses', () => { @@ -125,10 +126,11 @@ describe('', () => { status: ToolCallStatus.Error, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders tool call awaiting confirmation', () => { @@ -146,10 +148,11 @@ describe('', () => { }, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders shell command with yellow border', () => { @@ -161,10 +164,11 @@ describe('', () => { status: ToolCallStatus.Success, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders mixed tool calls including shell command', () => { @@ -188,10 +192,11 @@ describe('', () => { status: ToolCallStatus.Pending, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders with limited terminal height', () => { @@ -210,7 +215,7 @@ describe('', () => { resultDisplay: 'More output here', }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( ', () => { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders when not focused', () => { const toolCalls = [createToolCall()]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( ', () => { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders with narrow terminal width', () => { @@ -240,7 +247,7 @@ describe('', () => { 'This is a very long description that might cause wrapping issues', }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( ', () => { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders empty tool calls array', () => { - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); describe('Border Color Logic', () => { it('uses yellow border when tools are pending', () => { const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); // The snapshot will capture the visual appearance including border color expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('uses yellow border for shell commands even when successful', () => { @@ -275,10 +285,11 @@ describe('', () => { status: ToolCallStatus.Success, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('uses gray border when all tools are successful and no shell commands', () => { @@ -290,10 +301,11 @@ describe('', () => { status: ToolCallStatus.Success, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); @@ -313,7 +325,7 @@ describe('', () => { resultDisplay: '', // No result }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( ', () => { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); @@ -350,11 +363,12 @@ describe('', () => { }, }), ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , ); // Should only show confirmation for the first tool expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 9f9b9e60de..708de9f10a 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; import { BaseSelectionList, type BaseSelectionListProps, @@ -298,7 +299,7 @@ describe('BaseSelectionList', () => { rerender(); - await vi.waitFor(() => { + await waitFor(() => { expect(lastFrame()).toBeTruthy(); }); }; @@ -322,7 +323,7 @@ describe('BaseSelectionList', () => { // New visible window should be Items 2, 3, 4 (scroll offset 1). await updateActiveIndex(3); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).not.toContain('Item 1'); expect(output).toContain('Item 2'); @@ -336,7 +337,7 @@ describe('BaseSelectionList', () => { await updateActiveIndex(4); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 3'); // Should see items 3, 4, 5 expect(output).toContain('Item 5'); @@ -347,7 +348,7 @@ describe('BaseSelectionList', () => { // This should trigger scroll up to show items 2, 3, 4 await updateActiveIndex(1); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 2'); expect(output).toContain('Item 4'); @@ -361,7 +362,7 @@ describe('BaseSelectionList', () => { // Visible items: 8, 9, 10. const { lastFrame } = renderScrollableList(9); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 10'); expect(output).toContain('Item 8'); @@ -380,14 +381,14 @@ describe('BaseSelectionList', () => { expect(lastFrame()).toContain('Item 1'); await updateActiveIndex(3); // Should trigger scroll - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 2'); expect(output).toContain('Item 4'); expect(output).not.toContain('Item 1'); }); await updateActiveIndex(5); // Scroll further - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 4'); expect(output).toContain('Item 6'); @@ -414,7 +415,7 @@ describe('BaseSelectionList', () => { it('should correctly identify the selected item when scrolled (high index)', async () => { renderScrollableList(5); - await vi.waitFor(() => { + await waitFor(() => { // Item 6 (index 5) should be selected expect(mockRenderItem).toHaveBeenCalledWith( expect.objectContaining({ value: 'Item 6' }), @@ -472,7 +473,7 @@ describe('BaseSelectionList', () => { 0, ); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); // At the top, should show first 3 items expect(output).toContain('Item 1'); @@ -490,7 +491,7 @@ describe('BaseSelectionList', () => { 5, ); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); // After scrolling to middle, should see items around index 5 expect(output).toContain('Item 4'); @@ -509,7 +510,7 @@ describe('BaseSelectionList', () => { 9, ); - await vi.waitFor(() => { + await waitFor(() => { const output = lastFrame(); // At the end, should show last 3 items expect(output).toContain('Item 8'); diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx index 92147d3c59..6312e4816a 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js'; import { Box, Text } from 'ink'; @@ -18,7 +18,7 @@ describe('', () => { setMaxSizedBoxDebugging(true); it('renders children without truncation when they fit', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -28,10 +28,11 @@ describe('', () => { , ); expect(lastFrame()).equals('Hello, World!'); + unmount(); }); it('hides lines when content exceeds maxHeight', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -48,10 +49,11 @@ describe('', () => { ); expect(lastFrame()).equals(`... first 2 lines hidden ... Line 3`); + unmount(); }); it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -68,10 +70,11 @@ Line 3`); ); expect(lastFrame()).equals(`Line 1 ... last 2 lines hidden ...`); + unmount(); }); it('wraps text that exceeds maxWidth', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -84,13 +87,14 @@ Line 3`); expect(lastFrame()).equals(`This is a long line of text`); + unmount(); }); it('handles mixed wrapping and non-wrapping segments', () => { const multilineText = `This part will wrap around. And has a line break. Leading spaces preserved.`; - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -125,10 +129,11 @@ Longer No Wrap: This arou nd.`, ); + unmount(); }); it('handles words longer than maxWidth by splitting them', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -143,10 +148,11 @@ istic expia lidoc ious`); + unmount(); }); it('does not truncate when maxHeight is undefined', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -160,10 +166,11 @@ ious`); ); expect(lastFrame()).equals(`Line 1 Line 2`); + unmount(); }); it('shows plural "lines" when more than one line is hidden', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -180,10 +187,11 @@ Line 2`); ); expect(lastFrame()).equals(`... first 2 lines hidden ... Line 3`); + unmount(); }); it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -200,10 +208,11 @@ Line 3`); ); expect(lastFrame()).equals(`Line 1 ... last 2 lines hidden ...`); + unmount(); }); it('renders an empty box for empty children', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , @@ -211,10 +220,11 @@ Line 3`); // Expect an empty string or a box with nothing in it. // Ink renders an empty box as an empty string. expect(lastFrame()).equals(''); + unmount(); }); it('wraps text with multi-byte unicode characters correctly', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -228,10 +238,11 @@ Line 3`); // With maxWidth=5, it should wrap after the second character. expect(lastFrame()).equals(`你好 世界`); + unmount(); }); it('wraps text with multi-byte emoji characters correctly', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -246,10 +257,11 @@ Line 3`); expect(lastFrame()).equals(`🐶🐶 🐶🐶 🐶`); + unmount(); }); it('falls back to an ellipsis when width is extremely small', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -261,10 +273,11 @@ Line 3`); ); expect(lastFrame()).equals('N…'); + unmount(); }); it('truncates long non-wrapping text with ellipsis', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -276,10 +289,11 @@ Line 3`); ); expect(lastFrame()).equals('AB…'); + unmount(); }); it('truncates non-wrapping text containing line breaks', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -291,10 +305,11 @@ Line 3`); ); expect(lastFrame()).equals(`A\n…`); + unmount(); }); it('truncates emoji characters correctly with ellipsis', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -306,10 +321,11 @@ Line 3`); ); expect(lastFrame()).equals(`🐶…`); + unmount(); }); it('shows ellipsis for multiple rows with long non-wrapping text', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -329,10 +345,11 @@ Line 3`); ); expect(lastFrame()).equals(`AA…\nBB…\nCC…`); + unmount(); }); it('accounts for additionalHiddenLinesCount', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -350,10 +367,11 @@ Line 3`); // 1 line is hidden by overflow, 5 are additionally hidden. expect(lastFrame()).equals(`... first 7 lines hidden ... Line 3`); + unmount(); }); it('handles React.Fragment as a child', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( <> @@ -373,6 +391,7 @@ Line 3`); expect(lastFrame()).equals(`Line 1 from Fragment Line 2 from Fragment Line 3 direct child`); + unmount(); }); it('clips a long single text child from the top', () => { @@ -381,7 +400,7 @@ Line 3 direct child`); (_, i) => `Line ${i + 1}`, ).join('\n'); - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -397,6 +416,7 @@ Line 3 direct child`); ].join('\n'); expect(lastFrame()).equals(expected); + unmount(); }); it('clips a long single text child from the bottom', () => { @@ -405,7 +425,7 @@ Line 3 direct child`); (_, i) => `Line ${i + 1}`, ).join('\n'); - const { lastFrame } = render( + const { lastFrame, unmount } = render( @@ -421,5 +441,6 @@ Line 3 direct child`); ].join('\n'); expect(lastFrame()).equals(expected); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/shared/TextInput.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index 49491b2db3..7af09fe7a9 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { TextInput } from './TextInput.js'; import { useKeypress } from '../../hooks/useKeypress.js'; diff --git a/packages/cli/src/ui/components/views/ChatList.test.tsx b/packages/cli/src/ui/components/views/ChatList.test.tsx index f55aa8b611..9d42da7de2 100644 --- a/packages/cli/src/ui/components/views/ChatList.test.tsx +++ b/packages/cli/src/ui/components/views/ChatList.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { ChatList } from './ChatList.js'; import type { ChatDetail } from '../../types.js'; @@ -22,14 +22,16 @@ const mockChats: ChatDetail[] = [ describe('', () => { it('renders correctly with a list of chats', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with no chats', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); expect(lastFrame()).toContain('No saved conversation checkpoints found.'); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('handles invalid date formats gracefully', () => { @@ -39,8 +41,11 @@ describe('', () => { mtime: 'an-invalid-date-string', }, ]; - const { lastFrame } = render(); + const { lastFrame, unmount } = render( + , + ); expect(lastFrame()).toContain('(Invalid Date)'); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx index fcb2320dcf..8c841991ce 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { vi, describe, beforeEach, it, expect } from 'vitest'; import { useUIState } from '../../contexts/UIStateContext.js'; import { ExtensionUpdateState } from '../../state/extensions.js'; @@ -57,27 +57,30 @@ describe('', () => { it('should render "No extensions installed." if there are no extensions', () => { mockUIState(new Map()); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); expect(lastFrame()).toContain('No extensions installed.'); + unmount(); }); it('should render a list of extensions with their version and status', () => { mockUIState(new Map()); - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); const output = lastFrame(); expect(output).toContain('ext-one (v1.0.0) - active'); expect(output).toContain('ext-two (v2.1.0) - active'); expect(output).toContain('ext-disabled (v3.0.0) - disabled'); + unmount(); }); it('should display "unknown state" if an extension has no update state', () => { mockUIState(new Map()); - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toContain('(unknown state)'); + unmount(); }); const stateTestCases = [ @@ -115,10 +118,11 @@ describe('', () => { it(`should correctly display the state: ${state}`, () => { const updateState = new Map([[mockExtensions[0].name, state]]); mockUIState(updateState); - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toContain(expectedText); + unmount(); }); } }); diff --git a/packages/cli/src/ui/components/views/McpStatus.test.tsx b/packages/cli/src/ui/components/views/McpStatus.test.tsx index 4e5f238041..0fb37510b5 100644 --- a/packages/cli/src/ui/components/views/McpStatus.test.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../../test-utils/render.js'; import { describe, it, expect, vi } from 'vitest'; import { McpStatus } from './McpStatus.js'; import { MCPServerStatus } from '@google/gemini-cli-core'; @@ -46,32 +46,36 @@ describe('McpStatus', () => { }; it('renders correctly with a connected server', () => { - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with authenticated OAuth status', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with expired OAuth status', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with unauthenticated OAuth status', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with a disconnected server', async () => { @@ -79,26 +83,29 @@ describe('McpStatus', () => { await import('@google/gemini-cli-core'), 'getMCPServerStatus', ).mockReturnValue(MCPServerStatus.DISCONNECTED); - const { lastFrame } = render(); + const { lastFrame, unmount } = render(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly when discovery is in progress', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with schema enabled', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with parametersJsonSchema', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with prompts', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( { />, ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with a blocked server', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders correctly with a connecting server', () => { - const { lastFrame } = render( + const { lastFrame, unmount } = render( , ); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c369f489b6..24cc88b0b5 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import type { Mock } from 'vitest'; import { vi } from 'vitest'; import type { Key } from './KeypressContext.js'; @@ -275,7 +276,7 @@ describe('KeypressContext - Kitty Protocol', () => { act(() => writeSequence(pastedText)); - await vi.waitFor(() => { + await waitFor(() => { expect(keyHandler).toHaveBeenCalledTimes(1); }); @@ -1020,7 +1021,7 @@ describe('Kitty Sequence Parsing', () => { } // Should parse once complete - await vi.waitFor(() => { + await waitFor(() => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'escape', diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index b2602e3925..daa7dce0f1 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -5,7 +5,7 @@ */ import { type MutableRefObject, Component, type ReactNode } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { act } from 'react'; import type { SessionMetrics } from './SessionContext.js'; @@ -58,7 +58,7 @@ describe('SessionStatsContext', () => { ReturnType | undefined > = { current: undefined }; - render( + const { unmount } = render( , @@ -69,6 +69,7 @@ describe('SessionStatsContext', () => { expect(stats?.sessionStartTime).toBeInstanceOf(Date); expect(stats?.metrics).toBeDefined(); expect(stats?.metrics.models).toEqual({}); + unmount(); }); it('should update metrics when the uiTelemetryService emits an update', () => { @@ -76,7 +77,7 @@ describe('SessionStatsContext', () => { ReturnType | undefined > = { current: undefined }; - render( + const { unmount } = render( , @@ -142,6 +143,7 @@ describe('SessionStatsContext', () => { const stats = contextRef.current?.stats; expect(stats?.metrics).toEqual(newMetrics); expect(stats?.lastPromptTokenCount).toBe(100); + unmount(); }); it('should not update metrics if the data is the same', () => { @@ -156,7 +158,7 @@ describe('SessionStatsContext', () => { return null; }; - render( + const { unmount } = render( , @@ -228,6 +230,7 @@ describe('SessionStatsContext', () => { }); expect(renderCount).toBe(3); + unmount(); }); it('should throw an error when useSessionStats is used outside of a provider', () => { @@ -235,7 +238,7 @@ describe('SessionStatsContext', () => { // Suppress console.error from React for this test const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - render( + const { unmount } = render( , @@ -248,5 +251,6 @@ describe('SessionStatsContext', () => { ); consoleSpy.mockRestore(); + unmount(); }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 51bf95dbac..aac77e6a28 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -5,7 +5,8 @@ */ import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { vi, describe, @@ -18,13 +19,30 @@ import { const mockIsBinary = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); -vi.mock('@google/gemini-cli-core', () => ({ - ShellExecutionService: { execute: mockShellExecutionService }, - isBinary: mockIsBinary, -})); -vi.mock('fs'); -vi.mock('os'); -vi.mock('crypto'); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ShellExecutionService: { execute: mockShellExecutionService }, + isBinary: mockIsBinary, + }; +}); +vi.mock('node:fs'); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + const mocked = { + ...actual, + homedir: vi.fn(() => '/home/user'), + platform: vi.fn(() => 'linux'), + tmpdir: vi.fn(() => '/tmp'), + }; + return { + ...mocked, + default: mocked, + }; +}); +vi.mock('node:crypto'); vi.mock('../utils/textUtils.js'); import { @@ -134,7 +152,7 @@ describe('useShellCommandProcessor', () => { it('should initiate command execution and set pending state', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('ls -l', new AbortController().signal); }); @@ -233,7 +251,7 @@ describe('useShellCommandProcessor', () => { it('should update UI for text streams (non-interactive)', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand( 'stream', new AbortController().signal, @@ -256,7 +274,7 @@ describe('useShellCommandProcessor', () => { // Wait for the async PID update to happen. // Call 1: Initial, Call 2: PID update - await vi.waitFor(() => { + await waitFor(() => { expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); }); @@ -365,7 +383,7 @@ describe('useShellCommandProcessor', () => { vi.mocked(os.platform).mockReturnValue('win32'); const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('dir', new AbortController().signal); }); @@ -377,6 +395,11 @@ describe('useShellCommandProcessor', () => { false, expect.any(Object), ); + + await act(async () => { + resolveExecutionPromise(createMockServiceResult()); + }); + await act(async () => await onExecMock.mock.calls[0][0]); }); it('should handle command abort and display cancelled status', async () => { @@ -559,23 +582,21 @@ describe('useShellCommandProcessor', () => { it('should set activeShellPtyId when a command with a PID starts', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('ls', new AbortController().signal); }); - await vi.waitFor(() => { - expect(result.current.activeShellPtyId).toBe(12345); - }); + expect(result.current.activeShellPtyId).toBe(12345); }); it('should update the pending history item with the ptyId', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('ls', new AbortController().signal); }); - await vi.waitFor(() => { + await waitFor(() => { // Wait for the second call which is the functional update expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); }); @@ -594,16 +615,14 @@ describe('useShellCommandProcessor', () => { it('should reset activeShellPtyId to null after successful execution', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('ls', new AbortController().signal); }); const execPromise = onExecMock.mock.calls[0][0]; - await vi.waitFor(() => { - expect(result.current.activeShellPtyId).toBe(12345); - }); + expect(result.current.activeShellPtyId).toBe(12345); - act(() => { + await act(async () => { resolveExecutionPromise(createMockServiceResult()); }); await act(async () => await execPromise); @@ -614,7 +633,7 @@ describe('useShellCommandProcessor', () => { it('should reset activeShellPtyId to null after failed execution', async () => { const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand( 'bad-cmd', new AbortController().signal, @@ -622,11 +641,9 @@ describe('useShellCommandProcessor', () => { }); const execPromise = onExecMock.mock.calls[0][0]; - await vi.waitFor(() => { - expect(result.current.activeShellPtyId).toBe(12345); - }); + expect(result.current.activeShellPtyId).toBe(12345); - act(() => { + await act(async () => { resolveExecutionPromise(createMockServiceResult({ exitCode: 1 })); }); await act(async () => await execPromise); @@ -638,7 +655,7 @@ describe('useShellCommandProcessor', () => { let rejectResultPromise: (reason?: unknown) => void; mockShellExecutionService.mockImplementation(() => Promise.resolve({ - pid: 1234_5, + pid: 12345, result: new Promise((_, reject) => { rejectResultPromise = reject; }), @@ -646,16 +663,14 @@ describe('useShellCommandProcessor', () => { ); const { result } = renderProcessorHook(); - act(() => { + await act(async () => { result.current.handleShellCommand('cmd', new AbortController().signal); }); const execPromise = onExecMock.mock.calls[0][0]; - await vi.waitFor(() => { - expect(result.current.activeShellPtyId).toBe(12345); - }); + expect(result.current.activeShellPtyId).toBe(12345); - act(() => { + await act(async () => { rejectResultPromise(new Error('Failure')); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 6707bf3058..251998d96d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import type { CommandContext, @@ -15,7 +16,7 @@ import type { } from '../commands/types.js'; import { CommandKind } from '../commands/types.js'; import type { LoadedSettings } from '../../config/settings.js'; -import { MessageType } from '../types.js'; +import { MessageType, type SlashCommandProcessorResult } from '../types.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; @@ -38,6 +39,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...original, logSlashCommand, getIdeInstaller: vi.fn().mockReturnValue(null), + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + addStatusChangeListener: vi.fn(), + removeStatusChangeListener: vi.fn(), + }), + }, }; }); @@ -114,6 +121,8 @@ describe('useSlashCommandProcessor', () => { const mockConfig = makeFakeConfig({}); const mockSettings = {} as LoadedSettings; + let unmountHook: (() => Promise) | undefined; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(BuiltinCommandLoader).mockClear(); @@ -122,7 +131,14 @@ describe('useSlashCommandProcessor', () => { mockMcpLoadCommands.mockResolvedValue([]); }); - const setupProcessorHook = ( + afterEach(async () => { + if (unmountHook) { + await unmountHook(); + unmountHook = undefined; + } + }); + + const setupProcessorHook = async ( builtinCommands: SlashCommand[] = [], fileCommands: SlashCommand[] = [], mcpCommands: SlashCommand[] = [], @@ -132,54 +148,66 @@ describe('useSlashCommandProcessor', () => { mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands)); - let hookResult: ReturnType; + let result!: { current: ReturnType }; + let unmount!: () => void; + let rerender!: (props?: unknown) => void; - function TestComponent() { - hookResult = useSlashCommandProcessor( - mockConfig, - mockSettings, - mockAddItem, - mockClearItems, - mockLoadHistory, - vi.fn(), // refreshStatic - vi.fn(), // toggleVimEnabled - setIsProcessing, - vi.fn(), // setGeminiMdFileCount - { - openAuthDialog: mockOpenAuthDialog, - openThemeDialog: mockOpenThemeDialog, - openEditorDialog: vi.fn(), - openPrivacyNotice: vi.fn(), - openSettingsDialog: vi.fn(), - openModelDialog: mockOpenModelDialog, - openPermissionsDialog: vi.fn(), - quit: mockSetQuittingMessages, - setDebugMessage: vi.fn(), - toggleCorgiMode: vi.fn(), - toggleDebugProfiler: vi.fn(), - dispatchExtensionStateUpdate: vi.fn(), - addConfirmUpdateExtensionRequest: vi.fn(), - }, - new Map(), // extensionsUpdateState - true, // isConfigInitialized + await act(async () => { + const hook = renderHook(() => + useSlashCommandProcessor( + mockConfig, + mockSettings, + mockAddItem, + mockClearItems, + mockLoadHistory, + vi.fn(), // refreshStatic + vi.fn(), // toggleVimEnabled + setIsProcessing, + vi.fn(), // setGeminiMdFileCount + { + openAuthDialog: mockOpenAuthDialog, + openThemeDialog: mockOpenThemeDialog, + openEditorDialog: vi.fn(), + openPrivacyNotice: vi.fn(), + openSettingsDialog: vi.fn(), + openModelDialog: mockOpenModelDialog, + openPermissionsDialog: vi.fn(), + quit: mockSetQuittingMessages, + setDebugMessage: vi.fn(), + toggleCorgiMode: vi.fn(), + toggleDebugProfiler: vi.fn(), + dispatchExtensionStateUpdate: vi.fn(), + addConfirmUpdateExtensionRequest: vi.fn(), + }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized + ), ); - return null; - } + result = hook.result; + unmount = hook.unmount; + rerender = hook.rerender; + }); - const { unmount, rerender } = render(); + unmountHook = async () => unmount(); + + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); return { get current() { - return hookResult; + return result.current; }, unmount, - rerender: () => rerender(), + rerender: async () => { + rerender(); + }, }; }; describe('Initialization and Command Loading', () => { - it('should initialize CommandService with all required loaders', () => { - setupProcessorHook(); + it('should initialize CommandService with all required loaders', async () => { + await setupProcessorHook(); expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); @@ -187,9 +215,9 @@ describe('useSlashCommandProcessor', () => { it('should call loadCommands and populate state after mounting', async () => { const testCommand = createTestCommand({ name: 'test' }); - const result = setupProcessorHook([testCommand]); + const result = await setupProcessorHook([testCommand]); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.slashCommands).toHaveLength(1); }); @@ -201,9 +229,9 @@ describe('useSlashCommandProcessor', () => { it('should provide an immutable array of commands to consumers', async () => { const testCommand = createTestCommand({ name: 'test' }); - const result = setupProcessorHook([testCommand]); + const result = await setupProcessorHook([testCommand]); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.slashCommands).toHaveLength(1); }); @@ -229,9 +257,9 @@ describe('useSlashCommandProcessor', () => { CommandKind.FILE, ); - const result = setupProcessorHook([builtinCommand], [fileCommand]); + const result = await setupProcessorHook([builtinCommand], [fileCommand]); - await vi.waitFor(() => { + await waitFor(() => { // The service should only return one command with the name 'override' expect(result.current.slashCommands).toHaveLength(1); }); @@ -248,10 +276,8 @@ describe('useSlashCommandProcessor', () => { describe('Command Execution Logic', () => { it('should display an error for an unknown command', async () => { - const result = setupProcessorHook(); - await vi.waitFor(() => - expect(result.current.slashCommands).toBeDefined(), - ); + const result = await setupProcessorHook(); + await waitFor(() => expect(result.current.slashCommands).toBeDefined()); await act(async () => { await result.current.handleSlashCommand('/nonexistent'); @@ -281,10 +307,8 @@ describe('useSlashCommandProcessor', () => { }, ], }; - const result = setupProcessorHook([parentCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/parent'); @@ -317,10 +341,8 @@ describe('useSlashCommandProcessor', () => { }, ], }; - const result = setupProcessorHook([parentCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([parentCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/parent child with args'); @@ -343,7 +365,7 @@ describe('useSlashCommandProcessor', () => { it('sets isProcessing to false if the the input is not a command', async () => { const setMockIsProcessing = vi.fn(); - const result = setupProcessorHook([], [], [], setMockIsProcessing); + const result = await setupProcessorHook([], [], [], setMockIsProcessing); await act(async () => { await result.current.handleSlashCommand('imnotacommand'); @@ -359,16 +381,14 @@ describe('useSlashCommandProcessor', () => { action: vi.fn().mockRejectedValue(new Error('oh no!')), }); - const result = setupProcessorHook( + const result = await setupProcessorHook( [failCommand], [], [], setMockIsProcessing, ); - await vi.waitFor(() => - expect(result.current.slashCommands).toBeDefined(), - ); + await waitFor(() => expect(result.current.slashCommands).toBeDefined()); await act(async () => { await result.current.handleSlashCommand('/fail'); @@ -385,10 +405,13 @@ describe('useSlashCommandProcessor', () => { action: () => new Promise((resolve) => setTimeout(resolve, 50)), }); - const result = setupProcessorHook([command], [], [], mockSetIsProcessing); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), + const result = await setupProcessorHook( + [command], + [], + [], + mockSetIsProcessing, ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); const executionPromise = act(async () => { await result.current.handleSlashCommand('/long-running'); @@ -413,10 +436,8 @@ describe('useSlashCommandProcessor', () => { name: 'themecmd', action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }), }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/themecmd'); @@ -430,10 +451,8 @@ describe('useSlashCommandProcessor', () => { name: 'modelcmd', action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }), }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/modelcmd'); @@ -457,10 +476,8 @@ describe('useSlashCommandProcessor', () => { clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }], }), }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/load'); @@ -495,10 +512,8 @@ describe('useSlashCommandProcessor', () => { }), }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/loadwiththoughts'); @@ -516,11 +531,9 @@ describe('useSlashCommandProcessor', () => { name: 'exit', action: quitAction, }); - const result = setupProcessorHook([command]); + const result = await setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/exit'); @@ -541,10 +554,8 @@ describe('useSlashCommandProcessor', () => { CommandKind.FILE, ); - const result = setupProcessorHook([], [fileCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([], [fileCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); let actionResult; await act(async () => { @@ -575,10 +586,8 @@ describe('useSlashCommandProcessor', () => { CommandKind.MCP_PROMPT, ); - const result = setupProcessorHook([], [], [mcpCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([], [], [mcpCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); let actionResult; await act(async () => { @@ -619,20 +628,19 @@ describe('useSlashCommandProcessor', () => { }); it('should set confirmation request when action returns confirm_shell_commands', async () => { - const result = setupProcessorHook([shellCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([shellCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - // This is intentionally not awaited, because the promise it returns - // will not resolve until the user responds to the confirmation. - act(() => { - result.current.handleSlashCommand('/shellcmd'); + // Trigger command, don't await it yet as it suspends for confirmation + await act(async () => { + void result.current.handleSlashCommand('/shellcmd'); }); // We now wait for the state to be updated with the request. - await vi.waitFor(() => { - expect(result.current.shellConfirmationRequest).not.toBeNull(); + await act(async () => { + await waitFor(() => { + expect(result.current.shellConfirmationRequest).not.toBeNull(); + }); }); expect(result.current.shellConfirmationRequest?.commands).toEqual([ @@ -641,18 +649,18 @@ describe('useSlashCommandProcessor', () => { }); it('should do nothing if user cancels confirmation', async () => { - const result = setupProcessorHook([shellCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([shellCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - act(() => { - result.current.handleSlashCommand('/shellcmd'); + await act(async () => { + void result.current.handleSlashCommand('/shellcmd'); }); // Wait for the confirmation dialog to be set - await vi.waitFor(() => { - expect(result.current.shellConfirmationRequest).not.toBeNull(); + await act(async () => { + await waitFor(() => { + expect(result.current.shellConfirmationRequest).not.toBeNull(); + }); }); const onConfirm = result.current.shellConfirmationRequest?.onConfirm; @@ -676,16 +684,19 @@ describe('useSlashCommandProcessor', () => { }); it('should re-run command with one-time allowlist on "Proceed Once"', async () => { - const result = setupProcessorHook([shellCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([shellCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - act(() => { - result.current.handleSlashCommand('/shellcmd'); + let commandPromise: + | Promise + | undefined; + await act(async () => { + commandPromise = result.current.handleSlashCommand('/shellcmd'); }); - await vi.waitFor(() => { - expect(result.current.shellConfirmationRequest).not.toBeNull(); + await act(async () => { + await waitFor(() => { + expect(result.current.shellConfirmationRequest).not.toBeNull(); + }); }); const onConfirm = result.current.shellConfirmationRequest?.onConfirm; @@ -702,10 +713,14 @@ describe('useSlashCommandProcessor', () => { onConfirm!(ToolConfirmationOutcome.ProceedOnce, ['rm -rf /']); }); + await act(async () => { + await commandPromise; + }); + expect(result.current.shellConfirmationRequest).toBeNull(); // The action should have been called twice (initial + re-run). - await vi.waitFor(() => { + await waitFor(() => { expect(mockCommandAction).toHaveBeenCalledTimes(2); }); @@ -725,23 +740,26 @@ describe('useSlashCommandProcessor', () => { // Verify the session-wide allowlist was NOT permanently updated. // Re-render the hook by calling a no-op command to get the latest context. await act(async () => { - result.current.handleSlashCommand('/no-op'); + await result.current.handleSlashCommand('/no-op'); }); const finalContext = result.current.commandContext; expect(finalContext.session.sessionShellAllowlist.size).toBe(0); }); it('should re-run command and update session allowlist on "Proceed Always"', async () => { - const result = setupProcessorHook([shellCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([shellCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); - act(() => { - result.current.handleSlashCommand('/shellcmd'); + let commandPromise: + | Promise + | undefined; + await act(async () => { + commandPromise = result.current.handleSlashCommand('/shellcmd'); }); - await vi.waitFor(() => { - expect(result.current.shellConfirmationRequest).not.toBeNull(); + await act(async () => { + await waitFor(() => { + expect(result.current.shellConfirmationRequest).not.toBeNull(); + }); }); const onConfirm = result.current.shellConfirmationRequest?.onConfirm; @@ -755,8 +773,12 @@ describe('useSlashCommandProcessor', () => { onConfirm!(ToolConfirmationOutcome.ProceedAlways, ['rm -rf /']); }); + await act(async () => { + await commandPromise; + }); + expect(result.current.shellConfirmationRequest).toBeNull(); - await vi.waitFor(() => { + await waitFor(() => { expect(mockCommandAction).toHaveBeenCalledTimes(2); }); @@ -766,7 +788,7 @@ describe('useSlashCommandProcessor', () => { ); // Check that the session-wide allowlist WAS updated. - await vi.waitFor(() => { + await waitFor(() => { const finalContext = result.current.commandContext; expect(finalContext.session.sessionShellAllowlist.has('rm -rf /')).toBe( true, @@ -778,10 +800,8 @@ describe('useSlashCommandProcessor', () => { describe('Command Parsing and Matching', () => { it('should be case-sensitive', async () => { const command = createTestCommand({ name: 'test' }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { // Use uppercase when command is lowercase @@ -806,10 +826,8 @@ describe('useSlashCommandProcessor', () => { description: 'a command with an alias', action, }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('/alias'); @@ -824,10 +842,8 @@ describe('useSlashCommandProcessor', () => { it('should handle extra whitespace around the command', async () => { const action = vi.fn(); const command = createTestCommand({ name: 'test', action }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand(' /test with-args '); @@ -839,10 +855,8 @@ describe('useSlashCommandProcessor', () => { it('should handle `?` as a command prefix', async () => { const action = vi.fn(); const command = createTestCommand({ name: 'help', action }); - const result = setupProcessorHook([command]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(1), - ); + const result = await setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); await act(async () => { await result.current.handleSlashCommand('?help'); @@ -870,9 +884,9 @@ describe('useSlashCommandProcessor', () => { CommandKind.FILE, ); - const result = setupProcessorHook([], [fileCommand], [mcpCommand]); + const result = await setupProcessorHook([], [fileCommand], [mcpCommand]); - await vi.waitFor(() => { + await waitFor(() => { // The service should only return one command with the name 'override' expect(result.current.slashCommands).toHaveLength(1); }); @@ -906,9 +920,9 @@ describe('useSlashCommandProcessor', () => { // The order of commands in the final loaded array is not guaranteed, // so the test must work regardless of which comes first. - const result = setupProcessorHook([quitCommand], [exitCommand]); + const result = await setupProcessorHook([quitCommand], [exitCommand]); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.slashCommands).toHaveLength(2); }); @@ -933,10 +947,8 @@ describe('useSlashCommandProcessor', () => { CommandKind.FILE, ); - const result = setupProcessorHook([quitCommand], [exitCommand]); - await vi.waitFor(() => - expect(result.current.slashCommands).toHaveLength(2), - ); + const result = await setupProcessorHook([quitCommand], [exitCommand]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(2)); await act(async () => { await result.current.handleSlashCommand('/exit'); @@ -951,9 +963,9 @@ describe('useSlashCommandProcessor', () => { }); describe('Lifecycle', () => { - it('should abort command loading when the hook unmounts', () => { + it('should abort command loading when the hook unmounts', async () => { const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); - const { unmount } = setupProcessorHook(); + const { unmount } = await setupProcessorHook(); unmount(); @@ -996,8 +1008,8 @@ describe('useSlashCommandProcessor', () => { }); it('should log a simple slash command', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1015,8 +1027,8 @@ describe('useSlashCommandProcessor', () => { }); it('logs nothing for a bogus command', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1027,8 +1039,8 @@ describe('useSlashCommandProcessor', () => { }); it('logs a failure event for a failed command', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1046,8 +1058,8 @@ describe('useSlashCommandProcessor', () => { }); it('should log a slash command with a subcommand', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1064,8 +1076,8 @@ describe('useSlashCommandProcessor', () => { }); it('should log the command path when an alias is used', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1080,8 +1092,8 @@ describe('useSlashCommandProcessor', () => { }); it('should not log for unknown commands', async () => { - const result = setupProcessorHook(loggingTestCommands); - await vi.waitFor(() => + const result = await setupProcessorHook(loggingTestCommands); + await waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 42c63ae62b..74c92b9159 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { act, useState } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useAtCompletion } from './useAtCompletion.js'; import type { Config, FileSearch } from '@google/gemini-cli-core'; import { FileSearchFactory } from '@google/gemini-cli-core'; @@ -74,10 +75,11 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await vi.waitFor(() => { - expect(result.current.suggestions.length).toBeGreaterThan(0); + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(5); }); + expect(result.current.suggestions.length).toBeGreaterThan(0); expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', 'src/components/', @@ -104,7 +106,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -127,7 +129,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -164,7 +166,7 @@ describe('useAtCompletion', () => { ); // The hook should find 'cRaZycAsE.txt' even though the pattern is 'CrAzYCaSe'. - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'cRaZycAsE.txt', ]); @@ -192,12 +194,12 @@ describe('useAtCompletion', () => { ); // It's initially true because the effect runs synchronously. - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(true); }); // Wait for the loading to complete. - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); }); @@ -212,7 +214,7 @@ describe('useAtCompletion', () => { { initialProps: { pattern: 'a' } }, ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -222,7 +224,7 @@ describe('useAtCompletion', () => { rerender({ pattern: 'b' }); // Wait for the final result - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); @@ -265,7 +267,7 @@ describe('useAtCompletion', () => { ); // Wait for the initial search to complete (using real timers) - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -295,7 +297,7 @@ describe('useAtCompletion', () => { vi.useRealTimers(); // Wait for the search results to be processed - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); @@ -326,7 +328,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to be ready (initialization is complete) - await vi.waitFor(() => { + await waitFor(() => { expect(mockFileSearch.search).toHaveBeenCalledWith( 'a', expect.any(Object), @@ -342,7 +344,7 @@ describe('useAtCompletion', () => { expect(abortSpy).toHaveBeenCalledTimes(1); // Wait for the final result, which should be from the second, faster search. - await vi.waitFor( + await waitFor( () => { expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']); }, @@ -369,7 +371,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to be ready and have suggestions - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -401,7 +403,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to enter the error state - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); expect(result.current.suggestions).toEqual([]); // No suggestions on error @@ -432,7 +434,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -453,7 +455,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', undefined, testRootDir), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -481,7 +483,7 @@ describe('useAtCompletion', () => { ); // Wait for initial suggestions from the first directory - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file1.txt', ]); @@ -493,13 +495,13 @@ describe('useAtCompletion', () => { }); // After CWD changes, suggestions should be cleared and it should load again. - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(true); expect(result.current.suggestions).toEqual([]); }); // Wait for the new suggestions from the second directory - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file2.txt', ]); @@ -537,7 +539,7 @@ describe('useAtCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 01cf9e8c5d..aca08a7add 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -14,7 +14,8 @@ import { type Mock, } from 'vitest'; import { act, useEffect } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useCommandCompletion } from './useCommandCompletion.js'; import type { CommandContext } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; @@ -170,7 +171,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('@file'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toHaveLength(1); }); @@ -184,7 +185,7 @@ describe('useCommandCompletion', () => { ); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.showSuggestions).toBe(false); }); }); @@ -210,7 +211,7 @@ describe('useCommandCompletion', () => { const text = '@src/a\\ file.txt'; renderCommandCompletionHook(text); - await vi.waitFor(() => { + await waitFor(() => { expect(useAtCompletion).toHaveBeenLastCalledWith( expect.objectContaining({ enabled: true, @@ -226,7 +227,7 @@ describe('useCommandCompletion', () => { renderCommandCompletionHook(text, cursorOffset); - await vi.waitFor(() => { + await waitFor(() => { expect(useAtCompletion).toHaveBeenLastCalledWith( expect.objectContaining({ enabled: true, @@ -268,7 +269,7 @@ describe('useCommandCompletion', () => { shellModeActive, ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(expectedSuggestions); expect(result.current.showSuggestions).toBe( expectedShowSuggestions, @@ -317,7 +318,7 @@ describe('useCommandCompletion', () => { it('should navigate up through suggestions with wrap-around', async () => { const { result } = renderCommandCompletionHook('/'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -333,7 +334,7 @@ describe('useCommandCompletion', () => { it('should navigate down through suggestions with wrap-around', async () => { const { result } = renderCommandCompletionHook('/'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -352,7 +353,7 @@ describe('useCommandCompletion', () => { it('should handle navigation with multiple suggestions', async () => { const { result } = renderCommandCompletionHook('/'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -379,7 +380,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('/'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe( mockSuggestions.length, ); @@ -398,7 +399,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('/mem'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -416,7 +417,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('@src/fi'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -437,7 +438,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook(text, cursorOffset); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -457,7 +458,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('@src/comp'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -477,7 +478,7 @@ describe('useCommandCompletion', () => { const { result } = renderCommandCompletionHook('@src\\comp'); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx index 5eada66818..59e71730d0 100644 --- a/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx +++ b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; import { act, useCallback } from 'react'; import { vi } from 'vitest'; +import { render } from '../../test-utils/render.js'; import { useConsoleMessages } from './useConsoleMessages.js'; describe('useConsoleMessages', () => { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 22b092e036..3797198a8e 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -14,7 +14,7 @@ import { type MockedFunction, } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { useEditorSettings } from './useEditorSettings.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx index 2efceef3fa..2987926add 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx @@ -11,7 +11,8 @@ import * as path from 'node:path'; import { createExtension } from '../../test-utils/createExtension.js'; import { useExtensionUpdates } from './useExtensionUpdates.js'; import { GEMINI_DIR } from '@google/gemini-cli-core'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { MessageType } from '../types.js'; import { checkForAllExtensionUpdates, @@ -102,7 +103,7 @@ describe('useExtensionUpdates', () => { render(); - await vi.waitFor(() => { + await waitFor(() => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, @@ -152,7 +153,7 @@ describe('useExtensionUpdates', () => { render(); - await vi.waitFor( + await waitFor( () => { expect(addItem).toHaveBeenCalledWith( { @@ -230,7 +231,7 @@ describe('useExtensionUpdates', () => { render(); - await vi.waitFor( + await waitFor( () => { expect(addItem).toHaveBeenCalledTimes(2); expect(addItem).toHaveBeenCalledWith( @@ -313,7 +314,7 @@ describe('useExtensionUpdates', () => { render(); - await vi.waitFor(() => { + await waitFor(() => { expect(addItem).toHaveBeenCalledTimes(1); expect(addItem).toHaveBeenCalledWith( { diff --git a/packages/cli/src/ui/hooks/useFocus.test.tsx b/packages/cli/src/ui/hooks/useFocus.test.tsx index 65c5c83b1a..83c2405f0f 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { EventEmitter } from 'node:events'; import { useFocus } from './useFocus.js'; import { vi, type Mock } from 'vitest'; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 25609ab65c..e97c23da46 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -7,6 +7,7 @@ import { vi, type Mock, type MockInstance } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useFolderTrust } from './useFolderTrust.js'; import type { LoadedSettings } from '../../config/settings.js'; import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; @@ -88,7 +89,7 @@ describe('useFolderTrust', () => { const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isFolderTrustDialogOpen).toBe(true); }); expect(onTrustChange).toHaveBeenCalledWith(undefined); @@ -129,7 +130,7 @@ describe('useFolderTrust', () => { useFolderTrust(mockSettings, onTrustChange, addItem), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isTrusted).toBeUndefined(); }); @@ -139,7 +140,7 @@ describe('useFolderTrust', () => { ); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( '/test/path', TrustLevel.TRUST_FOLDER, @@ -207,7 +208,7 @@ describe('useFolderTrust', () => { ); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockTrustedFolders.setValue).not.toHaveBeenCalled(); expect(mockSettings.setValue).not.toHaveBeenCalled(); expect(result.current.isFolderTrustDialogOpen).toBe(true); @@ -229,7 +230,7 @@ describe('useFolderTrust', () => { useFolderTrust(mockSettings, onTrustChange, addItem), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isTrusted).toBe(false); }); @@ -237,7 +238,7 @@ describe('useFolderTrust', () => { result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isRestarting).toBe(true); expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 1c7a28e231..f9b05314da 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -9,6 +9,7 @@ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useGeminiStream } from './useGeminiStream.js'; import { useKeypress } from './useKeypress.js'; import * as atCommandProcessor from './atCommandProcessor.js'; @@ -507,7 +508,7 @@ describe('useGeminiStream', () => { } }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockMarkToolsAsSubmitted).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); @@ -590,7 +591,7 @@ describe('useGeminiStream', () => { } }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['1']); expect(client.addHistory).toHaveBeenCalledWith({ role: 'user', @@ -702,7 +703,7 @@ describe('useGeminiStream', () => { } }); - await vi.waitFor(() => { + await waitFor(() => { // The tools should be marked as submitted locally expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([ 'cancel-1', @@ -840,7 +841,7 @@ describe('useGeminiStream', () => { }); // 5. Wait for submitQuery to be called - await vi.waitFor(() => { + await waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledWith( toolCallResponseParts, expect.any(AbortSignal), @@ -889,7 +890,7 @@ describe('useGeminiStream', () => { }); // Wait for the first part of the response - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); @@ -897,7 +898,7 @@ describe('useGeminiStream', () => { simulateEscapeKeyPress(); // Verify cancellation message is added - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: MessageType.INFO, @@ -1030,7 +1031,7 @@ describe('useGeminiStream', () => { result.current.submitQuery('long running query'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); @@ -1038,13 +1039,12 @@ describe('useGeminiStream', () => { simulateEscapeKeyPress(); // Allow the stream to continue - act(() => { + await act(async () => { continueStream(); + // Wait a bit to see if the second part is processed + await new Promise((resolve) => setTimeout(resolve, 50)); }); - // Wait a bit to see if the second part is processed - await new Promise((resolve) => setTimeout(resolve, 50)); - // The text should not have been updated with " Canceled" const lastCall = mockAddItem.mock.calls.find( (call) => call[0].type === 'gemini', @@ -1138,7 +1138,7 @@ describe('useGeminiStream', () => { expect(mockCancelAllToolCalls).toHaveBeenCalled(); // A cancellation message should be added to history - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ text: 'Request cancelled.', @@ -1167,7 +1167,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/memory add "test fact"'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockScheduleToolCalls).toHaveBeenCalledWith( [ expect.objectContaining({ @@ -1194,7 +1194,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/help'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help'); expect(mockScheduleToolCalls).not.toHaveBeenCalled(); expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made @@ -1215,7 +1215,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/my-custom-command'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/my-custom-command', ); @@ -1250,7 +1250,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/emptycmd'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith('/emptycmd'); expect(localMockSendMessageStream).toHaveBeenCalledWith( '', @@ -1268,7 +1268,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('// This is a line comment'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); expect(localMockSendMessageStream).toHaveBeenCalledWith( '// This is a line comment', @@ -1286,7 +1286,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/* This is a block comment */'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); expect(localMockSendMessageStream).toHaveBeenCalledWith( '/* This is a block comment */', @@ -1324,7 +1324,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/about'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); }); }); @@ -1401,7 +1401,7 @@ describe('useGeminiStream', () => { } }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockPerformMemoryRefresh).toHaveBeenCalledTimes(1); }); }); @@ -1457,7 +1457,7 @@ describe('useGeminiStream', () => { }); // 3. Assertion - await vi.waitFor(() => { + await waitFor(() => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( 'Rate limit exceeded', mockAuthType, @@ -1990,7 +1990,7 @@ describe('useGeminiStream', () => { }); // Check that the info message was added - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2050,7 +2050,7 @@ describe('useGeminiStream', () => { }); // Check that the message was added without suggestion - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2105,7 +2105,7 @@ describe('useGeminiStream', () => { }); // Check that the message was added with suggestion - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2161,7 +2161,7 @@ describe('useGeminiStream', () => { }); // Check that onCancelSubmit was called - await vi.waitFor(() => { + await waitFor(() => { expect(onCancelSubmitSpy).toHaveBeenCalled(); }); }); @@ -2360,7 +2360,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery(`Test ${reason}`); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2487,7 +2487,7 @@ describe('useGeminiStream', () => { }); // Wait for the first response to complete - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -2520,7 +2520,7 @@ describe('useGeminiStream', () => { // We can verify this by checking that the LoadingIndicator would not show the previous thought // The actual thought state is internal to the hook, but we can verify the behavior // by ensuring the second response doesn't show the previous thought - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -2638,7 +2638,7 @@ describe('useGeminiStream', () => { }); // Verify cancellation message was added - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', @@ -2696,7 +2696,7 @@ describe('useGeminiStream', () => { }); // Verify error message was added - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -2747,7 +2747,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('test query'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); expect( typeof result.current.loopDetectionConfirmationRequest?.onComplete, @@ -2795,7 +2795,7 @@ describe('useGeminiStream', () => { }); // Wait for confirmation request to be set - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2824,7 +2824,7 @@ describe('useGeminiStream', () => { ); // Verify that the request was retried - await vi.waitFor(() => { + await waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledTimes(2); expect(mockSendMessageStream).toHaveBeenNthCalledWith( 2, @@ -2860,7 +2860,7 @@ describe('useGeminiStream', () => { }); // Wait for confirmation request to be set - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2907,7 +2907,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('first query'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2957,7 +2957,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('second query'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2980,7 +2980,7 @@ describe('useGeminiStream', () => { ); // Verify that the request was retried - await vi.waitFor(() => { + await waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledTimes(3); // 1st query, 2nd query, retry of 2nd query expect(mockSendMessageStream).toHaveBeenNthCalledWith( 3, @@ -3011,7 +3011,7 @@ describe('useGeminiStream', () => { }); // Verify that the content was added to history before the loop detection dialog - await vi.waitFor(() => { + await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -3022,7 +3022,7 @@ describe('useGeminiStream', () => { }); // Then verify loop detection confirmation request was set - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); }); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.tsx b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx index 9695c60b67..dd85e73e7e 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.tsx +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx @@ -7,7 +7,8 @@ import type { MockedFunction } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useGitBranchName } from './useGitBranchName.js'; import { fs, vol } from 'memfs'; import * as fsPromises from 'node:fs/promises'; @@ -161,7 +162,7 @@ describe('useGitBranchName', () => { expect(result.current).toBe('main'); // Wait for watcher to be set up - await vi.waitFor(() => { + await waitFor(() => { expect(watchSpy).toHaveBeenCalled(); }); @@ -171,7 +172,7 @@ describe('useGitBranchName', () => { rerender(); }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current).toBe('develop'); }); }); @@ -236,7 +237,7 @@ describe('useGitBranchName', () => { }); // Wait for watcher to be set up BEFORE unmounting - await vi.waitFor(() => { + await waitFor(() => { expect(watchMock).toHaveBeenCalledWith( GIT_LOGS_HEAD_PATH, expect.any(Function), diff --git a/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx b/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx index 3bc84f8553..2da958b71a 100644 --- a/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx +++ b/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { act } from 'react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { @@ -78,34 +78,46 @@ describe('useIdeTrustListener', () => { ); }); - const renderTrustListenerHook = () => { + const renderTrustListenerHook = async () => { let hookResult: ReturnType; function TestComponent() { hookResult = useIdeTrustListener(); return null; } - const { rerender } = render(); + const { rerender, unmount } = render(); + + // Flush any pending async state updates from the hook's initialization + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return { result: { get current() { return hookResult; }, }, - rerender: () => rerender(), + rerender: async () => { + rerender(); + }, + unmount: async () => { + unmount(); + }, }; }; - it('should initialize correctly with no trust information', () => { + it('should initialize correctly with no trust information', async () => { vi.mocked(trustedFolders.isWorkspaceTrusted).mockReturnValue({ isTrusted: undefined, source: undefined, }); - const { result } = renderTrustListenerHook(); + const { result, unmount } = await renderTrustListenerHook(); expect(result.current.isIdeTrusted).toBe(undefined); expect(result.current.needsRestart).toBe(false); expect(result.current.restartReason).toBe('NONE'); + await unmount(); }); it('should NOT set needsRestart when connecting for the first time', async () => { @@ -116,7 +128,7 @@ describe('useIdeTrustListener', () => { isTrusted: true, source: 'ide', }); - const { result } = renderTrustListenerHook(); + const { result, unmount } = await renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -136,6 +148,7 @@ describe('useIdeTrustListener', () => { expect(result.current.isIdeTrusted).toBe(true); expect(result.current.needsRestart).toBe(false); expect(result.current.restartReason).toBe('CONNECTION_CHANGE'); + await unmount(); }); it('should set needsRestart when IDE trust changes', async () => { @@ -150,7 +163,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result } = renderTrustListenerHook(); + const { result, unmount } = await renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -174,6 +187,7 @@ describe('useIdeTrustListener', () => { expect(result.current.isIdeTrusted).toBe(false); expect(result.current.needsRestart).toBe(true); expect(result.current.restartReason).toBe('TRUST_CHANGE'); + await unmount(); }); it('should set needsRestart when IDE disconnects', async () => { @@ -188,7 +202,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result } = renderTrustListenerHook(); + const { result, unmount } = await renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -210,6 +224,7 @@ describe('useIdeTrustListener', () => { expect(result.current.isIdeTrusted).toBe(undefined); expect(result.current.needsRestart).toBe(true); expect(result.current.restartReason).toBe('CONNECTION_CHANGE'); + await unmount(); }); it('should NOT set needsRestart if trust value does not change', async () => { @@ -224,7 +239,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result, rerender } = renderTrustListenerHook(); + const { result, rerender, unmount } = await renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -234,9 +249,10 @@ describe('useIdeTrustListener', () => { expect(result.current.isIdeTrusted).toBe(true); expect(result.current.needsRestart).toBe(false); - rerender(); + await rerender(); expect(result.current.isIdeTrusted).toBe(true); expect(result.current.needsRestart).toBe(false); + await unmount(); }); }); diff --git a/packages/cli/src/ui/hooks/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index aecc4fd876..4ede175c09 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -5,7 +5,7 @@ */ import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { useKeypress } from './useKeypress.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { useStdin } from 'ink'; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index 904010bcca..12bcbf36c9 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; import { diff --git a/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx b/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx index 4fb3db97e1..c421270d81 100644 --- a/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx +++ b/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { vi } from 'vitest'; import { useMemoryMonitor, diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.tsx b/packages/cli/src/ui/hooks/useMessageQueue.test.tsx index 001897bb5d..57758ee418 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.tsx +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.tsx @@ -6,7 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useMessageQueue } from './useMessageQueue.js'; import { StreamingState } from '../types.js'; @@ -150,7 +151,7 @@ describe('useMessageQueue', () => { // Transition to Idle rerender({ streamingState: StreamingState.Idle }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2'); expect(result.current.messageQueue).toEqual([]); }); @@ -206,7 +207,7 @@ describe('useMessageQueue', () => { // Go back to idle - should submit rerender({ streamingState: StreamingState.Idle }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockSubmitQuery).toHaveBeenCalledWith('First batch'); expect(result.current.messageQueue).toEqual([]); }); @@ -222,7 +223,7 @@ describe('useMessageQueue', () => { // Go back to idle - should submit again rerender({ streamingState: StreamingState.Idle }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch'); expect(mockSubmitQuery).toHaveBeenCalledTimes(2); }); diff --git a/packages/cli/src/ui/hooks/useModelCommand.test.tsx b/packages/cli/src/ui/hooks/useModelCommand.test.tsx index 0717ab6414..7232308cc7 100644 --- a/packages/cli/src/ui/hooks/useModelCommand.test.tsx +++ b/packages/cli/src/ui/hooks/useModelCommand.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { useModelCommand } from './useModelCommand.js'; describe('useModelCommand', () => { @@ -18,22 +18,24 @@ describe('useModelCommand', () => { } it('should initialize with the model dialog closed', () => { - render(); + const { unmount } = render(); expect(result.isModelDialogOpen).toBe(false); + unmount(); }); it('should open the model dialog when openModelDialog is called', () => { - render(); + const { unmount } = render(); act(() => { result.openModelDialog(); }); expect(result.isModelDialogOpen).toBe(true); + unmount(); }); it('should close the model dialog when closeModelDialog is called', () => { - render(); + const { unmount } = render(); // Open it first act(() => { @@ -46,5 +48,6 @@ describe('useModelCommand', () => { result.closeModelDialog(); }); expect(result.isModelDialogOpen).toBe(false); + unmount(); }); }); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 3e83b97536..8b14ab0655 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { Text } from 'ink'; import { usePhraseCycler, @@ -50,7 +50,9 @@ describe('usePhraseCycler', () => { , ); rerender(); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(lastFrame()).toBe('Waiting for user confirmation...'); }); @@ -59,7 +61,9 @@ describe('usePhraseCycler', () => { , ); const initialPhrase = lastFrame(); - await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS * 2); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS * 2); + }); expect(lastFrame()).toBe(initialPhrase); }); @@ -69,7 +73,9 @@ describe('usePhraseCycler', () => { , ); // Initial phrase should be one of the witty phrases - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); await act(async () => { @@ -77,7 +83,9 @@ describe('usePhraseCycler', () => { }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); - await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); }); @@ -109,11 +117,15 @@ describe('usePhraseCycler', () => { customPhrases={customPhrases} />, ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(lastFrame()).toBe('Phrase A'); // Interval -> callCount 1 -> returns 0.99 -> 'Phrase B' - await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + }); expect(lastFrame()).toBe('Phrase B'); // Deactivate -> resets to customPhrases[0] -> 'Phrase A' @@ -124,7 +136,9 @@ describe('usePhraseCycler', () => { customPhrases={customPhrases} />, ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(lastFrame()).toBe('Phrase A'); // Activate again -> callCount 2 -> returns 0 -> 'Phrase A' @@ -135,7 +149,9 @@ describe('usePhraseCycler', () => { customPhrases={customPhrases} />, ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(lastFrame()).toBe('Phrase A'); }); @@ -164,7 +180,9 @@ describe('usePhraseCycler', () => { expect(lastFrame()).toBe('Custom Phrase 1'); randomMock.mockReturnValue(0.99); - await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100); + }); expect(lastFrame()).toBe('Custom Phrase 2'); @@ -175,7 +193,9 @@ describe('usePhraseCycler', () => { rerender( , ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); }); @@ -185,7 +205,9 @@ describe('usePhraseCycler', () => { const { lastFrame } = render( , ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); }); @@ -195,22 +217,30 @@ describe('usePhraseCycler', () => { const { lastFrame, rerender } = render( , ); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); // Cycle to a different phrase (potentially) - await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); // Go to waiting state rerender(); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(lastFrame()).toBe('Waiting for user confirmation...'); // Go back to active cycling - should pick a random witty phrase rerender(); - await vi.advanceTimersByTimeAsync(0); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); }); }); diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx index 5c2a15d579..cf00af2045 100644 --- a/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx +++ b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render } from 'ink-testing-library'; +import { act } from 'react'; +import { render } from '../../test-utils/render.js'; import type { Config, CodeAssistServer, @@ -13,6 +14,7 @@ import type { } from '@google/gemini-cli-core'; import { UserTierId, getCodeAssistServer } from '@google/gemini-cli-core'; import { usePrivacySettings } from './usePrivacySettings.js'; +import { waitFor } from '../../test-utils/async.js'; // Mock the dependencies vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -52,7 +54,7 @@ describe('usePrivacySettings', () => { const { result } = renderPrivacySettingsHook(); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -71,7 +73,7 @@ describe('usePrivacySettings', () => { const { result } = renderPrivacySettingsHook(); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -90,7 +92,7 @@ describe('usePrivacySettings', () => { const { result } = renderPrivacySettingsHook(); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -118,15 +120,17 @@ describe('usePrivacySettings', () => { const { result } = renderPrivacySettingsHook(); // Wait for initial load - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); // Update the setting - await result.current.updateDataCollectionOptIn(false); + await act(async () => { + await result.current.updateDataCollectionOptIn(false); + }); // Wait for update to complete - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.privacyState.dataCollectionOptIn).toBe(false); }); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index edadbbacfc..7deaee8493 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -18,6 +18,7 @@ import { renderHook } from '../../test-utils/render.js'; import { type Config, type FallbackModelHandler, + type FallbackIntent, UserTierId, AuthType, TerminalQuotaError, @@ -186,10 +187,13 @@ describe('useQuotaAndFallback', () => { } of testCases) { it(`should handle ${description} correctly`, async () => { const handler = getRegisteredHandler(tier); - const result = await handler('model-A', 'model-B', error); + let result: FallbackIntent | null; + await act(async () => { + result = await handler('model-A', 'model-B', error); + }); // Automatic fallbacks should return 'stop' - expect(result).toBe('stop'); + expect(result!).toBe('stop'); expect(mockHistoryManager.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO }), @@ -224,25 +228,26 @@ describe('useQuotaAndFallback', () => { .calls[0][0] as FallbackModelHandler; // Call the handler but do not await it, to check the intermediate state - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new TerminalQuotaError('pro quota', mockGoogleApiError), - ); - - await act(async () => {}); + let promise: Promise; + await act(() => { + promise = handler( + 'gemini-pro', + 'gemini-flash', + new TerminalQuotaError('pro quota', mockGoogleApiError), + ); + }); // The hook should now have a pending request for the UI to handle expect(result.current.proQuotaRequest).not.toBeNull(); expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro'); // Simulate the user choosing to continue with the fallback model - act(() => { + await act(() => { result.current.handleProQuotaChoice('continue'); }); // The original promise from the handler should now resolve - const intent = await promise; + const intent = await promise!; expect(intent).toBe('retry'); // The pending request should be cleared from the state @@ -263,31 +268,36 @@ describe('useQuotaAndFallback', () => { const handler = setFallbackHandlerSpy.mock .calls[0][0] as FallbackModelHandler; - const promise1 = handler( - 'gemini-pro', - 'gemini-flash', - new TerminalQuotaError('pro quota 1', mockGoogleApiError), - ); - await act(async () => {}); + let promise1: Promise; + await act(() => { + promise1 = handler( + 'gemini-pro', + 'gemini-flash', + new TerminalQuotaError('pro quota 1', mockGoogleApiError), + ); + }); const firstRequest = result.current.proQuotaRequest; expect(firstRequest).not.toBeNull(); - const result2 = await handler( - 'gemini-pro', - 'gemini-flash', - new TerminalQuotaError('pro quota 2', mockGoogleApiError), - ); + let result2: FallbackIntent | null; + await act(async () => { + result2 = await handler( + 'gemini-pro', + 'gemini-flash', + new TerminalQuotaError('pro quota 2', mockGoogleApiError), + ); + }); // The lock should have stopped the second request - expect(result2).toBe('stop'); + expect(result2!).toBe('stop'); expect(result.current.proQuotaRequest).toBe(firstRequest); - act(() => { + await act(() => { result.current.handleProQuotaChoice('continue'); }); - const intent1 = await promise1; + const intent1 = await promise1!; expect(intent1).toBe('retry'); expect(result.current.proQuotaRequest).toBeNull(); }); @@ -327,18 +337,20 @@ describe('useQuotaAndFallback', () => { const handler = setFallbackHandlerSpy.mock .calls[0][0] as FallbackModelHandler; - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new TerminalQuotaError('pro quota', mockGoogleApiError), - ); - await act(async () => {}); // Allow state to update + let promise: Promise; + await act(() => { + promise = handler( + 'gemini-pro', + 'gemini-flash', + new TerminalQuotaError('pro quota', mockGoogleApiError), + ); + }); - act(() => { + await act(() => { result.current.handleProQuotaChoice('auth'); }); - const intent = await promise; + const intent = await promise!; expect(intent).toBe('auth'); expect(mockSetAuthState).toHaveBeenCalledWith(AuthState.Updating); expect(result.current.proQuotaRequest).toBeNull(); @@ -358,18 +370,20 @@ describe('useQuotaAndFallback', () => { const handler = setFallbackHandlerSpy.mock .calls[0][0] as FallbackModelHandler; // The first `addItem` call is for the initial quota error message - const promise = handler( - 'gemini-pro', - 'gemini-flash', - new TerminalQuotaError('pro quota', mockGoogleApiError), - ); - await act(async () => {}); // Allow state to update + let promise: Promise; + await act(() => { + promise = handler( + 'gemini-pro', + 'gemini-flash', + new TerminalQuotaError('pro quota', mockGoogleApiError), + ); + }); - act(() => { + await act(() => { result.current.handleProQuotaChoice('continue'); }); - const intent = await promise; + const intent = await promise!; expect(intent).toBe('retry'); expect(result.current.proQuotaRequest).toBeNull(); diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.tsx b/packages/cli/src/ui/hooks/useSelectionList.test.tsx index 9ee99746ca..61a181bcd0 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.tsx +++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx @@ -6,7 +6,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useSelectionList, type SelectionListItem, @@ -67,7 +68,7 @@ describe('useSelectionList', () => { }); }; - const renderSelectionListHook = (initialProps: { + const renderSelectionListHook = async (initialProps: { items: Array>; onSelect: (item: string) => void; onHighlight?: (item: string) => void; @@ -87,23 +88,26 @@ describe('useSelectionList', () => { return hookResult; }, }, - rerender: (newProps: Partial) => - rerender(), - unmount, + rerender: async (newProps: Partial) => { + rerender(); + }, + unmount: async () => { + unmount(); + }, }; }; describe('Initialization', () => { - it('should initialize with the default index (0) if enabled', () => { - const { result } = renderSelectionListHook({ + it('should initialize with the default index (0) if enabled', async () => { + const { result } = await renderSelectionListHook({ items, onSelect: mockOnSelect, }); expect(result.current.activeIndex).toBe(0); }); - it('should initialize with the provided initialIndex if enabled', () => { - const { result } = renderSelectionListHook({ + it('should initialize with the provided initialIndex if enabled', async () => { + const { result } = await renderSelectionListHook({ items, initialIndex: 2, onSelect: mockOnSelect, @@ -111,16 +115,16 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); }); - it('should handle an empty list gracefully', () => { - const { result } = renderSelectionListHook({ + it('should handle an empty list gracefully', async () => { + const { result } = await renderSelectionListHook({ items: [], onSelect: mockOnSelect, }); expect(result.current.activeIndex).toBe(0); }); - it('should find the next enabled item (downwards) if initialIndex is disabled', () => { - const { result } = renderSelectionListHook({ + it('should find the next enabled item (downwards) if initialIndex is disabled', async () => { + const { result } = await renderSelectionListHook({ items, initialIndex: 1, onSelect: mockOnSelect, @@ -128,13 +132,13 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); }); - it('should wrap around to find the next enabled item if initialIndex is disabled', () => { + it('should wrap around to find the next enabled item if initialIndex is disabled', async () => { const wrappingItems = [ { value: 'A', key: 'A' }, { value: 'B', disabled: true, key: 'B' }, { value: 'C', disabled: true, key: 'C' }, ]; - const { result } = renderSelectionListHook({ + const { result } = await renderSelectionListHook({ items: wrappingItems, initialIndex: 2, onSelect: mockOnSelect, @@ -142,15 +146,15 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(0); }); - it('should default to 0 if initialIndex is out of bounds', () => { - const { result } = renderSelectionListHook({ + it('should default to 0 if initialIndex is out of bounds', async () => { + const { result } = await renderSelectionListHook({ items, initialIndex: 10, onSelect: mockOnSelect, }); expect(result.current.activeIndex).toBe(0); - const { result: resultNeg } = renderSelectionListHook({ + const { result: resultNeg } = await renderSelectionListHook({ items, initialIndex: -1, onSelect: mockOnSelect, @@ -158,12 +162,12 @@ describe('useSelectionList', () => { expect(resultNeg.current.activeIndex).toBe(0); }); - it('should stick to the initial index if all items are disabled', () => { + it('should stick to the initial index if all items are disabled', async () => { const allDisabled = [ { value: 'A', disabled: true, key: 'A' }, { value: 'B', disabled: true, key: 'B' }, ]; - const { result } = renderSelectionListHook({ + const { result } = await renderSelectionListHook({ items: allDisabled, initialIndex: 1, onSelect: mockOnSelect, @@ -173,8 +177,8 @@ describe('useSelectionList', () => { }); describe('Keyboard Navigation (Up/Down/J/K)', () => { - it('should move down with "j" and "down" keys, skipping disabled items', () => { - const { result } = renderSelectionListHook({ + it('should move down with "j" and "down" keys, skipping disabled items', async () => { + const { result } = await renderSelectionListHook({ items, onSelect: mockOnSelect, }); @@ -185,8 +189,8 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(3); }); - it('should move up with "k" and "up" keys, skipping disabled items', () => { - const { result } = renderSelectionListHook({ + it('should move up with "k" and "up" keys, skipping disabled items', async () => { + const { result } = await renderSelectionListHook({ items, initialIndex: 3, onSelect: mockOnSelect, @@ -198,8 +202,8 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(0); }); - it('should wrap navigation correctly', () => { - const { result } = renderSelectionListHook({ + it('should wrap navigation correctly', async () => { + const { result } = await renderSelectionListHook({ items, initialIndex: items.length - 1, onSelect: mockOnSelect, @@ -212,8 +216,8 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(3); }); - it('should call onHighlight when index changes', () => { - renderSelectionListHook({ + it('should call onHighlight when index changes', async () => { + await renderSelectionListHook({ items, onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -223,9 +227,9 @@ describe('useSelectionList', () => { expect(mockOnHighlight).toHaveBeenCalledWith('C'); }); - it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', () => { + it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', async () => { const singleItem = [{ value: 'A', key: 'A' }]; - const { result } = renderSelectionListHook({ + const { result } = await renderSelectionListHook({ items: singleItem, onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -235,12 +239,12 @@ describe('useSelectionList', () => { expect(mockOnHighlight).not.toHaveBeenCalled(); }); - it('should not move or call onHighlight if all items are disabled', () => { + it('should not move or call onHighlight if all items are disabled', async () => { const allDisabled = [ { value: 'A', disabled: true, key: 'A' }, { value: 'B', disabled: true, key: 'B' }, ]; - const { result } = renderSelectionListHook({ + const { result } = await renderSelectionListHook({ items: allDisabled, onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -253,8 +257,8 @@ describe('useSelectionList', () => { }); describe('Selection (Enter)', () => { - it('should call onSelect when "return" is pressed on enabled item', () => { - renderSelectionListHook({ + it('should call onSelect when "return" is pressed on enabled item', async () => { + await renderSelectionListHook({ items, initialIndex: 2, onSelect: mockOnSelect, @@ -264,8 +268,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('C'); }); - it('should not call onSelect if the active item is disabled', () => { - const { result } = renderSelectionListHook({ + it('should not call onSelect if the active item is disabled', async () => { + const { result } = await renderSelectionListHook({ items, onSelect: mockOnSelect, }); @@ -278,8 +282,8 @@ describe('useSelectionList', () => { }); describe('Keyboard Navigation Robustness (Rapid Input)', () => { - it('should handle rapid navigation and selection robustly (avoiding stale state)', () => { - const { result } = renderSelectionListHook({ + it('should handle rapid navigation and selection robustly (avoiding stale state)', async () => { + const { result } = await renderSelectionListHook({ items, // A, B(disabled), C, D. Initial index 0 (A). onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -326,8 +330,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).not.toHaveBeenCalledWith('A'); }); - it('should handle ultra-rapid input (multiple presses in single act) without stale state', () => { - const { result } = renderSelectionListHook({ + it('should handle ultra-rapid input (multiple presses in single act) without stale state', async () => { + const { result } = await renderSelectionListHook({ items, // A, B(disabled), C, D. Initial index 0 (A). onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -366,8 +370,8 @@ describe('useSelectionList', () => { }); describe('Focus Management (isFocused)', () => { - it('should activate the keypress handler when focused (default) and items exist', () => { - const { result } = renderSelectionListHook({ + it('should activate the keypress handler when focused (default) and items exist', async () => { + const { result } = await renderSelectionListHook({ items, onSelect: mockOnSelect, }); @@ -376,8 +380,8 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); }); - it('should not activate the keypress handler when isFocused is false', () => { - renderSelectionListHook({ + it('should not activate the keypress handler when isFocused is false', async () => { + await renderSelectionListHook({ items, onSelect: mockOnSelect, isFocused: false, @@ -386,8 +390,8 @@ describe('useSelectionList', () => { expect(() => pressKey('down')).toThrow(/keypress handler is not active/); }); - it('should not activate the keypress handler when items list is empty', () => { - renderSelectionListHook({ + it('should not activate the keypress handler when items list is empty', async () => { + await renderSelectionListHook({ items: [], onSelect: mockOnSelect, isFocused: true, @@ -396,8 +400,8 @@ describe('useSelectionList', () => { expect(() => pressKey('down')).toThrow(/keypress handler is not active/); }); - it('should activate/deactivate when isFocused prop changes', () => { - const { result, rerender } = renderSelectionListHook({ + it('should activate/deactivate when isFocused prop changes', async () => { + const { result, rerender } = await renderSelectionListHook({ items, onSelect: mockOnSelect, isFocused: false, @@ -405,12 +409,12 @@ describe('useSelectionList', () => { expect(activeKeypressHandler).toBeNull(); - rerender({ isFocused: true }); + await rerender({ isFocused: true }); expect(activeKeypressHandler).not.toBeNull(); pressKey('down'); expect(result.current.activeIndex).toBe(2); - rerender({ isFocused: false }); + await rerender({ isFocused: false }); expect(activeKeypressHandler).toBeNull(); expect(() => pressKey('down')).toThrow(/keypress handler is not active/); }); @@ -433,8 +437,8 @@ describe('useSelectionList', () => { const pressNumber = (num: string) => pressKey(num, num); - it('should not respond to numbers if showNumbers is false (default)', () => { - const { result } = renderSelectionListHook({ + it('should not respond to numbers if showNumbers is false (default)', async () => { + const { result } = await renderSelectionListHook({ items: shortList, onSelect: mockOnSelect, }); @@ -443,8 +447,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).not.toHaveBeenCalled(); }); - it('should select item immediately if the number cannot be extended (unambiguous)', () => { - const { result } = renderSelectionListHook({ + it('should select item immediately if the number cannot be extended (unambiguous)', async () => { + const { result } = await renderSelectionListHook({ items: shortList, onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -459,8 +463,8 @@ describe('useSelectionList', () => { expect(vi.getTimerCount()).toBe(0); }); - it('should highlight and wait for timeout if the number can be extended (ambiguous)', () => { - const { result } = renderSelectionListHook({ + it('should highlight and wait for timeout if the number can be extended (ambiguous)', async () => { + const { result } = await renderSelectionListHook({ items: longList, initialIndex: 1, // Start at index 1 so pressing "1" (index 0) causes a change onSelect: mockOnSelect, @@ -484,8 +488,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('Item 1'); }); - it('should handle multi-digit input correctly', () => { - const { result } = renderSelectionListHook({ + it('should handle multi-digit input correctly', async () => { + const { result } = await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -502,8 +506,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('Item 12'); }); - it('should reset buffer if input becomes invalid (out of bounds)', () => { - const { result } = renderSelectionListHook({ + it('should reset buffer if input becomes invalid (out of bounds)', async () => { + const { result } = await renderSelectionListHook({ items: shortList, onSelect: mockOnSelect, showNumbers: true, @@ -519,8 +523,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('C'); }); - it('should allow "0" as subsequent digit, but ignore as first digit', () => { - const { result } = renderSelectionListHook({ + it('should allow "0" as subsequent digit, but ignore as first digit', async () => { + const { result } = await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -540,8 +544,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('Item 10'); }); - it('should clear the initial "0" input after timeout', () => { - renderSelectionListHook({ + it('should clear the initial "0" input after timeout', async () => { + await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -557,8 +561,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).toHaveBeenCalledWith('Item 1'); }); - it('should highlight but not select a disabled item (immediate selection case)', () => { - const { result } = renderSelectionListHook({ + it('should highlight but not select a disabled item (immediate selection case)', async () => { + const { result } = await renderSelectionListHook({ items: shortList, // B (index 1, number 2) is disabled onSelect: mockOnSelect, onHighlight: mockOnHighlight, @@ -574,14 +578,14 @@ describe('useSelectionList', () => { expect(mockOnSelect).not.toHaveBeenCalled(); }); - it('should highlight but not select a disabled item (timeout case)', () => { + it('should highlight but not select a disabled item (timeout case)', async () => { // Create a list where the ambiguous prefix points to a disabled item const disabledAmbiguousList = [ { value: 'Item 1 Disabled', disabled: true, key: 'Item 1 Disabled' }, ...longList.slice(1), ]; - const { result } = renderSelectionListHook({ + const { result } = await renderSelectionListHook({ items: disabledAmbiguousList, onSelect: mockOnSelect, showNumbers: true, @@ -599,8 +603,8 @@ describe('useSelectionList', () => { expect(mockOnSelect).not.toHaveBeenCalled(); }); - it('should clear the number buffer if a non-numeric key (e.g., navigation) is pressed', () => { - const { result } = renderSelectionListHook({ + it('should clear the number buffer if a non-numeric key (e.g., navigation) is pressed', async () => { + const { result } = await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -619,8 +623,8 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); }); - it('should clear the number buffer if "return" is pressed', () => { - renderSelectionListHook({ + it('should clear the number buffer if "return" is pressed', async () => { + await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -642,20 +646,20 @@ describe('useSelectionList', () => { describe('Reactivity (Dynamic Updates)', () => { it('should update activeIndex when initialIndex prop changes', async () => { - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ items, onSelect: mockOnSelect, initialIndex: 0, }); - rerender({ initialIndex: 2 }); - await vi.waitFor(() => { + await rerender({ initialIndex: 2 }); + await waitFor(() => { expect(result.current.activeIndex).toBe(2); }); }); it('should respect a new initialIndex even after user interaction', async () => { - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ items, onSelect: mockOnSelect, initialIndex: 0, @@ -666,30 +670,30 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); // The component re-renders with a new initial index - rerender({ initialIndex: 3 }); + await rerender({ initialIndex: 3 }); // The hook should now respect the new initial index - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(3); }); }); it('should validate index when initialIndex prop changes to a disabled item', async () => { - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ items, onSelect: mockOnSelect, initialIndex: 0, }); - rerender({ initialIndex: 1 }); + await rerender({ initialIndex: 1 }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(2); }); }); it('should adjust activeIndex if items change and the initialIndex is now out of bounds', async () => { - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, initialIndex: 3, items, @@ -701,10 +705,10 @@ describe('useSelectionList', () => { { value: 'X', key: 'X' }, { value: 'Y', key: 'Y' }, ]; - rerender({ items: shorterItems }); // Length 2 + await rerender({ items: shorterItems }); // Length 2 // The useEffect syncs based on the initialIndex (3) which is now out of bounds. It defaults to 0. - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(0); }); }); @@ -715,7 +719,7 @@ describe('useSelectionList', () => { { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, initialIndex: 1, items: initialItems, @@ -728,22 +732,22 @@ describe('useSelectionList', () => { { value: 'B', disabled: true, key: 'B' }, { value: 'C', key: 'C' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(2); }); }); it('should reset to 0 if items change to an empty list', async () => { - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, initialIndex: 2, items, }); - rerender({ items: [] }); - await vi.waitFor(() => { + await rerender({ items: [] }); + await waitFor(() => { expect(result.current.activeIndex).toBe(0); }); }); @@ -756,7 +760,7 @@ describe('useSelectionList', () => { { value: 'D', key: 'D' }, ]; - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, onHighlight: mockOnHighlight, initialIndex: 2, @@ -780,10 +784,10 @@ describe('useSelectionList', () => { { value: 'D', key: 'D' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); // Active index should remain the same since items are deeply equal - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(3); }); // onHighlight should NOT be called since the index didn't change @@ -798,7 +802,7 @@ describe('useSelectionList', () => { { value: 'D', key: 'D' }, ]; - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, onHighlight: mockOnHighlight, initialIndex: 3, @@ -815,10 +819,10 @@ describe('useSelectionList', () => { { value: 'Z', key: 'Z' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); // Active index should update based on initialIndex and new items - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(0); }); }); @@ -830,7 +834,7 @@ describe('useSelectionList', () => { { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, initialIndex: 1, items: initialItems, @@ -845,10 +849,10 @@ describe('useSelectionList', () => { { value: 'C', key: 'C' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); // Should find next valid index since current became disabled - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(2); }); }); @@ -860,7 +864,7 @@ describe('useSelectionList', () => { { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderSelectionListHook({ + const { result, rerender } = await renderSelectionListHook({ onSelect: mockOnSelect, items: initialItems, }); @@ -875,14 +879,14 @@ describe('useSelectionList', () => { { value: 'C', key: 'C' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.activeIndex).toBe(2); }); }); - it('should not re-initialize when items have identical keys but are different objects', () => { + it('should not re-initialize when items have identical keys but are different objects', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, @@ -890,7 +894,7 @@ describe('useSelectionList', () => { let renderCount = 0; - const renderHookWithCount = (initialProps: { + const renderHookWithCount = async (initialProps: { items: Array>; }) => { function TestComponent(props: typeof initialProps) { @@ -904,12 +908,13 @@ describe('useSelectionList', () => { } const { rerender } = render(); return { - rerender: (newProps: Partial) => - rerender(), + rerender: async (newProps: Partial) => { + rerender(); + }, }; }; - const { rerender } = renderHookWithCount({ items: initialItems }); + const { rerender } = await renderHookWithCount({ items: initialItems }); // Initial render expect(renderCount).toBe(1); @@ -920,7 +925,7 @@ describe('useSelectionList', () => { { value: 'B', key: 'B' }, ]; - rerender({ items: newItems }); + await rerender({ items: newItems }); expect(renderCount).toBe(2); }); }); @@ -934,13 +939,13 @@ describe('useSelectionList', () => { vi.useRealTimers(); }); - it('should clear timeout on unmount when timer is active', () => { + it('should clear timeout on unmount when timer is active', async () => { const longList: Array> = Array.from( { length: 15 }, (_, i) => ({ value: `Item ${i + 1}`, key: `Item ${i + 1}` }), ); - const { unmount } = renderSelectionListHook({ + const { unmount } = await renderSelectionListHook({ items: longList, onSelect: mockOnSelect, showNumbers: true, @@ -955,7 +960,7 @@ describe('useSelectionList', () => { }); expect(mockOnSelect).not.toHaveBeenCalled(); - unmount(); + await unmount(); expect(vi.getTimerCount()).toBe(0); diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index a682d0acb7..093a2643aa 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useShellHistory } from './useShellHistory.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -96,9 +97,11 @@ describe('useShellHistory', () => { it('should initialize and read the history file from the correct path', async () => { mockedFs.readFile.mockResolvedValue('cmd1\ncmd2'); - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalledWith( MOCKED_HISTORY_FILE, 'utf-8', @@ -112,6 +115,8 @@ describe('useShellHistory', () => { // History is loaded newest-first: ['cmd2', 'cmd1'] expect(command).toBe('cmd2'); + + unmount(); }); it('should handle a nonexistent history file gracefully', async () => { @@ -119,9 +124,11 @@ describe('useShellHistory', () => { error.code = 'ENOENT'; mockedFs.readFile.mockRejectedValue(error); - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -131,12 +138,16 @@ describe('useShellHistory', () => { }); expect(command).toBe(null); + + unmount(); }); it('should add a command and write to the history file', async () => { - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -144,7 +155,7 @@ describe('useShellHistory', () => { result.current.addCommandToHistory('new_command'); }); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.mkdir).toHaveBeenCalledWith(MOCKED_HISTORY_DIR, { recursive: true, }); @@ -159,14 +170,18 @@ describe('useShellHistory', () => { command = result.current.getPreviousCommand(); }); expect(command).toBe('new_command'); + + unmount(); }); it('should navigate history correctly with previous/next commands', async () => { mockedFs.readFile.mockResolvedValue('cmd1\ncmd2\ncmd3'); - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); // Wait for history to be loaded: ['cmd3', 'cmd2', 'cmd1'] - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -208,12 +223,16 @@ describe('useShellHistory', () => { command = result.current.getNextCommand(); }); expect(command).toBe(''); + + unmount(); }); it('should not add empty or whitespace-only commands to history', async () => { - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -222,14 +241,18 @@ describe('useShellHistory', () => { }); expect(mockedFs.writeFile).not.toHaveBeenCalled(); + + unmount(); }); it('should truncate history to MAX_HISTORY_LENGTH (100)', async () => { const oldCommands = Array.from({ length: 120 }, (_, i) => `old_cmd_${i}`); mockedFs.readFile.mockResolvedValue(oldCommands.join('\n')); - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await vi.waitFor(() => { + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -238,7 +261,7 @@ describe('useShellHistory', () => { }); // Wait for the async write to happen and then inspect the arguments. - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.writeFile).toHaveBeenCalled(); }); @@ -252,14 +275,18 @@ describe('useShellHistory', () => { expect(writtenLines.length).toBe(100); expect(writtenLines[0]).toBe('old_cmd_21'); // New oldest command expect(writtenLines[99]).toBe('new_cmd'); // Newest command + + unmount(); }); it('should move an existing command to the top when re-added', async () => { mockedFs.readFile.mockResolvedValue('cmd1\ncmd2\ncmd3'); - const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); + const { result, unmount } = renderHook(() => + useShellHistory(MOCKED_PROJECT_ROOT), + ); // Initial state: ['cmd3', 'cmd2', 'cmd1'] - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -270,7 +297,7 @@ describe('useShellHistory', () => { // After re-adding 'cmd1': ['cmd1', 'cmd3', 'cmd2'] expect(mockedFs.readFile).toHaveBeenCalled(); - await vi.waitFor(() => { + await waitFor(() => { expect(mockedFs.writeFile).toHaveBeenCalled(); }); @@ -278,5 +305,7 @@ describe('useShellHistory', () => { const writtenLines = writtenContent.split('\n'); expect(writtenLines).toEqual(['cmd2', 'cmd3', 'cmd1']); + + unmount(); }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index e569e783be..290c752651 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -5,11 +5,12 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { act, useState } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useSlashCompletion } from './useSlashCompletion.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandKind } from '../commands/types.js'; -import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; // Test utility type and helper function for creating test SlashCommands @@ -194,46 +195,86 @@ describe('useSlashCompletion', () => { }), createTestCommand({ name: 'chat', description: 'Manage chat history' }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/', - slashCommands, - mockCommandContext, - ), - ); - - await vi.waitFor(() => { - expect(result.current.suggestions.length).toBe(slashCommands.length); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + ), ); + result = hook.result; + unmount = hook.unmount; }); + + await act(async () => { + await waitFor(() => { + expect(result.current.suggestions.length).toBe(slashCommands.length); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining([ + 'help', + 'clear', + 'memory', + 'chat', + 'stats', + ]), + ); + }); + }); + unmount!(); }); it('should filter commands based on partial input', async () => { const slashCommands = [ createTestCommand({ name: 'memory', description: 'Manage memory' }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/mem', - slashCommands, - mockCommandContext, - ), - ); + const setSuggestions = vi.fn(); + const setIsLoadingSuggestions = vi.fn(); + const setIsPerfectMatch = vi.fn(); - await vi.waitFor(() => { - expect(result.current.suggestions).toEqual([ - { - label: 'memory', - value: 'memory', - description: 'Manage memory', - commandKind: CommandKind.BUILT_IN, - }, - ]); + let result: { + current: { completionStart: number; completionEnd: number }; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useSlashCompletion({ + enabled: true, + query: '/mem', + slashCommands, + commandContext: mockCommandContext, + setSuggestions, + setIsLoadingSuggestions, + setIsPerfectMatch, + }), + ); + result = hook.result; + unmount = hook.unmount; }); + + await act(async () => { + await waitFor(() => { + expect(setSuggestions).toHaveBeenCalledWith([ + { + label: 'memory', + value: 'memory', + description: 'Manage memory', + commandKind: CommandKind.BUILT_IN, + }, + ]); + expect(result.current.completionStart).toBe(1); + expect(result.current.completionEnd).toBe(4); + }); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + unmount!(); }); it('should suggest commands based on partial altNames', async () => { @@ -244,16 +285,24 @@ describe('useSlashCompletion', () => { description: 'check session stats. Usage: /stats [model|tools]', }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/usag', - slashCommands, - mockCommandContext, - ), - ); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/usag', + slashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'stats', @@ -262,7 +311,9 @@ describe('useSlashCompletion', () => { commandKind: CommandKind.BUILT_IN, }, ]); + expect(result.current.completionStart).toBe(1); }); + unmount!(); }); it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => { @@ -273,16 +324,27 @@ describe('useSlashCompletion', () => { action: vi.fn(), }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/clear', - slashCommands, - mockCommandContext, - ), - ); - - expect(result.current.suggestions).toHaveLength(0); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/clear', + slashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.completionStart).toBe(1); + }); + unmount!(); }); it.each([['/?'], ['/usage']])( @@ -303,16 +365,28 @@ describe('useSlashCompletion', () => { }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - query, - mockSlashCommands, - mockCommandContext, - ), - ); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + query, + mockSlashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); - expect(result.current.suggestions).toHaveLength(0); + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.completionStart).toBe(1); + }); + unmount!(); }, ); @@ -320,32 +394,55 @@ describe('useSlashCompletion', () => { const slashCommands = [ createTestCommand({ name: 'clear', description: 'Clear the screen' }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/clear ', - slashCommands, - mockCommandContext, - ), - ); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/clear ', + slashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); - expect(result.current.suggestions).toHaveLength(0); + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + }); + unmount!(); }); it('should not provide suggestions for an unknown command', async () => { const slashCommands = [ createTestCommand({ name: 'help', description: 'Show help' }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/unknown-command', - slashCommands, - mockCommandContext, - ), - ); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/unknown-command', + slashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); - expect(result.current.suggestions).toHaveLength(0); + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.completionStart).toBe(1); + }); + unmount!(); }); it('should not suggest hidden commands', async () => { @@ -360,19 +457,28 @@ describe('useSlashCompletion', () => { hidden: true, }), ]; - const { result } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/', - slashCommands, - mockCommandContext, - ), - ); + let result: { + current: ReturnType; + }; + let unmount: () => void; + await act(async () => { + const hook = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + ), + ); + result = hook.result; + unmount = hook.unmount; + }); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions.length).toBe(1); expect(result.current.suggestions[0].label).toBe('visible'); }); + unmount!(); }); }); @@ -389,7 +495,7 @@ describe('useSlashCompletion', () => { }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/memory ', @@ -398,7 +504,7 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toEqual( expect.arrayContaining([ @@ -417,6 +523,7 @@ describe('useSlashCompletion', () => { ]), ); }); + unmount(); }); it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { @@ -430,7 +537,7 @@ describe('useSlashCompletion', () => { ], }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/memory ', @@ -439,7 +546,7 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toHaveLength(2); expect(result.current.suggestions).toEqual( expect.arrayContaining([ @@ -458,6 +565,7 @@ describe('useSlashCompletion', () => { ]), ); }); + unmount(); }); it('should filter sub-commands by prefix', async () => { @@ -471,7 +579,7 @@ describe('useSlashCompletion', () => { ], }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/memory a', @@ -480,7 +588,7 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'add', @@ -489,7 +597,9 @@ describe('useSlashCompletion', () => { commandKind: CommandKind.BUILT_IN, }, ]); + expect(result.current.completionStart).toBe(8); }); + unmount(); }); it('should provide no suggestions for an invalid sub-command', async () => { @@ -503,7 +613,7 @@ describe('useSlashCompletion', () => { ], }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/memory dothisnow', @@ -511,8 +621,13 @@ describe('useSlashCompletion', () => { mockCommandContext, ), ); - - expect(result.current.suggestions).toHaveLength(0); + await act(async () => { + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(0); + expect(result.current.completionStart).toBe(8); + }); + }); + unmount(); }); }); @@ -544,7 +659,7 @@ describe('useSlashCompletion', () => { }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/chat resume my-ch', @@ -553,25 +668,32 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { - expect(mockCompletionFn).toHaveBeenCalledWith( - expect.objectContaining({ - invocation: { - raw: '/chat resume my-ch', - name: 'resume', - args: 'my-ch', - }, - }), - 'my-ch', - ); + await act(async () => { + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith( + expect.objectContaining({ + invocation: { + raw: '/chat resume my-ch', + name: 'resume', + args: 'my-ch', + }, + }), + 'my-ch', + ); + }); }); - await vi.waitFor(() => { - expect(result.current.suggestions).toEqual([ - { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, - { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, - ]); + await act(async () => { + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, + { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, + ]); + expect(result.current.completionStart).toBe(13); + expect(result.current.isLoadingSuggestions).toBe(false); + }); }); + unmount(); }); it('should call command.completion with an empty string when args start with a space', async () => { @@ -593,7 +715,7 @@ describe('useSlashCompletion', () => { }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/chat resume ', @@ -602,50 +724,55 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { - expect(mockCompletionFn).toHaveBeenCalledWith( - expect.objectContaining({ - invocation: { - raw: '/chat resume', - name: 'resume', - args: '', - }, - }), - '', - ); + await act(async () => { + await waitFor(() => { + expect(mockCompletionFn).toHaveBeenCalledWith( + expect.objectContaining({ + invocation: { + raw: '/chat resume ', + name: 'resume', + args: '', + }, + }), + '', + ); + }); }); - await vi.waitFor(() => { - expect(result.current.suggestions).toHaveLength(3); + await act(async () => { + await waitFor(() => { + expect(result.current.suggestions).toHaveLength(3); + expect(result.current.completionStart).toBe(13); + }); }); + unmount(); }); it('should handle completion function that returns null', async () => { - const completionFn = vi.fn().mockResolvedValue(null); + const mockCompletionFn = vi.fn().mockResolvedValue(null); + const slashCommands = [ createTestCommand({ - name: 'chat', - description: 'Manage chat history', - subCommands: [ - createTestCommand({ - name: 'resume', - description: 'Resume a saved chat', - completion: completionFn, - }), - ], + name: 'test', + description: 'Test command', + completion: mockCompletionFn, }), ]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, - '/chat resume ', + '/test arg', slashCommands, mockCommandContext, ), ); - expect(result.current.suggestions).toHaveLength(0); + await waitFor(() => { + expect(result.current.suggestions).toEqual([]); + expect(result.current.isLoadingSuggestions).toBe(false); + }); + unmount(); }); }); @@ -666,7 +793,7 @@ describe('useSlashCompletion', () => { }, ] as SlashCommand[]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/', @@ -675,22 +802,25 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'summarize', - value: 'summarize', - description: 'Summarize content', - commandKind: CommandKind.MCP_PROMPT, - }, - { - label: 'help', - value: 'help', - description: 'Show help', - commandKind: CommandKind.BUILT_IN, - }, - ]), - ); + await waitFor(() => { + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'summarize', + value: 'summarize', + description: 'Summarize content', + commandKind: CommandKind.MCP_PROMPT, + }, + { + label: 'help', + value: 'help', + description: 'Show help', + commandKind: CommandKind.BUILT_IN, + }, + ]), + ); + }); + unmount(); }); it('should include commandKind when filtering MCP commands by prefix', async () => { @@ -709,7 +839,7 @@ describe('useSlashCompletion', () => { }, ] as SlashCommand[]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/summ', @@ -718,7 +848,7 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'summarize', @@ -727,7 +857,9 @@ describe('useSlashCompletion', () => { commandKind: CommandKind.MCP_PROMPT, }, ]); + expect(result.current.completionStart).toBe(1); }); + unmount(); }); it('should include commandKind for sub-commands', async () => { @@ -753,7 +885,7 @@ describe('useSlashCompletion', () => { }, ] as SlashCommand[]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/memory', @@ -762,22 +894,25 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'show', - value: 'show', - description: 'Show memory', - commandKind: CommandKind.BUILT_IN, - }, - { - label: 'add', - value: 'add', - description: 'Add to memory', - commandKind: CommandKind.MCP_PROMPT, - }, - ]), - ); + await waitFor(() => { + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'show', + value: 'show', + description: 'Show memory', + commandKind: CommandKind.BUILT_IN, + }, + { + label: 'add', + value: 'add', + description: 'Add to memory', + commandKind: CommandKind.MCP_PROMPT, + }, + ]), + ); + }); + unmount(); }); it('should include commandKind for file commands', async () => { @@ -790,7 +925,7 @@ describe('useSlashCompletion', () => { }, ] as SlashCommand[]; - const { result } = renderHook(() => + const { result, unmount } = renderHook(() => useTestHarnessForSlashCompletion( true, '/custom', @@ -799,7 +934,7 @@ describe('useSlashCompletion', () => { ), ); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'custom-script', @@ -808,11 +943,13 @@ describe('useSlashCompletion', () => { commandKind: CommandKind.FILE, }, ]); + expect(result.current.completionStart).toBe(1); }); + unmount(); }); }); - it('should not call shared callbacks when disabled', () => { + it('should not call shared callbacks when disabled', async () => { const mockSetSuggestions = vi.fn(); const mockSetIsLoadingSuggestions = vi.fn(); const mockSetIsPerfectMatch = vi.fn(); @@ -824,7 +961,7 @@ describe('useSlashCompletion', () => { }), ]; - const { rerender } = renderHook( + const { rerender, unmount } = renderHook( ({ enabled, query }) => useSlashCompletion({ enabled, @@ -849,9 +986,17 @@ describe('useSlashCompletion', () => { rerender({ enabled: false, query: '@src/file.ts' }); rerender({ enabled: false, query: '@src/file.tsx' }); + // Wait for any internal async operations to settle to avoid act warnings + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + // Should not have called shared callbacks during @ completion typing - expect(mockSetSuggestions).not.toHaveBeenCalled(); - expect(mockSetIsLoadingSuggestions).not.toHaveBeenCalled(); - expect(mockSetIsPerfectMatch).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockSetSuggestions).not.toHaveBeenCalled(); + expect(mockSetIsLoadingSuggestions).not.toHaveBeenCalled(); + expect(mockSetIsPerfectMatch).not.toHaveBeenCalled(); + }); + unmount(); }); }); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 5fa55474f6..28a7908450 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -159,6 +159,7 @@ interface PerfectMatchResult { } function useCommandSuggestions( + query: string | null, parserResult: CommandParserResult, commandContext: CommandContext, getFzfForCommands: ( @@ -207,7 +208,7 @@ function useCommandSuggestions( { ...commandContext, invocation: { - raw: `/${rawParts.join(' ')}`, + raw: query || `/${rawParts.join(' ')}`, name: leafCommand.name, args: argString, }, @@ -306,7 +307,13 @@ function useCommandSuggestions( setSuggestions([]); return () => abortController.abort(); - }, [parserResult, commandContext, getFzfForCommands, getPrefixSuggestions]); + }, [ + query, + parserResult, + commandContext, + getFzfForCommands, + getPrefixSuggestions, + ]); return { suggestions, isLoading }; } @@ -475,6 +482,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { // Use extracted hooks for better separation of concerns const parserResult = useCommandParser(query, slashCommands); const { suggestions: hookSuggestions, isLoading } = useCommandSuggestions( + query, parserResult, commandContext, getFzfForCommands, @@ -503,7 +511,11 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { return; } - setSuggestions(hookSuggestions); + if (isPerfectMatch) { + setSuggestions([]); + } else { + setSuggestions(hookSuggestions); + } setIsLoadingSuggestions(isLoading); setIsPerfectMatch(isPerfectMatch); setCompletionStart(calculatedStart); diff --git a/packages/cli/src/ui/hooks/useTimer.test.tsx b/packages/cli/src/ui/hooks/useTimer.test.tsx index 475116086b..e8ebad7aec 100644 --- a/packages/cli/src/ui/hooks/useTimer.test.tsx +++ b/packages/cli/src/ui/hooks/useTimer.test.tsx @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { render } from '../../test-utils/render.js'; import { useTimer } from './useTimer.js'; describe('useTimer', () => { diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 59896ea487..4eb4948d99 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -407,7 +407,9 @@ describe('useReactToolScheduler', () => { ]); // Clean up the pending promise to avoid open handles. - resolveExecute({ llmContent: 'output', returnDisplay: 'display' }); + await act(async () => { + resolveExecute({ llmContent: 'output', returnDisplay: 'display' }); + }); }); it('should handle tool not found', async () => { @@ -854,7 +856,9 @@ describe('useReactToolScheduler', () => { await vi.advanceTimersByTimeAsync(0); }); - schedule(request2, new AbortController().signal); + act(() => { + schedule(request2, new AbortController().signal); + }); await act(async () => { await vi.advanceTimersByTimeAsync(50); diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index b767d04cb8..d3cec9cc13 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -7,7 +7,8 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import type React from 'react'; import { act } from 'react'; -import { render } from 'ink-testing-library'; +import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useVim } from './vim.js'; import type { VimMode } from './vim.js'; import type { Key } from './useKeypress.js'; @@ -175,25 +176,10 @@ describe('useVim hook', () => { }; }; - const renderVimHook = (buffer?: Partial) => { - let hookResult: ReturnType; - function TestComponent() { - hookResult = useVim( - (buffer || mockBuffer) as TextBuffer, - mockHandleFinalSubmit, - ); - return null; - } - const { rerender } = render(); - return { - result: { - get current() { - return hookResult; - }, - }, - rerender: () => rerender(), - }; - }; + const renderVimHook = (buffer?: Partial) => + renderHook(() => + useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit), + ); const exitInsertMode = (result: { current: { @@ -1307,7 +1293,7 @@ describe('useVim hook', () => { mockVimContext.vimMode = 'INSERT'; const { result } = renderVimHook(); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.mode).toBe('INSERT'); }); @@ -1323,7 +1309,7 @@ describe('useVim hook', () => { const emptyBuffer = createMockBuffer(''); const { result } = renderVimHook(emptyBuffer); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.mode).toBe('INSERT'); }); @@ -1337,7 +1323,7 @@ describe('useVim hook', () => { const nonEmptyBuffer = createMockBuffer('not empty'); const { result } = renderVimHook(nonEmptyBuffer); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.mode).toBe('INSERT'); }); diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index 6d8930d9c6..67c997c0fc 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -4,9 +4,61 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { vi, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; + +global.IS_REACT_ACT_ENVIRONMENT = true; + // Unset NO_COLOR environment variable to ensure consistent theme behavior between local and CI test runs if (process.env.NO_COLOR !== undefined) { delete process.env.NO_COLOR; } import './src/test-utils/customMatchers.js'; + +let consoleErrorSpy: vi.SpyInstance; +let actWarnings: Array<{ message: string; stack: string }> = []; + +beforeEach(() => { + actWarnings = []; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0]; + if ( + typeof firstArg === 'string' && + firstArg.includes('was not wrapped in act(...)') + ) { + const stackLines = (new Error().stack || '').split('\n'); + let lastReactFrameIndex = -1; + + // Find the index of the last frame that comes from react-reconciler + for (let i = 0; i < stackLines.length; i++) { + if (stackLines[i].includes('react-reconciler')) { + lastReactFrameIndex = i; + } + } + + // If we found react-reconciler frames, start the stack trace after the last one. + // Otherwise, just strip the first line (which is the Error message itself). + const relevantStack = + lastReactFrameIndex !== -1 + ? stackLines.slice(lastReactFrameIndex + 1).join('\n') + : stackLines.slice(1).join('\n'); + + actWarnings.push({ + message: format(...args), + stack: relevantStack, + }); + } + }); +}); + +afterEach(() => { + consoleErrorSpy.mockRestore(); + + if (actWarnings.length > 0) { + const messages = actWarnings + .map(({ message, stack }) => `${message}\n${stack}`) + .join('\n\n'); + throw new Error(`Failing test due to "act(...)" warnings:\n${messages}`); + } +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index aeac3ad329..24f73f45d4 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -12,13 +12,16 @@ import * as path from 'node:path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + conditions: ['test'], + }, test: { - include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], + include: ['**/*.{test,spec}.{js,ts,jsx,tsx}', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], environment: 'node', globals: true, reporters: ['default', 'junit'], - silent: true, + outputFile: { junit: 'junit.xml', },