Fix tests to wrap all calls changing the UI with act. (#12268)

This commit is contained in:
Jacob Richman
2025-10-30 11:50:26 -07:00
committed by GitHub
parent cc081337b7
commit 54fa26ef0e
69 changed files with 2002 additions and 1291 deletions
-1
View File
@@ -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,
+34
View File
@@ -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<void> {
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));
});
}
}
}
+34 -4
View File
@@ -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(<Text>Hello World</Text>);
expect(lastFrame()).toBe('Hello World');
});
it('should support rerender', () => {
const { lastFrame, rerender } = render(<Text>Hello</Text>);
expect(lastFrame()).toBe('Hello');
rerender(<Text>World</Text>);
expect(lastFrame()).toBe('World');
});
it('should support unmount', () => {
const cleanup = vi.fn();
function TestComponent() {
useEffect(() => cleanup, []);
return <Text>Hello</Text>;
}
const { unmount } = render(<TestComponent />);
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', () => {
+29 -1
View File
@@ -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<typeof inkRender> => {
let renderResult: ReturnType<typeof inkRender> =
undefined as unknown as ReturnType<typeof inkRender>;
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: () =>
+1 -1
View File
@@ -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';
+332 -226
View File
@@ -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<typeof vi.fn> };
@@ -323,53 +324,59 @@ describe('AppContainer State Management', () => {
});
describe('Basic Rendering', () => {
it('renders without crashing with minimal props', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
it('renders without crashing with minimal props', async () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
startupWarnings={startupWarnings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
startupWarnings={startupWarnings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={initResultWithError}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={initResultWithError}
/>,
);
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(
<AppContainer
config={mockConfig}
@@ -399,53 +406,62 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
it('provides UIStateContext with state management', async () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
it('provides UIActionsContext with action handlers', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
it('provides UIActionsContext with action handlers', async () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
unmount();
});
it('provides ConfigContext with config object', () => {
expect(() => {
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
it('provides ConfigContext with config object', async () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={settingsAllHidden}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={settingsAllHidden}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={settingsWithMemory}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={settingsWithMemory}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version={version}
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
async (version) => {
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version={version}
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={errorConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={errorConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={undefinedSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
}).not.toThrow();
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={undefinedSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -574,12 +600,16 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -600,12 +630,16 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -622,13 +656,19 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1001,10 +1041,15 @@ describe('AppContainer State Management', () => {
initializationResult={mockInitResult}
/>,
);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(capturedUIState.queueErrorMessage).toBeNull();
capturedUIActions.setQueueErrorMessage('Test error');
act(() => {
capturedUIActions.setQueueErrorMessage('Test error');
});
rerender(
<AppContainer
config={mockConfig}
@@ -1015,7 +1060,9 @@ describe('AppContainer State Management', () => {
);
expect(capturedUIState.queueErrorMessage).toBe('Test error');
vi.advanceTimersByTime(3000);
act(() => {
vi.advanceTimersByTime(3000);
});
rerender(
<AppContainer
config={mockConfig}
@@ -1025,10 +1072,11 @@ describe('AppContainer State Management', () => {
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1036,8 +1084,13 @@ describe('AppContainer State Management', () => {
initializationResult={mockInitResult}
/>,
);
await act(async () => {
vi.advanceTimersByTime(0);
});
capturedUIActions.setQueueErrorMessage('First error');
act(() => {
capturedUIActions.setQueueErrorMessage('First error');
});
rerender(
<AppContainer
config={mockConfig}
@@ -1048,9 +1101,13 @@ describe('AppContainer State Management', () => {
);
expect(capturedUIState.queueErrorMessage).toBe('First error');
vi.advanceTimersByTime(1500);
act(() => {
vi.advanceTimersByTime(1500);
});
capturedUIActions.setQueueErrorMessage('Second error');
act(() => {
capturedUIActions.setQueueErrorMessage('Second error');
});
rerender(
<AppContainer
config={mockConfig}
@@ -1061,7 +1118,9 @@ describe('AppContainer State Management', () => {
);
expect(capturedUIState.queueErrorMessage).toBe('Second error');
vi.advanceTimersByTime(2000);
act(() => {
vi.advanceTimersByTime(2000);
});
rerender(
<AppContainer
config={mockConfig}
@@ -1073,7 +1132,9 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
@@ -1083,6 +1144,7 @@ describe('AppContainer State Management', () => {
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1114,6 +1176,9 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1141,9 +1208,12 @@ describe('AppContainer State Management', () => {
initializationResult={mockInitResult}
/>,
);
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () =>
inkRerender(
renderResult.rerender(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1151,17 +1221,20 @@ describe('AppContainer State Management', () => {
initializationResult={mockInitResult}
/>,
);
unmount = renderResult.unmount;
};
const pressKey = (key: Partial<Key>, 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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
// 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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
expect(mockCoreEvents.on).toHaveBeenCalledWith(
CoreEvent.UserFeedback,
expect.any(Function),
);
expect(mockCoreEvents.drainFeedbackBacklog).toHaveBeenCalledTimes(1);
});
it('unsubscribes from UserFeedback on unmount', () => {
const { unmount } = render(
<AppContainer
config={mockConfig}
@@ -1355,6 +1383,78 @@ describe('AppContainer State Management', () => {
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
@@ -1373,6 +1473,9 @@ describe('AppContainer State Management', () => {
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();
});
});
});
@@ -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';
@@ -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';
@@ -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';
@@ -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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
@@ -48,11 +49,12 @@ describe('ConsentPrompt', () => {
},
undefined,
);
unmount();
});
it('renders a ReactNode prompt directly', () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
@@ -62,11 +64,12 @@ describe('ConsentPrompt', () => {
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
@@ -75,14 +78,17 @@ describe('ConsentPrompt', () => {
);
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
@@ -91,14 +97,17 @@ describe('ConsentPrompt', () => {
);
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
@@ -115,5 +124,6 @@ describe('ConsentPrompt', () => {
}),
undefined,
);
unmount();
});
});
@@ -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('<ContextSummaryDisplay />', () => {
};
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('<ContextSummaryDisplay />', () => {
];
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('<ContextSummaryDisplay />', () => {
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();
});
});
@@ -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();
});
});
+15 -5
View File
@@ -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('<Header />', () => {
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
const { lastFrame, unmount } = render(
<Header version="1.0.0" nightly={false} />,
);
expect(lastFrame()).toContain(longAsciiLogo);
unmount();
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
unmount();
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
const { lastFrame, unmount } = render(
<Header version="1.0.0" nightly={true} />,
);
expect(lastFrame()).toContain('v1.0.0');
unmount();
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
const { lastFrame, unmount } = render(
<Header version="1.0.0" nightly={false} />,
);
expect(lastFrame()).not.toContain('v1.0.0');
unmount();
});
});
+5 -3
View File
@@ -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(<Help commands={mockCommands} />);
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('/test');
expect(output).not.toContain('/hidden');
unmount();
});
it('should not render hidden subcommands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const { lastFrame, unmount } = render(<Help commands={mockCommands} />);
const output = lastFrame();
expect(output).toContain('visible-child');
expect(output).not.toContain('hidden-child');
unmount();
});
});
@@ -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(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
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(<InputPrompt {...props} />);
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', () => {
<InputPrompt {...props} />,
);
await vi.waitFor(() => {
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain(expected);
});
@@ -1147,7 +1143,7 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />,
);
await vi.waitFor(() => {
await waitFor(() => {
const frame = stdout.lastFrame();
expect(frame).toContain(expected);
});
@@ -1171,7 +1167,7 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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(
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
{ 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(
<InputPrompt {...props} />,
);
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', () => {
<InputPrompt {...props} />,
{ 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(
<InputPrompt {...props} />,
);
await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
unmount();
});
@@ -2103,7 +2119,7 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
unmount();
});
@@ -2112,7 +2128,7 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await vi.waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
unmount();
});
@@ -2122,7 +2138,7 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
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();
@@ -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('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', () => {
@@ -108,11 +109,12 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Working...',
elapsedTime: 60,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
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('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Working...',
elapsedTime: 125,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
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(
<LoadingIndicator {...defaultProps} />,
StreamingState.Idle,
);
@@ -179,6 +183,7 @@ describe('<LoadingIndicator />', () => {
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe('');
unmount();
});
it('should display fallback phrase if thought is empty', () => {
@@ -187,12 +192,13 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('Loading...');
unmount();
});
it('should display the subject of a thought', () => {
@@ -203,7 +209,7 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
@@ -213,6 +219,7 @@ describe('<LoadingIndicator />', () => {
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('<LoadingIndicator />', () => {
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame } = renderWithContext(
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator {...props} />,
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(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
@@ -246,11 +254,12 @@ describe('<LoadingIndicator />', () => {
);
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(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -264,10 +273,11 @@ describe('<LoadingIndicator />', () => {
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(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -288,24 +298,27 @@ describe('<LoadingIndicator />', () => {
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(
<LoadingIndicator {...defaultProps} />,
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(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
});
});
@@ -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('<ModelDialog />', () => {
});
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('<ModelDialog />', () => {
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('<ModelDialog />', () => {
}),
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('<ModelDialog />', () => {
}),
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('<ModelDialog />', () => {
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('<ModelDialog />', () => {
// 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('<ModelDialog />', () => {
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(
<ConfigContext.Provider
value={{ getModel: mockGetModel } as unknown as Config}
>
@@ -219,5 +228,6 @@ describe('<ModelDialog />', () => {
// 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();
});
});
@@ -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';
@@ -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', () => {
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
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', () => {
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
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();
});
@@ -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(
<PrepareLabel
label="simple command"
userInput=""
@@ -23,11 +23,12 @@ describe('PrepareLabel', () => {
/>,
);
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(
<PrepareLabel
label={long}
userInput=""
@@ -40,11 +41,12 @@ describe('PrepareLabel', () => {
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(
<PrepareLabel
label={long}
userInput=""
@@ -56,13 +58,14 @@ describe('PrepareLabel', () => {
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(
<PrepareLabel
label={label}
userInput={userInput}
@@ -72,6 +75,7 @@ describe('PrepareLabel', () => {
/>,
);
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(
<PrepareLabel
label={label}
userInput={core}
@@ -95,6 +99,7 @@ describe('PrepareLabel', () => {
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(
<PrepareLabel
label={label}
userInput={core}
@@ -119,5 +124,6 @@ describe('PrepareLabel', () => {
expect(f.endsWith('...')).toBe(true);
expect(f.length).toBe(MAX_WIDTH + 2);
expect(out).toMatchSnapshot();
unmount();
});
});
@@ -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(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -49,11 +50,12 @@ describe('ProQuotaDialog', () => {
}),
undefined,
);
unmount();
});
it('should call onChoice with "auth" when "Change auth" is selected', () => {
const mockOnChoice = vi.fn();
render(
const { unmount } = render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -65,14 +67,17 @@ describe('ProQuotaDialog', () => {
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(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -84,8 +89,11 @@ describe('ProQuotaDialog', () => {
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
// Simulate the selection
onSelect('continue');
act(() => {
onSelect('continue');
});
expect(mockOnChoice).toHaveBeenCalledWith('continue');
unmount();
});
});
@@ -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(<QueuedMessageDisplay messageQueue={[]} />);
const { lastFrame, unmount } = render(
<QueuedMessageDisplay messageQueue={[]} />,
);
expect(lastFrame()).toBe('');
unmount();
});
it('displays single queued message', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<QueuedMessageDisplay messageQueue={['First message']} />,
);
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(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
@@ -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(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
@@ -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(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
expect(output).toContain('Message with multiple whitespace');
unmount();
});
});
@@ -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';
@@ -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',
@@ -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';
@@ -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();
});
});
@@ -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';
@@ -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) │
│ │
@@ -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('<CompressionMessage />', () => {
describe('pending state', () => {
it('renders pending message when compression is in progress', () => {
const props = createCompressionProps({ isPending: true });
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(<CompressionMessage {...props} />);
const output = lastFrame();
expect(output).toContain('Compressing chat history');
unmount();
});
});
@@ -42,13 +43,14 @@ describe('<CompressionMessage />', () => {
newTokenCount: 50,
compressionStatus: CompressionStatus.COMPRESSED,
});
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(<CompressionMessage {...props} />);
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('<CompressionMessage />', () => {
{ 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(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
@@ -73,7 +77,8 @@ describe('<CompressionMessage />', () => {
);
expect(output).not.toContain('Skipping compression');
expect(output).not.toContain('did not reduce size');
});
unmount();
}
});
});
@@ -86,13 +91,14 @@ describe('<CompressionMessage />', () => {
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(<CompressionMessage {...props} />);
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('<CompressionMessage />', () => {
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(<CompressionMessage {...props} />);
const output = lastFrame();
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
unmount();
});
});
@@ -132,18 +139,21 @@ describe('<CompressionMessage />', () => {
},
];
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(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(
<CompressionMessage {...props} />,
);
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('<CompressionMessage />', () => {
{ 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('<CompressionMessage />', () => {
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(
<CompressionMessage {...props} />,
);
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('<CompressionMessage />', () => {
{ 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('<CompressionMessage />', () => {
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
const { lastFrame } = render(<CompressionMessage {...props} />);
const { lastFrame, unmount } = render(
<CompressionMessage {...props} />,
);
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();
}
});
});
});
@@ -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';
@@ -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';
@@ -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('<ToolGroupMessage />', () => {
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders multiple tool calls with different statuses', () => {
@@ -125,10 +126,11 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Error,
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders tool call awaiting confirmation', () => {
@@ -146,10 +148,11 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders shell command with yellow border', () => {
@@ -161,10 +164,11 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders mixed tool calls including shell command', () => {
@@ -188,10 +192,11 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Pending,
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with limited terminal height', () => {
@@ -210,7 +215,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: 'More output here',
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@@ -218,11 +223,12 @@ describe('<ToolGroupMessage />', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@@ -230,6 +236,7 @@ describe('<ToolGroupMessage />', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with narrow terminal width', () => {
@@ -240,7 +247,7 @@ describe('<ToolGroupMessage />', () => {
'This is a very long description that might cause wrapping issues',
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@@ -248,24 +255,27 @@ describe('<ToolGroupMessage />', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders empty tool calls array', () => {
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
);
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(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// 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('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('uses gray border when all tools are successful and no shell commands', () => {
@@ -290,10 +301,11 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -313,7 +325,7 @@ describe('<ToolGroupMessage />', () => {
resultDisplay: '', // No result
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
@@ -321,6 +333,7 @@ describe('<ToolGroupMessage />', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -350,11 +363,12 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const { lastFrame } = renderWithProviders(
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
@@ -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(<BaseSelectionList {...componentProps} />);
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');
@@ -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('<MaxSizedBox />', () => {
setMaxSizedBoxDebugging(true);
it('renders children without truncation when they fit', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
@@ -28,10 +28,11 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
expect(lastFrame()).equals('Hello, World!');
unmount();
});
it('hides lines when content exceeds maxHeight', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
@@ -48,10 +49,11 @@ describe('<MaxSizedBox />', () => {
);
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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={10} maxHeight={5}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={20} maxHeight={20}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
@@ -143,10 +148,11 @@ istic
expia
lidoc
ious`);
unmount();
});
it('does not truncate when maxHeight is undefined', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={2} maxHeight={2}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={3}>
<Box>
@@ -329,10 +345,11 @@ Line 3`);
);
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
unmount();
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
@@ -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(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Box>
@@ -421,5 +441,6 @@ Line 3 direct child`);
].join('\n');
expect(lastFrame()).equals(expected);
unmount();
});
});
@@ -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';
@@ -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('<ChatList />', () => {
it('renders correctly with a list of chats', () => {
const { lastFrame } = render(<ChatList chats={mockChats} />);
const { lastFrame, unmount } = render(<ChatList chats={mockChats} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with no chats', () => {
const { lastFrame } = render(<ChatList chats={[]} />);
const { lastFrame, unmount } = render(<ChatList chats={[]} />);
expect(lastFrame()).toContain('No saved conversation checkpoints found.');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('handles invalid date formats gracefully', () => {
@@ -39,8 +41,11 @@ describe('<ChatList />', () => {
mtime: 'an-invalid-date-string',
},
];
const { lastFrame } = render(<ChatList chats={mockChatsWithInvalidDate} />);
const { lastFrame, unmount } = render(
<ChatList chats={mockChatsWithInvalidDate} />,
);
expect(lastFrame()).toContain('(Invalid Date)');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -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('<ExtensionsList />', () => {
it('should render "No extensions installed." if there are no extensions', () => {
mockUIState(new Map());
const { lastFrame } = render(<ExtensionsList extensions={[]} />);
const { lastFrame, unmount } = render(<ExtensionsList extensions={[]} />);
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(
<ExtensionsList extensions={mockExtensions} />,
);
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(
<ExtensionsList extensions={[mockExtensions[0]]} />,
);
expect(lastFrame()).toContain('(unknown state)');
unmount();
});
const stateTestCases = [
@@ -115,10 +118,11 @@ describe('<ExtensionsList />', () => {
it(`should correctly display the state: ${state}`, () => {
const updateState = new Map([[mockExtensions[0].name, state]]);
mockUIState(updateState);
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<ExtensionsList extensions={[mockExtensions[0]]} />,
);
expect(lastFrame()).toContain(expectedText);
unmount();
});
}
});
@@ -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(<McpStatus {...baseProps} />);
const { lastFrame, unmount } = render(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with authenticated OAuth status', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'authenticated' }} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with expired OAuth status', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'expired' }} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with unauthenticated OAuth status', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus
{...baseProps}
authStatus={{ 'server-1': 'unauthenticated' }}
/>,
);
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(<McpStatus {...baseProps} />);
const { lastFrame, unmount } = render(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly when discovery is in progress', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus {...baseProps} discoveryInProgress={true} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with schema enabled', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus {...baseProps} showSchema={true} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with parametersJsonSchema', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus
{...baseProps}
tools={[
@@ -120,10 +127,11 @@ describe('McpStatus', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with prompts', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus
{...baseProps}
prompts={[
@@ -136,22 +144,25 @@ describe('McpStatus', () => {
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with a blocked server', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus
{...baseProps}
blockedServers={[{ name: 'server-1', extensionName: 'test-extension' }]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with a connecting server', () => {
const { lastFrame } = render(
const { lastFrame, unmount } = render(
<McpStatus {...baseProps} connectingServers={['server-1']} />,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
@@ -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',
@@ -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<typeof useSessionStats> | undefined
> = { current: undefined };
render(
const { unmount } = render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
@@ -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<typeof useSessionStats> | undefined
> = { current: undefined };
render(
const { unmount } = render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
@@ -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(
<SessionStatsProvider>
<CountingTestHarness />
</SessionStatsProvider>,
@@ -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(
<ErrorBoundary onError={onError}>
<TestHarness contextRef={{ current: undefined }} />
</ErrorBoundary>,
@@ -248,5 +251,6 @@ describe('SessionStatsContext', () => {
);
consoleSpy.mockRestore();
unmount();
});
});
@@ -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<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: { execute: mockShellExecutionService },
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
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'));
});
@@ -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<void>) | 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<typeof useSlashCommandProcessor>;
let result!: { current: ReturnType<typeof useSlashCommandProcessor> };
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(<TestComponent />);
unmountHook = async () => unmount();
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
});
return {
get current() {
return hookResult;
return result.current;
},
unmount,
rerender: () => rerender(<TestComponent />),
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<false | SlashCommandProcessorResult>
| 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<false | SlashCommandProcessorResult>
| 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 () => {
@@ -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);
});
@@ -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);
});
@@ -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', () => {
@@ -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';
@@ -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(<TestComponent />);
await vi.waitFor(() => {
await waitFor(() => {
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
@@ -152,7 +153,7 @@ describe('useExtensionUpdates', () => {
render(<TestComponent />);
await vi.waitFor(
await waitFor(
() => {
expect(addItem).toHaveBeenCalledWith(
{
@@ -230,7 +231,7 @@ describe('useExtensionUpdates', () => {
render(<TestComponent />);
await vi.waitFor(
await waitFor(
() => {
expect(addItem).toHaveBeenCalledTimes(2);
expect(addItem).toHaveBeenCalledWith(
@@ -313,7 +314,7 @@ describe('useExtensionUpdates', () => {
render(<TestComponent />);
await vi.waitFor(() => {
await waitFor(() => {
expect(addItem).toHaveBeenCalledTimes(1);
expect(addItem).toHaveBeenCalledWith(
{
+1 -1
View File
@@ -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';
@@ -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
});
@@ -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();
});
});
@@ -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),
@@ -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<typeof useIdeTrustListener>;
function TestComponent() {
hookResult = useIdeTrustListener();
return null;
}
const { rerender } = render(<TestComponent />);
const { rerender, unmount } = render(<TestComponent />);
// 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(<TestComponent />),
rerender: async () => {
rerender(<TestComponent />);
},
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();
});
});
@@ -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';
@@ -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 {
@@ -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,
@@ -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);
});
@@ -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(<TestComponent />);
const { unmount } = render(<TestComponent />);
expect(result.isModelDialogOpen).toBe(false);
unmount();
});
it('should open the model dialog when openModelDialog is called', () => {
render(<TestComponent />);
const { unmount } = render(<TestComponent />);
act(() => {
result.openModelDialog();
});
expect(result.isModelDialogOpen).toBe(true);
unmount();
});
it('should close the model dialog when closeModelDialog is called', () => {
render(<TestComponent />);
const { unmount } = render(<TestComponent />);
// Open it first
act(() => {
@@ -46,5 +48,6 @@ describe('useModelCommand', () => {
result.closeModelDialog();
});
expect(result.isModelDialogOpen).toBe(false);
unmount();
});
});
@@ -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', () => {
<TestComponent isActive={true} isWaiting={false} />,
);
rerender(<TestComponent isActive={true} isWaiting={true} />);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(lastFrame()).toBe('Waiting for user confirmation...');
});
@@ -59,7 +61,9 @@ describe('usePhraseCycler', () => {
<TestComponent isActive={false} isWaiting={false} />,
);
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', () => {
<TestComponent isActive={true} isWaiting={false} />,
);
// 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(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
);
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(
<TestComponent isActive={true} isWaiting={false} customPhrases={[]} />,
);
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(
<TestComponent isActive={true} isWaiting={false} />,
);
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(<TestComponent isActive={false} isWaiting={true} />);
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(<TestComponent isActive={true} isWaiting={false} />);
await vi.advanceTimersByTimeAsync(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
});
});
@@ -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);
});
@@ -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<FallbackIntent | null>;
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<FallbackIntent | null>;
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<FallbackIntent | null>;
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<FallbackIntent | null>;
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();
@@ -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<SelectionListItem<string>>;
onSelect: (item: string) => void;
onHighlight?: (item: string) => void;
@@ -87,23 +88,26 @@ describe('useSelectionList', () => {
return hookResult;
},
},
rerender: (newProps: Partial<typeof initialProps>) =>
rerender(<TestComponent {...initialProps} {...newProps} />),
unmount,
rerender: async (newProps: Partial<typeof initialProps>) => {
rerender(<TestComponent {...initialProps} {...newProps} />);
},
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<SelectionListItem<string>>;
}) => {
function TestComponent(props: typeof initialProps) {
@@ -904,12 +908,13 @@ describe('useSelectionList', () => {
}
const { rerender } = render(<TestComponent {...initialProps} />);
return {
rerender: (newProps: Partial<typeof initialProps>) =>
rerender(<TestComponent {...initialProps} {...newProps} />),
rerender: async (newProps: Partial<typeof initialProps>) => {
rerender(<TestComponent {...initialProps} {...newProps} />);
},
};
};
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<SelectionListItem<string>> = 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);
@@ -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();
});
});
@@ -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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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<typeof useTestHarnessForSlashCompletion>;
};
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();
});
});
@@ -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);
+1 -1
View File
@@ -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', () => {
@@ -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);
+9 -23
View File
@@ -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<TextBuffer>) => {
let hookResult: ReturnType<typeof useVim>;
function TestComponent() {
hookResult = useVim(
(buffer || mockBuffer) as TextBuffer,
mockHandleFinalSubmit,
);
return null;
}
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: () => rerender(<TestComponent />),
};
};
const renderVimHook = (buffer?: Partial<TextBuffer>) =>
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');
});
+52
View File
@@ -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}`);
}
});
+5 -2
View File
@@ -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',
},