From ee66732ad258f097455ca0664b7084a88a4586d1 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Sat, 25 Oct 2025 14:41:53 -0700 Subject: [PATCH] First batch of fixing tests to use best practices. (#11964) --- packages/cli/src/config/extension.test.ts | 8 +- .../cli/src/config/extensions/update.test.ts | 2 + packages/cli/src/gemini.test.tsx | 3 +- .../ui/components/FolderTrustDialog.test.tsx | 2 + .../src/ui/components/InputPrompt.test.tsx | 10 +- .../src/ui/components/ModelDialog.test.tsx | 2 + .../PermissionsModifyTrustDialog.test.tsx | 2 + .../src/ui/components/SettingsDialog.test.tsx | 2 + .../src/ui/components/ThemeDialog.test.tsx | 3 +- .../__snapshots__/InputPrompt.test.tsx.snap | 30 - .../shared/BaseSelectionList.test.tsx | 2 + .../ui/components/shared/text-buffer.test.ts | 2 + .../src/ui/contexts/KeypressContext.test.tsx | 2 + .../src/ui/contexts/SessionContext.test.tsx | 2 + ...test.ts => shellCommandProcessor.test.tsx} | 24 +- ...test.ts => slashCommandProcessor.test.tsx} | 175 +++-- .../ui/hooks/useAutoAcceptIndicator.test.ts | 2 + ....test.ts => useCommandCompletion.test.tsx} | 369 +++------- ...es.test.ts => useConsoleMessages.test.tsx} | 35 +- ...ngs.test.ts => useEditorSettings.test.tsx} | 98 ++- ...s.test.ts => useExtensionUpdates.test.tsx} | 48 +- .../src/ui/hooks/useFlickerDetector.test.ts | 2 + .../{useFocus.test.ts => useFocus.test.tsx} | 40 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 2 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 2 + ...Name.test.ts => useGitBranchName.test.tsx} | 40 +- .../src/ui/hooks/useHistoryManager.test.ts | 2 + ...r.test.ts => useIdeTrustListener.test.tsx} | 32 +- .../cli/src/ui/hooks/useInputHistory.test.ts | 2 + .../src/ui/hooks/useInputHistoryStore.test.ts | 2 + ...eKeypress.test.ts => useKeypress.test.tsx} | 64 +- ...r.test.ts => useLoadingIndicator.test.tsx} | 52 +- ...itor.test.ts => useMemoryMonitor.test.tsx} | 15 +- ...Queue.test.ts => useMessageQueue.test.tsx} | 231 +++---- .../cli/src/ui/hooks/useModelCommand.test.ts | 42 -- .../cli/src/ui/hooks/useModelCommand.test.tsx | 50 ++ .../hooks/usePermissionsModifyTrust.test.ts | 2 + .../cli/src/ui/hooks/usePhraseCycler.test.ts | 2 + ...gs.test.ts => usePrivacySettings.test.tsx} | 36 +- .../src/ui/hooks/useQuotaAndFallback.test.ts | 2 + .../ui/hooks/useReactToolScheduler.test.ts | 2 + ...List.test.ts => useSelectionList.test.tsx} | 651 ++++++++---------- .../cli/src/ui/hooks/useShellHistory.test.ts | 2 + .../{useTimer.test.ts => useTimer.test.tsx} | 81 ++- .../cli/src/ui/hooks/useToolScheduler.test.ts | 2 + .../ui/hooks/{vim.test.ts => vim.test.tsx} | 45 +- packages/cli/vitest.config.ts | 9 +- .../src/agents/subagent-tool-wrapper.test.ts | 6 +- 48 files changed, 1128 insertions(+), 1113 deletions(-) rename packages/cli/src/ui/hooks/{shellCommandProcessor.test.ts => shellCommandProcessor.test.tsx} (98%) rename packages/cli/src/ui/hooks/{slashCommandProcessor.test.ts => slashCommandProcessor.test.tsx} (90%) rename packages/cli/src/ui/hooks/{useCommandCompletion.test.ts => useCommandCompletion.test.tsx} (65%) rename packages/cli/src/ui/hooks/{useConsoleMessages.test.ts => useConsoleMessages.test.tsx} (79%) rename packages/cli/src/ui/hooks/{useEditorSettings.test.ts => useEditorSettings.test.tsx} (68%) rename packages/cli/src/ui/hooks/{useExtensionUpdates.test.ts => useExtensionUpdates.test.tsx} (93%) rename packages/cli/src/ui/hooks/{useFocus.test.ts => useFocus.test.tsx} (82%) rename packages/cli/src/ui/hooks/{useGitBranchName.test.ts => useGitBranchName.test.tsx} (85%) rename packages/cli/src/ui/hooks/{useIdeTrustListener.test.ts => useIdeTrustListener.test.tsx} (90%) rename packages/cli/src/ui/hooks/{useKeypress.test.ts => useKeypress.test.tsx} (83%) rename packages/cli/src/ui/hooks/{useLoadingIndicator.test.ts => useLoadingIndicator.test.tsx} (77%) rename packages/cli/src/ui/hooks/{useMemoryMonitor.test.ts => useMemoryMonitor.test.tsx} (87%) rename packages/cli/src/ui/hooks/{useMessageQueue.test.ts => useMessageQueue.test.tsx} (69%) delete mode 100644 packages/cli/src/ui/hooks/useModelCommand.test.ts create mode 100644 packages/cli/src/ui/hooks/useModelCommand.test.tsx rename packages/cli/src/ui/hooks/{usePrivacySettings.test.ts => usePrivacySettings.test.tsx} (81%) rename packages/cli/src/ui/hooks/{useSelectionList.test.ts => useSelectionList.test.tsx} (64%) rename packages/cli/src/ui/hooks/{useTimer.test.ts => useTimer.test.tsx} (59%) rename packages/cli/src/ui/hooks/{vim.test.ts => vim.test.tsx} (98%) diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7f0e4e2f02..f701e3cb3e 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { vi, type MockedFunction } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -460,8 +462,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledOnce(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), @@ -492,8 +493,7 @@ describe('extension tests', () => { expect(extensions).toHaveLength(1); expect(extensions[0].name).toBe('good-ext'); - expect(consoleSpy).toHaveBeenCalledOnce(); - expect(consoleSpy).toHaveBeenCalledWith( + expect(consoleSpy).toHaveBeenCalledExactlyOnceWith( expect.stringContaining( `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 176e7ad3fa..66bf99fabc 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { vi, type MockedFunction } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index e1c04e2cfd..8be78561b9 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -377,8 +377,7 @@ describe('validateDnsResolutionOrder', () => { it('should return the default "ipv4first" and log a warning for an invalid string', () => { expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); - expect(consoleWarnSpy).toHaveBeenCalledOnce(); - expect(consoleWarnSpy).toHaveBeenCalledWith( + expect(consoleWarnSpy).toHaveBeenCalledExactlyOnceWith( 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', ); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 11676cf2f6..77280be320 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor, act } from '@testing-library/react'; import { vi } from 'vitest'; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 33c53b8e2f..3da977c409 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -5,7 +5,7 @@ */ import { renderWithProviders } from '../../test-utils/render.js'; -import { act } from '@testing-library/react'; +import { act } from 'react'; import type { InputPromptProps } from './InputPrompt.js'; import { InputPrompt } from './InputPrompt.js'; import type { TextBuffer } from './shared/text-buffer.js'; @@ -1936,7 +1936,7 @@ describe('InputPrompt', () => { await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; - act(() => { + await act(async () => { callback('Message 1\n\nMessage 2\n\nMessage 3'); }); expect(props.buffer.setText).toHaveBeenCalledWith( @@ -1978,7 +1978,7 @@ describe('InputPrompt', () => { }); await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; - act(() => { + await act(async () => { callback(undefined); }); @@ -2021,7 +2021,7 @@ describe('InputPrompt', () => { await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; - act(() => { + await act(async () => { callback('Single message'); }); @@ -2077,7 +2077,7 @@ describe('InputPrompt', () => { await vi.waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled()); const callback = mockPopAllMessages.mock.calls[0][0]; - act(() => { + await act(async () => { callback(undefined); }); diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 33236801ba..0080a03b3d 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { render, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index a88f533820..ed2740c580 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + /// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 908c1f994f..50d32c1871 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + /** * * diff --git a/packages/cli/src/ui/components/ThemeDialog.test.tsx b/packages/cli/src/ui/components/ThemeDialog.test.tsx index 4d5d50032a..0a2f81e858 100644 --- a/packages/cli/src/ui/components/ThemeDialog.test.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.test.tsx @@ -12,7 +12,6 @@ import { KeypressProvider } from '../contexts/KeypressContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js'; import { act } from 'react'; -import { waitFor } from '@testing-library/react'; const createMockSettings = ( userSettings = {}, @@ -127,7 +126,7 @@ describe('ThemeDialog Snapshots', () => { stdin.write('\x1b'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockOnCancel).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 4991f1ac4f..cd2cbb17d2 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -1,23 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-collapsed-match 1`] = ` -"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ (r:) Type your message or @path/to/file │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll - ..." -`; - -exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-expanded-match 1`] = ` -"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ (r:) Type your message or @path/to/file │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll - llllllllllllllllllllllllllllllllllllllllllllllllll" -`; - exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` "╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ (r:) Type your message or @path/to/file │ @@ -38,12 +20,6 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` "╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ > commit │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - -exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 2`] = ` -"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ (r:) commit │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ git commit -m "feat: add search" in src/app" @@ -51,12 +27,6 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` "╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ > commit │ -╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" -`; - -exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 2`] = ` -"╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ (r:) commit │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ git commit -m "feat: add search" in src/app" diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 0d383a8641..bc2fd37db3 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { describe, it, expect, vi, beforeEach } from 'vitest'; import { waitFor } from '@testing-library/react'; import { renderWithProviders } from '../../../test-utils/render.js'; diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 9e56856aca..77013f27b5 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { describe, it, expect, beforeEach } from 'vitest'; import stripAnsi from 'strip-ansi'; import { renderHook, act } from '@testing-library/react'; diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 197974c751..4f1aa42e69 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import type React from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import type { Mock } from 'vitest'; diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index c80262e503..45833ae5ee 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { type MutableRefObject } from 'react'; import { render } from 'ink-testing-library'; import { renderHook } from '@testing-library/react'; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx similarity index 98% rename from packages/cli/src/ui/hooks/shellCommandProcessor.test.ts rename to packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 154dcee6b9..51bf95dbac 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { act, renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { vi, describe, @@ -92,9 +93,10 @@ describe('useShellCommandProcessor', () => { }); }); - const renderProcessorHook = () => - renderHook(() => - useShellCommandProcessor( + const renderProcessorHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useShellCommandProcessor( addItemToHistoryMock, setPendingHistoryItemMock, onExecMock, @@ -102,8 +104,18 @@ describe('useShellCommandProcessor', () => { mockConfig, mockGeminiClient, setShellInputFocusedMock, - ), - ); + ); + return null; + } + render(); + return { + result: { + get current() { + return hookResult; + }, + }, + }; + }; const createMockServiceResult = ( overrides: Partial = {}, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx similarity index 90% rename from packages/cli/src/ui/hooks/slashCommandProcessor.test.ts rename to packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 6016381f26..6707bf3058 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { act, renderHook, waitFor } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import type { CommandContext, @@ -131,8 +132,10 @@ describe('useSlashCommandProcessor', () => { mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands)); - const { result } = renderHook(() => - useSlashCommandProcessor( + let hookResult: ReturnType; + + function TestComponent() { + hookResult = useSlashCommandProcessor( mockConfig, mockSettings, mockAddItem, @@ -159,10 +162,19 @@ describe('useSlashCommandProcessor', () => { }, new Map(), // extensionsUpdateState true, // isConfigInitialized - ), - ); + ); + return null; + } - return result; + const { unmount, rerender } = render(); + + return { + get current() { + return hookResult; + }, + unmount, + rerender: () => rerender(), + }; }; describe('Initialization and Command Loading', () => { @@ -177,7 +189,7 @@ describe('useSlashCommandProcessor', () => { const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.slashCommands).toHaveLength(1); }); @@ -191,7 +203,7 @@ describe('useSlashCommandProcessor', () => { const testCommand = createTestCommand({ name: 'test' }); const result = setupProcessorHook([testCommand]); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.slashCommands).toHaveLength(1); }); @@ -219,7 +231,7 @@ describe('useSlashCommandProcessor', () => { const result = setupProcessorHook([builtinCommand], [fileCommand]); - await waitFor(() => { + await vi.waitFor(() => { // The service should only return one command with the name 'override' expect(result.current.slashCommands).toHaveLength(1); }); @@ -237,7 +249,9 @@ describe('useSlashCommandProcessor', () => { describe('Command Execution Logic', () => { it('should display an error for an unknown command', async () => { const result = setupProcessorHook(); - await waitFor(() => expect(result.current.slashCommands).toBeDefined()); + await vi.waitFor(() => + expect(result.current.slashCommands).toBeDefined(), + ); await act(async () => { await result.current.handleSlashCommand('/nonexistent'); @@ -268,7 +282,9 @@ describe('useSlashCommandProcessor', () => { ], }; const result = setupProcessorHook([parentCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/parent'); @@ -302,7 +318,9 @@ describe('useSlashCommandProcessor', () => { ], }; const result = setupProcessorHook([parentCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/parent child with args'); @@ -348,7 +366,9 @@ describe('useSlashCommandProcessor', () => { setMockIsProcessing, ); - await waitFor(() => expect(result.current.slashCommands).toBeDefined()); + await vi.waitFor(() => + expect(result.current.slashCommands).toBeDefined(), + ); await act(async () => { await result.current.handleSlashCommand('/fail'); @@ -366,7 +386,9 @@ describe('useSlashCommandProcessor', () => { }); const result = setupProcessorHook([command], [], [], mockSetIsProcessing); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); const executionPromise = act(async () => { await result.current.handleSlashCommand('/long-running'); @@ -392,7 +414,9 @@ describe('useSlashCommandProcessor', () => { action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }), }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/themecmd'); @@ -407,7 +431,9 @@ describe('useSlashCommandProcessor', () => { action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'model' }), }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/modelcmd'); @@ -432,7 +458,9 @@ describe('useSlashCommandProcessor', () => { }), }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/load'); @@ -468,7 +496,9 @@ describe('useSlashCommandProcessor', () => { }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/loadwiththoughts'); @@ -488,7 +518,9 @@ describe('useSlashCommandProcessor', () => { }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/exit'); @@ -510,7 +542,9 @@ describe('useSlashCommandProcessor', () => { ); const result = setupProcessorHook([], [fileCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); let actionResult; await act(async () => { @@ -542,7 +576,9 @@ describe('useSlashCommandProcessor', () => { ); const result = setupProcessorHook([], [], [mcpCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); let actionResult; await act(async () => { @@ -584,7 +620,9 @@ describe('useSlashCommandProcessor', () => { it('should set confirmation request when action returns confirm_shell_commands', async () => { const result = setupProcessorHook([shellCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.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. @@ -593,7 +631,7 @@ describe('useSlashCommandProcessor', () => { }); // We now wait for the state to be updated with the request. - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.shellConfirmationRequest).not.toBeNull(); }); @@ -604,14 +642,16 @@ describe('useSlashCommandProcessor', () => { it('should do nothing if user cancels confirmation', async () => { const result = setupProcessorHook([shellCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); act(() => { result.current.handleSlashCommand('/shellcmd'); }); // Wait for the confirmation dialog to be set - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.shellConfirmationRequest).not.toBeNull(); }); @@ -637,12 +677,14 @@ describe('useSlashCommandProcessor', () => { it('should re-run command with one-time allowlist on "Proceed Once"', async () => { const result = setupProcessorHook([shellCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); act(() => { result.current.handleSlashCommand('/shellcmd'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.shellConfirmationRequest).not.toBeNull(); }); @@ -663,7 +705,7 @@ describe('useSlashCommandProcessor', () => { expect(result.current.shellConfirmationRequest).toBeNull(); // The action should have been called twice (initial + re-run). - await waitFor(() => { + await vi.waitFor(() => { expect(mockCommandAction).toHaveBeenCalledTimes(2); }); @@ -691,12 +733,14 @@ describe('useSlashCommandProcessor', () => { it('should re-run command and update session allowlist on "Proceed Always"', async () => { const result = setupProcessorHook([shellCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); act(() => { result.current.handleSlashCommand('/shellcmd'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.shellConfirmationRequest).not.toBeNull(); }); @@ -712,7 +756,7 @@ describe('useSlashCommandProcessor', () => { }); expect(result.current.shellConfirmationRequest).toBeNull(); - await waitFor(() => { + await vi.waitFor(() => { expect(mockCommandAction).toHaveBeenCalledTimes(2); }); @@ -722,7 +766,7 @@ describe('useSlashCommandProcessor', () => { ); // Check that the session-wide allowlist WAS updated. - await waitFor(() => { + await vi.waitFor(() => { const finalContext = result.current.commandContext; expect(finalContext.session.sessionShellAllowlist.has('rm -rf /')).toBe( true, @@ -735,7 +779,9 @@ describe('useSlashCommandProcessor', () => { it('should be case-sensitive', async () => { const command = createTestCommand({ name: 'test' }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { // Use uppercase when command is lowercase @@ -761,7 +807,9 @@ describe('useSlashCommandProcessor', () => { action, }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('/alias'); @@ -777,7 +825,9 @@ describe('useSlashCommandProcessor', () => { const action = vi.fn(); const command = createTestCommand({ name: 'test', action }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand(' /test with-args '); @@ -790,7 +840,9 @@ describe('useSlashCommandProcessor', () => { const action = vi.fn(); const command = createTestCommand({ name: 'help', action }); const result = setupProcessorHook([command]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(1), + ); await act(async () => { await result.current.handleSlashCommand('?help'); @@ -820,7 +872,7 @@ describe('useSlashCommandProcessor', () => { const result = setupProcessorHook([], [fileCommand], [mcpCommand]); - await waitFor(() => { + await vi.waitFor(() => { // The service should only return one command with the name 'override' expect(result.current.slashCommands).toHaveLength(1); }); @@ -856,7 +908,7 @@ describe('useSlashCommandProcessor', () => { // so the test must work regardless of which comes first. const result = setupProcessorHook([quitCommand], [exitCommand]); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.slashCommands).toHaveLength(2); }); @@ -882,7 +934,9 @@ describe('useSlashCommandProcessor', () => { ); const result = setupProcessorHook([quitCommand], [exitCommand]); - await waitFor(() => expect(result.current.slashCommands).toHaveLength(2)); + await vi.waitFor(() => + expect(result.current.slashCommands).toHaveLength(2), + ); await act(async () => { await result.current.handleSlashCommand('/exit'); @@ -899,36 +953,7 @@ describe('useSlashCommandProcessor', () => { describe('Lifecycle', () => { it('should abort command loading when the hook unmounts', () => { const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); - const { unmount } = renderHook(() => - useSlashCommandProcessor( - mockConfig, - mockSettings, - mockAddItem, - mockClearItems, - mockLoadHistory, - vi.fn(), // refreshStatic - vi.fn().mockResolvedValue(false), // toggleVimEnabled - vi.fn(), // setIsProcessing - vi.fn(), // setGeminiMdFileCount - { - openAuthDialog: vi.fn(), - openThemeDialog: vi.fn(), - openEditorDialog: vi.fn(), - openPrivacyNotice: vi.fn(), - openSettingsDialog: vi.fn(), - openModelDialog: vi.fn(), - openPermissionsDialog: vi.fn(), - quit: vi.fn(), - setDebugMessage: vi.fn(), - toggleCorgiMode: vi.fn(), - toggleDebugProfiler: vi.fn(), - dispatchExtensionStateUpdate: vi.fn(), - addConfirmUpdateExtensionRequest: vi.fn(), - }, - new Map(), // extensionsUpdateState - true, // isConfigInitialized - ), - ); + const { unmount } = setupProcessorHook(); unmount(); @@ -972,7 +997,7 @@ describe('useSlashCommandProcessor', () => { it('should log a simple slash command', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -991,7 +1016,7 @@ describe('useSlashCommandProcessor', () => { it('logs nothing for a bogus command', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1003,7 +1028,7 @@ describe('useSlashCommandProcessor', () => { it('logs a failure event for a failed command', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1022,7 +1047,7 @@ describe('useSlashCommandProcessor', () => { it('should log a slash command with a subcommand', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1040,7 +1065,7 @@ describe('useSlashCommandProcessor', () => { it('should log the command path when an alias is used', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { @@ -1056,7 +1081,7 @@ describe('useSlashCommandProcessor', () => { it('should not log for unknown commands', async () => { const result = setupProcessorHook(loggingTestCommands); - await waitFor(() => + await vi.waitFor(() => expect(result.current.slashCommands?.length).toBeGreaterThan(0), ); await act(async () => { diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 2e103ca234..25b515de6b 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { describe, it, diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx similarity index 65% rename from packages/cli/src/ui/hooks/useCommandCompletion.test.ts rename to packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 4cc53f9885..01cf9e8c5d 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, @@ -15,12 +13,12 @@ import { afterEach, type Mock, } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { act, useEffect } from 'react'; +import { render } from 'ink-testing-library'; import { useCommandCompletion } from './useCommandCompletion.js'; import type { CommandContext } from '../commands/types.js'; import type { Config } from '@google/gemini-cli-core'; import { useTextBuffer } from '../components/shared/text-buffer.js'; -import { useEffect } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { UseAtCompletionProps } from './useAtCompletion.js'; import { useAtCompletion } from './useAtCompletion.js'; @@ -93,7 +91,8 @@ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; const mockConfig = { getEnablePromptCompletion: () => false, - } as Config; + getGeminiClient: vi.fn(), + } as unknown as Config; const testDirs: string[] = []; const testRootDir = '/'; @@ -108,6 +107,40 @@ describe('useCommandCompletion', () => { }); } + const renderCommandCompletionHook = ( + initialText: string, + cursorOffset?: number, + shellModeActive = false, + ) => { + let hookResult: ReturnType & { + textBuffer: ReturnType; + }; + + function TestComponent() { + const textBuffer = useTextBufferForTest(initialText, cursorOffset); + const completion = useCommandCompletion( + textBuffer, + testDirs, + testRootDir, + [], + mockCommandContext, + false, + shellModeActive, + mockConfig, + ); + hookResult = { ...completion, textBuffer }; + return null; + } + render(); + return { + result: { + get current() { + return hookResult; + }, + }, + }; + }; + beforeEach(() => { vi.clearAllMocks(); // Reset to default mocks before each test @@ -121,18 +154,7 @@ describe('useCommandCompletion', () => { describe('Core Hook Behavior', () => { describe('State Management', () => { it('should initialize with default state', () => { - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest(''), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook(''); expect(result.current.suggestions).toEqual([]); expect(result.current.activeSuggestionIndex).toBe(-1); @@ -146,26 +168,13 @@ describe('useCommandCompletion', () => { atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@file'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { completion, textBuffer }; + const { result } = renderCommandCompletionHook('@file'); + + await vi.waitFor(() => { + expect(result.current.suggestions).toHaveLength(1); }); - await waitFor(() => { - expect(result.current.completion.suggestions).toHaveLength(1); - }); - - expect(result.current.completion.showSuggestions).toBe(true); + expect(result.current.showSuggestions).toBe(true); act(() => { result.current.textBuffer.replaceRangeByOffset( @@ -175,24 +184,13 @@ describe('useCommandCompletion', () => { ); }); - await waitFor(() => { - expect(result.current.completion.showSuggestions).toBe(false); + await vi.waitFor(() => { + expect(result.current.showSuggestions).toBe(false); }); }); it('should reset all state to default values', () => { - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('@files'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('@files'); act(() => { result.current.setActiveSuggestionIndex(5); @@ -210,20 +208,9 @@ describe('useCommandCompletion', () => { it('should call useAtCompletion with the correct query for an escaped space', async () => { const text = '@src/a\\ file.txt'; - renderHook(() => - useCommandCompletion( - useTextBufferForTest(text), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + renderCommandCompletionHook(text); - await waitFor(() => { + await vi.waitFor(() => { expect(useAtCompletion).toHaveBeenLastCalledWith( expect.objectContaining({ enabled: true, @@ -237,20 +224,9 @@ describe('useCommandCompletion', () => { const text = '@file1 @file2'; const cursorOffset = 3; // @fi|le1 @file2 - renderHook(() => - useCommandCompletion( - useTextBufferForTest(text, cursorOffset), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + renderCommandCompletionHook(text, cursorOffset); - await waitFor(() => { + await vi.waitFor(() => { expect(useAtCompletion).toHaveBeenLastCalledWith( expect.objectContaining({ enabled: true, @@ -286,22 +262,13 @@ describe('useCommandCompletion', () => { slashSuggestions: [{ label: 'clear', value: 'clear' }], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - shellModeActive, // Parameterized shellModeActive - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook( + '/', + undefined, + shellModeActive, + ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(expectedSuggestions); expect(result.current.showSuggestions).toBe( expectedShowSuggestions, @@ -327,18 +294,7 @@ describe('useCommandCompletion', () => { it('should handle navigateUp with no suggestions', () => { setupMocks({ slashSuggestions: [] }); - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); act(() => { result.current.navigateUp(); @@ -349,18 +305,7 @@ describe('useCommandCompletion', () => { it('should handle navigateDown with no suggestions', () => { setupMocks({ slashSuggestions: [] }); - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); act(() => { result.current.navigateDown(); @@ -370,20 +315,9 @@ describe('useCommandCompletion', () => { }); it('should navigate up through suggestions with wrap-around', async () => { - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -397,20 +331,9 @@ describe('useCommandCompletion', () => { }); it('should navigate down through suggestions with wrap-around', async () => { - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -427,20 +350,9 @@ describe('useCommandCompletion', () => { }); it('should handle navigation with multiple suggestions', async () => { - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(5); }); @@ -465,20 +377,9 @@ describe('useCommandCompletion', () => { it('should automatically select the first item when suggestions are available', async () => { setupMocks({ slashSuggestions: mockSuggestions }); - const { result } = renderHook(() => - useCommandCompletion( - useTextBufferForTest('/'), - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ), - ); + const { result } = renderCommandCompletionHook('/'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe( mockSuggestions.length, ); @@ -495,22 +396,9 @@ describe('useCommandCompletion', () => { slashCompletionRange: { completionStart: 1, completionEnd: 4 }, }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('/mem'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook('/mem'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -526,22 +414,9 @@ describe('useCommandCompletion', () => { atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@src/fi'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook('@src/fi'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -560,22 +435,9 @@ describe('useCommandCompletion', () => { atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest(text, cursorOffset); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook(text, cursorOffset); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -593,22 +455,9 @@ describe('useCommandCompletion', () => { atSuggestions: [{ label: 'src/components/', value: 'src/components/' }], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@src/comp'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook('@src/comp'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -626,22 +475,9 @@ describe('useCommandCompletion', () => { ], }); - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('@src\\comp'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); + const { result } = renderCommandCompletionHook('@src\\comp'); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBe(1); }); @@ -657,9 +493,14 @@ describe('useCommandCompletion', () => { it('should not trigger prompt completion for line comments', async () => { const mockConfig = { getEnablePromptCompletion: () => true, - } as Config; + getGeminiClient: vi.fn(), + } as unknown as Config; - const { result } = renderHook(() => { + let hookResult: ReturnType & { + textBuffer: ReturnType; + }; + + function TestComponent() { const textBuffer = useTextBufferForTest('// This is a line comment'); const completion = useCommandCompletion( textBuffer, @@ -671,19 +512,26 @@ describe('useCommandCompletion', () => { false, mockConfig, ); - return { ...completion, textBuffer }; - }); + hookResult = { ...completion, textBuffer }; + return null; + } + render(); // Should not trigger prompt completion for comments - expect(result.current.suggestions.length).toBe(0); + expect(hookResult!.suggestions.length).toBe(0); }); it('should not trigger prompt completion for block comments', async () => { const mockConfig = { getEnablePromptCompletion: () => true, - } as Config; + getGeminiClient: vi.fn(), + } as unknown as Config; - const { result } = renderHook(() => { + let hookResult: ReturnType & { + textBuffer: ReturnType; + }; + + function TestComponent() { const textBuffer = useTextBufferForTest( '/* This is a block comment */', ); @@ -697,19 +545,26 @@ describe('useCommandCompletion', () => { false, mockConfig, ); - return { ...completion, textBuffer }; - }); + hookResult = { ...completion, textBuffer }; + return null; + } + render(); // Should not trigger prompt completion for comments - expect(result.current.suggestions.length).toBe(0); + expect(hookResult!.suggestions.length).toBe(0); }); it('should trigger prompt completion for regular text when enabled', async () => { const mockConfig = { getEnablePromptCompletion: () => true, - } as Config; + getGeminiClient: vi.fn(), + } as unknown as Config; - const { result } = renderHook(() => { + let hookResult: ReturnType & { + textBuffer: ReturnType; + }; + + function TestComponent() { const textBuffer = useTextBufferForTest( 'This is regular text that should trigger completion', ); @@ -723,11 +578,13 @@ describe('useCommandCompletion', () => { false, mockConfig, ); - return { ...completion, textBuffer }; - }); + hookResult = { ...completion, textBuffer }; + return null; + } + render(); // This test verifies that comments are filtered out while regular text is not - expect(result.current.textBuffer.text).toBe( + expect(hookResult!.textBuffer.text).toBe( 'This is regular text that should trigger completion', ); }); diff --git a/packages/cli/src/ui/hooks/useConsoleMessages.test.ts b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx similarity index 79% rename from packages/cli/src/ui/hooks/useConsoleMessages.test.ts rename to packages/cli/src/ui/hooks/useConsoleMessages.test.tsx index a6c6409af3..5eada66818 100644 --- a/packages/cli/src/ui/hooks/useConsoleMessages.test.ts +++ b/packages/cli/src/ui/hooks/useConsoleMessages.test.tsx @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { act, renderHook } from '@testing-library/react'; +import { render } from 'ink-testing-library'; +import { act, useCallback } from 'react'; import { vi } from 'vitest'; import { useConsoleMessages } from './useConsoleMessages.js'; -import { useCallback } from 'react'; describe('useConsoleMessages', () => { beforeEach(() => { @@ -38,13 +38,30 @@ describe('useConsoleMessages', () => { }; }; + const renderConsoleMessagesHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useTestableConsoleMessages(); + return null; + } + const { unmount } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + unmount, + }; + }; + it('should initialize with an empty array of console messages', () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); expect(result.current.consoleMessages).toEqual([]); }); it('should add a new message when log is called', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); act(() => { result.current.log('Test message'); @@ -60,7 +77,7 @@ describe('useConsoleMessages', () => { }); it('should batch and count identical consecutive messages', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); act(() => { result.current.log('Test message'); @@ -78,7 +95,7 @@ describe('useConsoleMessages', () => { }); it('should not batch different messages', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); act(() => { result.current.log('First message'); @@ -96,7 +113,7 @@ describe('useConsoleMessages', () => { }); it('should clear all messages when clearConsoleMessages is called', async () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); act(() => { result.current.log('A message'); @@ -116,7 +133,7 @@ describe('useConsoleMessages', () => { }); it('should clear the pending timeout when clearConsoleMessages is called', () => { - const { result } = renderHook(() => useTestableConsoleMessages()); + const { result } = renderConsoleMessagesHook(); const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); act(() => { @@ -132,7 +149,7 @@ describe('useConsoleMessages', () => { }); it('should clean up the timeout on unmount', () => { - const { result, unmount } = renderHook(() => useTestableConsoleMessages()); + const { result, unmount } = renderConsoleMessagesHook(); const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); act(() => { diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.ts b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx similarity index 68% rename from packages/cli/src/ui/hooks/useEditorSettings.test.ts rename to packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 3cc4136f96..22b092e036 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -14,7 +14,7 @@ import { type MockedFunction, } from 'vitest'; import { act } from 'react'; -import { renderHook } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import { useEditorSettings } from './useEditorSettings.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; @@ -43,6 +43,16 @@ describe('useEditorSettings', () => { let mockAddItem: MockedFunction< (item: Omit, timestamp: number) => void >; + let result: ReturnType; + + function TestComponent() { + result = useEditorSettings( + mockLoadedSettings, + mockSetEditorError, + mockAddItem, + ); + return null; + } beforeEach(() => { vi.resetAllMocks(); @@ -64,47 +74,39 @@ describe('useEditorSettings', () => { }); it('should initialize with dialog closed', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); - expect(result.current.isEditorDialogOpen).toBe(false); + expect(result.isEditorDialogOpen).toBe(false); }); it('should open editor dialog when openEditorDialog is called', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); act(() => { - result.current.openEditorDialog(); + result.openEditorDialog(); }); - expect(result.current.isEditorDialogOpen).toBe(true); + expect(result.isEditorDialogOpen).toBe(true); }); it('should close editor dialog when exitEditorDialog is called', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); act(() => { - result.current.openEditorDialog(); - result.current.exitEditorDialog(); + result.openEditorDialog(); + result.exitEditorDialog(); }); - expect(result.current.isEditorDialogOpen).toBe(false); + expect(result.isEditorDialogOpen).toBe(false); }); it('should handle editor selection successfully', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); const editorType: EditorType = 'vscode'; const scope = SettingScope.User; act(() => { - result.current.openEditorDialog(); - result.current.handleEditorSelect(editorType, scope); + result.openEditorDialog(); + result.handleEditorSelect(editorType, scope); }); expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( @@ -122,19 +124,17 @@ describe('useEditorSettings', () => { ); expect(mockSetEditorError).toHaveBeenCalledWith(null); - expect(result.current.isEditorDialogOpen).toBe(false); + expect(result.isEditorDialogOpen).toBe(false); }); it('should handle clearing editor preference (undefined editor)', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); const scope = SettingScope.Workspace; act(() => { - result.current.openEditorDialog(); - result.current.handleEditorSelect(undefined, scope); + result.openEditorDialog(); + result.handleEditorSelect(undefined, scope); }); expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( @@ -152,20 +152,18 @@ describe('useEditorSettings', () => { ); expect(mockSetEditorError).toHaveBeenCalledWith(null); - expect(result.current.isEditorDialogOpen).toBe(false); + expect(result.isEditorDialogOpen).toBe(false); }); it('should handle different editor types', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim']; const scope = SettingScope.User; editorTypes.forEach((editorType) => { act(() => { - result.current.handleEditorSelect(editorType, scope); + result.handleEditorSelect(editorType, scope); }); expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( @@ -185,16 +183,14 @@ describe('useEditorSettings', () => { }); it('should handle different setting scopes', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); const editorType: EditorType = 'vscode'; const scopes = [SettingScope.User, SettingScope.Workspace]; scopes.forEach((scope) => { act(() => { - result.current.handleEditorSelect(editorType, scope); + result.handleEditorSelect(editorType, scope); }); expect(mockLoadedSettings.setValue).toHaveBeenCalledWith( @@ -214,9 +210,7 @@ describe('useEditorSettings', () => { }); it('should not set preference for unavailable editors', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); mockCheckHasEditorType.mockReturnValue(false); @@ -224,19 +218,17 @@ describe('useEditorSettings', () => { const scope = SettingScope.User; act(() => { - result.current.openEditorDialog(); - result.current.handleEditorSelect(editorType, scope); + result.openEditorDialog(); + result.handleEditorSelect(editorType, scope); }); expect(mockLoadedSettings.setValue).not.toHaveBeenCalled(); expect(mockAddItem).not.toHaveBeenCalled(); - expect(result.current.isEditorDialogOpen).toBe(true); + expect(result.isEditorDialogOpen).toBe(true); }); it('should not set preference for editors not allowed in sandbox', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); mockAllowEditorTypeInSandbox.mockReturnValue(false); @@ -244,19 +236,17 @@ describe('useEditorSettings', () => { const scope = SettingScope.User; act(() => { - result.current.openEditorDialog(); - result.current.handleEditorSelect(editorType, scope); + result.openEditorDialog(); + result.handleEditorSelect(editorType, scope); }); expect(mockLoadedSettings.setValue).not.toHaveBeenCalled(); expect(mockAddItem).not.toHaveBeenCalled(); - expect(result.current.isEditorDialogOpen).toBe(true); + expect(result.isEditorDialogOpen).toBe(true); }); it('should handle errors during editor selection', () => { - const { result } = renderHook(() => - useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem), - ); + render(); const errorMessage = 'Failed to save settings'; ( @@ -271,14 +261,14 @@ describe('useEditorSettings', () => { const scope = SettingScope.User; act(() => { - result.current.openEditorDialog(); - result.current.handleEditorSelect(editorType, scope); + result.openEditorDialog(); + result.handleEditorSelect(editorType, scope); }); expect(mockSetEditorError).toHaveBeenCalledWith( `Failed to set editor preference: Error: ${errorMessage}`, ); expect(mockAddItem).not.toHaveBeenCalled(); - expect(result.current.isEditorDialogOpen).toBe(true); + expect(result.isEditorDialogOpen).toBe(true); }); }); diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx similarity index 93% rename from packages/cli/src/ui/hooks/useExtensionUpdates.test.ts rename to packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx index b0949035d0..7d17a57611 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx @@ -11,7 +11,7 @@ import * as path from 'node:path'; import { createExtension } from '../../test-utils/createExtension.js'; import { useExtensionUpdates } from './useExtensionUpdates.js'; import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core'; -import { renderHook, waitFor } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import { MessageType } from '../types.js'; import { checkForAllExtensionUpdates, @@ -25,7 +25,7 @@ vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, - homedir: vi.fn(), + homedir: vi.fn().mockReturnValue('/tmp/mock-home'), }; }); @@ -96,15 +96,18 @@ describe('useExtensionUpdates', () => { }, ); - renderHook(() => + function TestComponent() { useExtensionUpdates( extensions as GeminiCLIExtension[], extensionManager, addItem, - ), - ); + ); + return null; + } - await waitFor(() => { + render(); + + await vi.waitFor(() => { expect(addItem).toHaveBeenCalledWith( { type: MessageType.INFO, @@ -148,11 +151,14 @@ describe('useExtensionUpdates', () => { name: '', }); - renderHook(() => - useExtensionUpdates([extension], extensionManager, addItem), - ); + function TestComponent() { + useExtensionUpdates([extension], extensionManager, addItem); + return null; + } - await waitFor( + render(); + + await vi.waitFor( () => { expect(addItem).toHaveBeenCalledWith( { @@ -226,11 +232,14 @@ describe('useExtensionUpdates', () => { name: '', }); - renderHook(() => - useExtensionUpdates(extensions, extensionManager, addItem), - ); + function TestComponent() { + useExtensionUpdates(extensions, extensionManager, addItem); + return null; + } - await waitFor( + render(); + + await vi.waitFor( () => { expect(addItem).toHaveBeenCalledTimes(2); expect(addItem).toHaveBeenCalledWith( @@ -308,15 +317,18 @@ describe('useExtensionUpdates', () => { }, ); - renderHook(() => + function TestComponent() { useExtensionUpdates( extensions as GeminiCLIExtension[], extensionManager, addItem, - ), - ); + ); + return null; + } - await waitFor(() => { + render(); + + await vi.waitFor(() => { expect(addItem).toHaveBeenCalledTimes(1); expect(addItem).toHaveBeenCalledWith( { diff --git a/packages/cli/src/ui/hooks/useFlickerDetector.test.ts b/packages/cli/src/ui/hooks/useFlickerDetector.test.ts index ffa1923a0d..aa60378648 100644 --- a/packages/cli/src/ui/hooks/useFlickerDetector.test.ts +++ b/packages/cli/src/ui/hooks/useFlickerDetector.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { renderHook } from '@testing-library/react'; import { vi, type Mock } from 'vitest'; import { useFlickerDetector } from './useFlickerDetector.js'; diff --git a/packages/cli/src/ui/hooks/useFocus.test.ts b/packages/cli/src/ui/hooks/useFocus.test.tsx similarity index 82% rename from packages/cli/src/ui/hooks/useFocus.test.ts rename to packages/cli/src/ui/hooks/useFocus.test.tsx index a4f784a18a..65c5c83b1a 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.ts +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderHook, act } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import { EventEmitter } from 'node:events'; import { useFocus } from './useFocus.js'; import { vi, type Mock } from 'vitest'; import { useStdin, useStdout } from 'ink'; import { KeypressProvider } from '../contexts/KeypressContext.js'; -import React from 'react'; +import { act } from 'react'; // Mock the ink hooks vi.mock('ink', async (importOriginal) => { @@ -25,9 +25,6 @@ vi.mock('ink', async (importOriginal) => { const mockedUseStdin = vi.mocked(useStdin); const mockedUseStdout = vi.mocked(useStdout); -const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(KeypressProvider, null, children); - describe('useFocus', () => { let stdin: EventEmitter & { resume: Mock; pause: Mock }; let stdout: { write: Mock }; @@ -51,15 +48,36 @@ describe('useFocus', () => { stdin.removeAllListeners(); }); + const renderFocusHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useFocus(); + return null; + } + const { unmount } = render( + + + , + ); + return { + result: { + get current() { + return hookResult; + }, + }, + unmount, + }; + }; + it('should initialize with focus and enable focus reporting', () => { - const { result } = renderHook(() => useFocus(), { wrapper }); + const { result } = renderFocusHook(); expect(result.current).toBe(true); expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h'); }); it('should set isFocused to false when a focus-out event is received', () => { - const { result } = renderHook(() => useFocus(), { wrapper }); + const { result } = renderFocusHook(); // Initial state is focused expect(result.current).toBe(true); @@ -74,7 +92,7 @@ describe('useFocus', () => { }); it('should set isFocused to true when a focus-in event is received', () => { - const { result } = renderHook(() => useFocus(), { wrapper }); + const { result } = renderFocusHook(); // Simulate focus-out to set initial state to false act(() => { @@ -92,7 +110,7 @@ describe('useFocus', () => { }); it('should clean up and disable focus reporting on unmount', () => { - const { unmount } = renderHook(() => useFocus(), { wrapper }); + const { unmount } = renderFocusHook(); // At this point we should have listeners from both KeypressProvider and useFocus const listenerCountAfterMount = stdin.listenerCount('data'); @@ -107,7 +125,7 @@ describe('useFocus', () => { }); it('should handle multiple focus events correctly', () => { - const { result } = renderHook(() => useFocus(), { wrapper }); + const { result } = renderFocusHook(); act(() => { stdin.emit('data', Buffer.from('\x1b[O')); @@ -131,7 +149,7 @@ describe('useFocus', () => { }); it('restores focus on keypress after focus is lost', () => { - const { result } = renderHook(() => useFocus(), { wrapper }); + const { result } = renderFocusHook(); // Simulate focus-out event act(() => { diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 6be20a3e63..cc663a11d9 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { vi, type Mock, type MockInstance } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useFolderTrust } from './useFolderTrust.js'; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 02db0f466e..14a596c9e1 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx similarity index 85% rename from packages/cli/src/ui/hooks/useGitBranchName.test.ts rename to packages/cli/src/ui/hooks/useGitBranchName.test.tsx index 7688a48916..9695c60b67 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.tsx @@ -7,7 +7,7 @@ import type { MockedFunction } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; -import { renderHook, waitFor } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import { useGitBranchName } from './useGitBranchName.js'; import { fs, vol } from 'memfs'; import * as fsPromises from 'node:fs/promises'; @@ -54,13 +54,31 @@ describe('useGitBranchName', () => { vi.restoreAllMocks(); }); + const renderGitBranchNameHook = (cwd: string) => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useGitBranchName(cwd); + return null; + } + const { rerender, unmount } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: () => rerender(), + unmount, + }; + }; + it('should return branch name', async () => { (mockSpawnAsync as MockedFunction).mockResolvedValue( { stdout: 'main\n', } as { stdout: string; stderr: string }, ); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); // Rerender to get the updated state @@ -74,7 +92,7 @@ describe('useGitBranchName', () => { new Error('Git error'), ); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); expect(result.current).toBeUndefined(); await act(async () => { @@ -95,7 +113,7 @@ describe('useGitBranchName', () => { return { stdout: '' } as { stdout: string; stderr: string }; }); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); }); @@ -114,7 +132,7 @@ describe('useGitBranchName', () => { return { stdout: '' } as { stdout: string; stderr: string }; }); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); }); @@ -135,7 +153,7 @@ describe('useGitBranchName', () => { stderr: string; }); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); @@ -143,7 +161,7 @@ describe('useGitBranchName', () => { expect(result.current).toBe('main'); // Wait for watcher to be set up - await waitFor(() => { + await vi.waitFor(() => { expect(watchSpy).toHaveBeenCalled(); }); @@ -153,7 +171,7 @@ describe('useGitBranchName', () => { rerender(); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current).toBe('develop'); }); }); @@ -168,7 +186,7 @@ describe('useGitBranchName', () => { } as { stdout: string; stderr: string }, ); - const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + const { result, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); @@ -211,14 +229,14 @@ describe('useGitBranchName', () => { } as { stdout: string; stderr: string }, ); - const { unmount, rerender } = renderHook(() => useGitBranchName(CWD)); + const { unmount, rerender } = renderGitBranchNameHook(CWD); await act(async () => { rerender(); }); // Wait for watcher to be set up BEFORE unmounting - await waitFor(() => { + await vi.waitFor(() => { expect(watchMock).toHaveBeenCalledWith( GIT_LOGS_HEAD_PATH, expect.any(Function), diff --git a/packages/cli/src/ui/hooks/useHistoryManager.test.ts b/packages/cli/src/ui/hooks/useHistoryManager.test.ts index c6f600323e..d813379ac2 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.test.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useHistory } from './useHistoryManager.js'; diff --git a/packages/cli/src/ui/hooks/useIdeTrustListener.test.ts b/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx similarity index 90% rename from packages/cli/src/ui/hooks/useIdeTrustListener.test.ts rename to packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx index e3d62a218c..3bc84f8553 100644 --- a/packages/cli/src/ui/hooks/useIdeTrustListener.test.ts +++ b/packages/cli/src/ui/hooks/useIdeTrustListener.test.tsx @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { renderHook, act } from '@testing-library/react'; +import { render } from 'ink-testing-library'; +import { act } from 'react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { IdeClient, @@ -79,13 +78,30 @@ describe('useIdeTrustListener', () => { ); }); + const renderTrustListenerHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useIdeTrustListener(); + return null; + } + const { rerender } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: () => rerender(), + }; + }; + it('should initialize correctly with no trust information', () => { vi.mocked(trustedFolders.isWorkspaceTrusted).mockReturnValue({ isTrusted: undefined, source: undefined, }); - const { result } = renderHook(() => useIdeTrustListener()); + const { result } = renderTrustListenerHook(); expect(result.current.isIdeTrusted).toBe(undefined); expect(result.current.needsRestart).toBe(false); @@ -100,7 +116,7 @@ describe('useIdeTrustListener', () => { isTrusted: true, source: 'ide', }); - const { result } = renderHook(() => useIdeTrustListener()); + const { result } = renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -134,7 +150,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result } = renderHook(() => useIdeTrustListener()); + const { result } = renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -172,7 +188,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result } = renderHook(() => useIdeTrustListener()); + const { result } = renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { @@ -208,7 +224,7 @@ describe('useIdeTrustListener', () => { source: 'ide', }); - const { result, rerender } = renderHook(() => useIdeTrustListener()); + const { result, rerender } = renderTrustListenerHook(); // Manually trigger the initial connection state for the test setup await act(async () => { diff --git a/packages/cli/src/ui/hooks/useInputHistory.test.ts b/packages/cli/src/ui/hooks/useInputHistory.test.ts index 8d10c376b6..55e0b63182 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { act, renderHook } from '@testing-library/react'; import { useInputHistory } from './useInputHistory.js'; diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts index 5404cefc02..6953ce1b37 100644 --- a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { act, renderHook } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { useInputHistoryStore } from './useInputHistoryStore.js'; diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.tsx similarity index 83% rename from packages/cli/src/ui/hooks/useKeypress.test.ts rename to packages/cli/src/ui/hooks/useKeypress.test.tsx index 07fcf62ead..aecc4fd876 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.ts +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useKeypress } from './useKeypress.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { useStdin } from 'ink'; @@ -44,8 +44,17 @@ describe('useKeypress', () => { const onKeypress = vi.fn(); let originalNodeVersion: string; - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(KeypressProvider, null, children); + const renderKeypressHook = (isActive = true) => { + function TestComponent() { + useKeypress(onKeypress, { isActive }); + return null; + } + return render( + + + , + ); + }; beforeEach(() => { vi.clearAllMocks(); @@ -67,9 +76,7 @@ describe('useKeypress', () => { }); it('should not listen if isActive is false', () => { - renderHook(() => useKeypress(onKeypress, { isActive: false }), { - wrapper, - }); + renderKeypressHook(false); act(() => stdin.write('a')); expect(onKeypress).not.toHaveBeenCalled(); }); @@ -81,33 +88,27 @@ describe('useKeypress', () => { { key: { name: 'up', sequence: '\x1b[A' } }, { key: { name: 'down', sequence: '\x1b[B' } }, ])('should listen for keypress when active for key $key.name', ({ key }) => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); + renderKeypressHook(true); act(() => stdin.write(key.sequence)); expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key)); }); it('should set and release raw mode', () => { - const { unmount } = renderHook( - () => useKeypress(onKeypress, { isActive: true }), - { wrapper }, - ); + const { unmount } = renderKeypressHook(true); expect(mockSetRawMode).toHaveBeenCalledWith(true); unmount(); expect(mockSetRawMode).toHaveBeenCalledWith(false); }); it('should stop listening after being unmounted', () => { - const { unmount } = renderHook( - () => useKeypress(onKeypress, { isActive: true }), - { wrapper }, - ); + const { unmount } = renderKeypressHook(true); unmount(); act(() => stdin.write('a')); expect(onKeypress).not.toHaveBeenCalled(); }); it('should correctly identify alt+enter (meta key)', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { wrapper }); + renderKeypressHook(true); const key = { name: 'return', sequence: '\x1B\r' }; act(() => stdin.write(key.sequence)); expect(onKeypress).toHaveBeenCalledWith( @@ -130,9 +131,7 @@ describe('useKeypress', () => { }); it('should process a paste as a single event', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); const pasteText = 'hello world'; act(() => stdin.write(PASTE_START + pasteText + PASTE_END)); @@ -148,9 +147,7 @@ describe('useKeypress', () => { }); it('should handle keypress interspersed with pastes', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); const keyA = { name: 'a', sequence: 'a' }; act(() => stdin.write('a')); @@ -174,9 +171,7 @@ describe('useKeypress', () => { }); it('should handle lone pastes', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); const pasteText = 'pasted'; act(() => { @@ -192,9 +187,7 @@ describe('useKeypress', () => { }); it('should handle paste false alarm', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); act(() => { stdin.write(PASTE_START.slice(0, 5)); @@ -211,9 +204,7 @@ describe('useKeypress', () => { }); it('should handle back to back pastes', () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); const pasteText1 = 'herp'; const pasteText2 = 'derp'; @@ -238,9 +229,7 @@ describe('useKeypress', () => { }); it('should handle pastes split across writes', async () => { - renderHook(() => useKeypress(onKeypress, { isActive: true }), { - wrapper, - }); + renderKeypressHook(true); const keyA = { name: 'a', sequence: 'a' }; act(() => stdin.write('a')); @@ -272,10 +261,7 @@ describe('useKeypress', () => { }); it('should emit partial paste content if unmounted mid-paste', () => { - const { unmount } = renderHook( - () => useKeypress(onKeypress, { isActive: true }), - { wrapper }, - ); + const { unmount } = renderKeypressHook(true); const pasteText = 'incomplete paste'; act(() => stdin.write(PASTE_START + pasteText)); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx similarity index 77% rename from packages/cli/src/ui/hooks/useLoadingIndicator.test.ts rename to packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index 77e381b873..904010bcca 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; import { @@ -24,11 +25,35 @@ describe('useLoadingIndicator', () => { vi.restoreAllMocks(); }); + const renderLoadingIndicatorHook = ( + initialStreamingState: StreamingState, + ) => { + let hookResult: ReturnType; + function TestComponent({ + streamingState, + }: { + streamingState: StreamingState; + }) { + hookResult = useLoadingIndicator(streamingState); + return null; + } + const { rerender } = render( + , + ); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: { streamingState: StreamingState }) => + rerender(), + }; + }; + it('should initialize with default values when Idle', () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Idle), - ); + const { result } = renderLoadingIndicatorHook(StreamingState.Idle); expect(result.current.elapsedTime).toBe(0); expect(WITTY_LOADING_PHRASES).toContain( result.current.currentLoadingPhrase, @@ -37,9 +62,7 @@ describe('useLoadingIndicator', () => { it('should reflect values when Responding', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Responding), - ); + const { result } = renderLoadingIndicatorHook(StreamingState.Responding); // Initial state before timers advance expect(result.current.elapsedTime).toBe(0); @@ -58,9 +81,8 @@ describe('useLoadingIndicator', () => { }); it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', async () => { - const { result, rerender } = renderHook( - ({ streamingState }) => useLoadingIndicator(streamingState), - { initialProps: { streamingState: StreamingState.Responding } }, + const { result, rerender } = renderLoadingIndicatorHook( + StreamingState.Responding, ); await act(async () => { @@ -86,9 +108,8 @@ describe('useLoadingIndicator', () => { it('should reset elapsedTime and use a witty phrase when transitioning from WaitingForConfirmation to Responding', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result, rerender } = renderHook( - ({ streamingState }) => useLoadingIndicator(streamingState), - { initialProps: { streamingState: StreamingState.Responding } }, + const { result, rerender } = renderLoadingIndicatorHook( + StreamingState.Responding, ); await act(async () => { @@ -120,9 +141,8 @@ describe('useLoadingIndicator', () => { it('should reset timer and phrase when streamingState changes from Responding to Idle', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result, rerender } = renderHook( - ({ streamingState }) => useLoadingIndicator(streamingState), - { initialProps: { streamingState: StreamingState.Responding } }, + const { result, rerender } = renderLoadingIndicatorHook( + StreamingState.Responding, ); await act(async () => { diff --git a/packages/cli/src/ui/hooks/useMemoryMonitor.test.ts b/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx similarity index 87% rename from packages/cli/src/ui/hooks/useMemoryMonitor.test.ts rename to packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx index 3250a33833..4fb3db97e1 100644 --- a/packages/cli/src/ui/hooks/useMemoryMonitor.test.ts +++ b/packages/cli/src/ui/hooks/useMemoryMonitor.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderHook } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import { vi } from 'vitest'; import { useMemoryMonitor, @@ -27,11 +27,16 @@ describe('useMemoryMonitor', () => { vi.useRealTimers(); }); + function TestComponent() { + useMemoryMonitor({ addItem }); + return null; + } + it('should not warn when memory usage is below threshold', () => { memoryUsageSpy.mockReturnValue({ rss: MEMORY_WARNING_THRESHOLD / 2, } as NodeJS.MemoryUsage); - renderHook(() => useMemoryMonitor({ addItem })); + render(); vi.advanceTimersByTime(10000); expect(addItem).not.toHaveBeenCalled(); }); @@ -40,7 +45,7 @@ describe('useMemoryMonitor', () => { memoryUsageSpy.mockReturnValue({ rss: MEMORY_WARNING_THRESHOLD * 1.5, } as NodeJS.MemoryUsage); - renderHook(() => useMemoryMonitor({ addItem })); + render(); vi.advanceTimersByTime(MEMORY_CHECK_INTERVAL); expect(addItem).toHaveBeenCalledTimes(1); expect(addItem).toHaveBeenCalledWith( @@ -56,7 +61,7 @@ describe('useMemoryMonitor', () => { memoryUsageSpy.mockReturnValue({ rss: MEMORY_WARNING_THRESHOLD * 1.5, } as NodeJS.MemoryUsage); - const { rerender } = renderHook(() => useMemoryMonitor({ addItem })); + const { rerender } = render(); vi.advanceTimersByTime(MEMORY_CHECK_INTERVAL); expect(addItem).toHaveBeenCalledTimes(1); @@ -64,7 +69,7 @@ describe('useMemoryMonitor', () => { memoryUsageSpy.mockReturnValue({ rss: MEMORY_WARNING_THRESHOLD * 1.5, } as NodeJS.MemoryUsage); - rerender(); + rerender(); vi.advanceTimersByTime(MEMORY_CHECK_INTERVAL); expect(addItem).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.tsx similarity index 69% rename from packages/cli/src/ui/hooks/useMessageQueue.test.ts rename to packages/cli/src/ui/hooks/useMessageQueue.test.tsx index d28f5fb250..001897bb5d 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useMessageQueue } from './useMessageQueue.js'; import { StreamingState } from '../types.js'; @@ -22,27 +23,45 @@ describe('useMessageQueue', () => { vi.clearAllMocks(); }); + const renderMessageQueueHook = (initialProps: { + isConfigInitialized: boolean; + streamingState: StreamingState; + submitQuery: (query: string) => void; + }) => { + let hookResult: ReturnType; + function TestComponent(props: typeof initialProps) { + hookResult = useMessageQueue(props); + return null; + } + const { rerender } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: Partial) => + rerender(), + }; + }; + it('should initialize with empty queue', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Idle, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Idle, + submitQuery: mockSubmitQuery, + }); expect(result.current.messageQueue).toEqual([]); expect(result.current.getQueuedMessagesText()).toBe(''); }); it('should add messages to queue', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Test message 1'); @@ -56,13 +75,11 @@ describe('useMessageQueue', () => { }); it('should filter out empty messages', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Valid message'); @@ -78,13 +95,11 @@ describe('useMessageQueue', () => { }); it('should clear queue', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Test message'); @@ -100,13 +115,11 @@ describe('useMessageQueue', () => { }); it('should return queued messages as text with double newlines', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Message 1'); @@ -119,18 +132,12 @@ describe('useMessageQueue', () => { ); }); - it('should auto-submit queued messages when transitioning to Idle', () => { - const { result, rerender } = renderHook( - ({ streamingState }) => - useMessageQueue({ - isConfigInitialized: true, - streamingState, - submitQuery: mockSubmitQuery, - }), - { - initialProps: { streamingState: StreamingState.Responding }, - }, - ); + it('should auto-submit queued messages when transitioning to Idle', async () => { + const { result, rerender } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); // Add some messages act(() => { @@ -143,22 +150,18 @@ describe('useMessageQueue', () => { // Transition to Idle rerender({ streamingState: StreamingState.Idle }); - expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2'); - expect(result.current.messageQueue).toEqual([]); + await vi.waitFor(() => { + expect(mockSubmitQuery).toHaveBeenCalledWith('Message 1\n\nMessage 2'); + expect(result.current.messageQueue).toEqual([]); + }); }); it('should not auto-submit when queue is empty', () => { - const { rerender } = renderHook( - ({ streamingState }) => - useMessageQueue({ - isConfigInitialized: true, - streamingState, - submitQuery: mockSubmitQuery, - }), - { - initialProps: { streamingState: StreamingState.Responding }, - }, - ); + const { rerender } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); // Transition to Idle with empty queue rerender({ streamingState: StreamingState.Idle }); @@ -167,17 +170,11 @@ describe('useMessageQueue', () => { }); it('should not auto-submit when not transitioning to Idle', () => { - const { result, rerender } = renderHook( - ({ streamingState }) => - useMessageQueue({ - isConfigInitialized: true, - streamingState, - submitQuery: mockSubmitQuery, - }), - { - initialProps: { streamingState: StreamingState.Responding }, - }, - ); + const { result, rerender } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); // Add messages act(() => { @@ -191,18 +188,12 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual(['Message 1']); }); - it('should handle multiple state transitions correctly', () => { - const { result, rerender } = renderHook( - ({ streamingState }) => - useMessageQueue({ - isConfigInitialized: true, - streamingState, - submitQuery: mockSubmitQuery, - }), - { - initialProps: { streamingState: StreamingState.Idle }, - }, - ); + it('should handle multiple state transitions correctly', async () => { + const { result, rerender } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Idle, + submitQuery: mockSubmitQuery, + }); // Start responding rerender({ streamingState: StreamingState.Responding }); @@ -215,8 +206,10 @@ describe('useMessageQueue', () => { // Go back to idle - should submit rerender({ streamingState: StreamingState.Idle }); - expect(mockSubmitQuery).toHaveBeenCalledWith('First batch'); - expect(result.current.messageQueue).toEqual([]); + await vi.waitFor(() => { + expect(mockSubmitQuery).toHaveBeenCalledWith('First batch'); + expect(result.current.messageQueue).toEqual([]); + }); // Start responding again rerender({ streamingState: StreamingState.Responding }); @@ -229,19 +222,19 @@ describe('useMessageQueue', () => { // Go back to idle - should submit again rerender({ streamingState: StreamingState.Idle }); - expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch'); - expect(mockSubmitQuery).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect(mockSubmitQuery).toHaveBeenCalledWith('Second batch'); + expect(mockSubmitQuery).toHaveBeenCalledTimes(2); + }); }); describe('popAllMessages', () => { it('should pop all messages and return them joined with double newlines', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); // Add multiple messages act(() => { @@ -269,13 +262,11 @@ describe('useMessageQueue', () => { }); it('should return undefined when queue is empty', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); let poppedMessages: string | undefined = 'not-undefined'; act(() => { @@ -289,13 +280,11 @@ describe('useMessageQueue', () => { }); it('should handle single message correctly', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Single message'); @@ -313,13 +302,11 @@ describe('useMessageQueue', () => { }); it('should clear the entire queue after popping', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); act(() => { result.current.addMessage('Message 1'); @@ -346,13 +333,11 @@ describe('useMessageQueue', () => { }); it('should work correctly with state updates', () => { - const { result } = renderHook(() => - useMessageQueue({ - isConfigInitialized: true, - streamingState: StreamingState.Responding, - submitQuery: mockSubmitQuery, - }), - ); + const { result } = renderMessageQueueHook({ + isConfigInitialized: true, + streamingState: StreamingState.Responding, + submitQuery: mockSubmitQuery, + }); // Add messages act(() => { diff --git a/packages/cli/src/ui/hooks/useModelCommand.test.ts b/packages/cli/src/ui/hooks/useModelCommand.test.ts deleted file mode 100644 index 30cbe7e56a..0000000000 --- a/packages/cli/src/ui/hooks/useModelCommand.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { useModelCommand } from './useModelCommand.js'; - -describe('useModelCommand', () => { - it('should initialize with the model dialog closed', () => { - const { result } = renderHook(() => useModelCommand()); - expect(result.current.isModelDialogOpen).toBe(false); - }); - - it('should open the model dialog when openModelDialog is called', () => { - const { result } = renderHook(() => useModelCommand()); - - act(() => { - result.current.openModelDialog(); - }); - - expect(result.current.isModelDialogOpen).toBe(true); - }); - - it('should close the model dialog when closeModelDialog is called', () => { - const { result } = renderHook(() => useModelCommand()); - - // Open it first - act(() => { - result.current.openModelDialog(); - }); - expect(result.current.isModelDialogOpen).toBe(true); - - // Then close it - act(() => { - result.current.closeModelDialog(); - }); - expect(result.current.isModelDialogOpen).toBe(false); - }); -}); diff --git a/packages/cli/src/ui/hooks/useModelCommand.test.tsx b/packages/cli/src/ui/hooks/useModelCommand.test.tsx new file mode 100644 index 0000000000..0717ab6414 --- /dev/null +++ b/packages/cli/src/ui/hooks/useModelCommand.test.tsx @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { useModelCommand } from './useModelCommand.js'; + +describe('useModelCommand', () => { + let result: ReturnType; + + function TestComponent() { + result = useModelCommand(); + return null; + } + + it('should initialize with the model dialog closed', () => { + render(); + expect(result.isModelDialogOpen).toBe(false); + }); + + it('should open the model dialog when openModelDialog is called', () => { + render(); + + act(() => { + result.openModelDialog(); + }); + + expect(result.isModelDialogOpen).toBe(true); + }); + + it('should close the model dialog when closeModelDialog is called', () => { + render(); + + // Open it first + act(() => { + result.openModelDialog(); + }); + expect(result.isModelDialogOpen).toBe(true); + + // Then close it + act(() => { + result.closeModelDialog(); + }); + expect(result.isModelDialogOpen).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index 519752e82b..9549274160 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + /// import { diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts index 538f6d204b..bfa53ff8c8 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.test.ts b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx similarity index 81% rename from packages/cli/src/ui/hooks/usePrivacySettings.test.ts rename to packages/cli/src/ui/hooks/usePrivacySettings.test.tsx index 30dd0c4483..5c2a15d579 100644 --- a/packages/cli/src/ui/hooks/usePrivacySettings.test.ts +++ b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { render } from 'ink-testing-library'; import type { Config, CodeAssistServer, @@ -31,12 +31,28 @@ describe('usePrivacySettings', () => { vi.clearAllMocks(); }); + const renderPrivacySettingsHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = usePrivacySettings(mockConfig); + return null; + } + render(); + return { + result: { + get current() { + return hookResult; + }, + }, + }; + }; + it('should throw error when content generator is not a CodeAssistServer', async () => { vi.mocked(getCodeAssistServer).mockReturnValue(undefined); - const { result } = renderHook(() => usePrivacySettings(mockConfig)); + const { result } = renderPrivacySettingsHook(); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -53,9 +69,9 @@ describe('usePrivacySettings', () => { }) as unknown as LoadCodeAssistResponse, } as unknown as CodeAssistServer); - const { result } = renderHook(() => usePrivacySettings(mockConfig)); + const { result } = renderPrivacySettingsHook(); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -72,9 +88,9 @@ describe('usePrivacySettings', () => { }) as unknown as LoadCodeAssistResponse, } as unknown as CodeAssistServer); - const { result } = renderHook(() => usePrivacySettings(mockConfig)); + const { result } = renderPrivacySettingsHook(); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -99,10 +115,10 @@ describe('usePrivacySettings', () => { } as unknown as CodeAssistServer; vi.mocked(getCodeAssistServer).mockReturnValue(mockCodeAssistServer); - const { result } = renderHook(() => usePrivacySettings(mockConfig)); + const { result } = renderPrivacySettingsHook(); // Wait for initial load - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.privacyState.isLoading).toBe(false); }); @@ -110,7 +126,7 @@ describe('usePrivacySettings', () => { await result.current.updateDataCollectionOptIn(false); // Wait for update to complete - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.privacyState.dataCollectionOptIn).toBe(false); }); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index 0e94a1874d..e3a86009dd 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { vi, describe, diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts index b3fcfad8b7..ac38b5d1e4 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { CoreToolScheduler } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import { renderHook } from '@testing-library/react'; diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.ts b/packages/cli/src/ui/hooks/useSelectionList.test.tsx similarity index 64% rename from packages/cli/src/ui/hooks/useSelectionList.test.ts rename to packages/cli/src/ui/hooks/useSelectionList.test.tsx index a8878d195c..9ee99746ca 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useSelectionList, type SelectionListItem, @@ -66,40 +67,64 @@ describe('useSelectionList', () => { }); }; + const renderSelectionListHook = (initialProps: { + items: Array>; + onSelect: (item: string) => void; + onHighlight?: (item: string) => void; + initialIndex?: number; + isFocused?: boolean; + showNumbers?: boolean; + }) => { + let hookResult: ReturnType; + function TestComponent(props: typeof initialProps) { + hookResult = useSelectionList(props); + return null; + } + const { rerender, unmount } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: Partial) => + rerender(), + unmount, + }; + }; + describe('Initialization', () => { it('should initialize with the default index (0) if enabled', () => { - const { result } = renderHook(() => - useSelectionList({ items, onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(0); }); it('should initialize with the provided initialIndex if enabled', () => { - const { result } = renderHook(() => - useSelectionList({ - items, - initialIndex: 2, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items, + initialIndex: 2, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(2); }); it('should handle an empty list gracefully', () => { - const { result } = renderHook(() => - useSelectionList({ items: [], onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items: [], + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(0); }); it('should find the next enabled item (downwards) if initialIndex is disabled', () => { - const { result } = renderHook(() => - useSelectionList({ - items, - initialIndex: 1, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items, + initialIndex: 1, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(2); }); @@ -109,33 +134,27 @@ describe('useSelectionList', () => { { value: 'B', disabled: true, key: 'B' }, { value: 'C', disabled: true, key: 'C' }, ]; - const { result } = renderHook(() => - useSelectionList({ - items: wrappingItems, - initialIndex: 2, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items: wrappingItems, + initialIndex: 2, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(0); }); it('should default to 0 if initialIndex is out of bounds', () => { - const { result } = renderHook(() => - useSelectionList({ - items, - initialIndex: 10, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items, + initialIndex: 10, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(0); - const { result: resultNeg } = renderHook(() => - useSelectionList({ - items, - initialIndex: -1, - onSelect: mockOnSelect, - }), - ); + const { result: resultNeg } = renderSelectionListHook({ + items, + initialIndex: -1, + onSelect: mockOnSelect, + }); expect(resultNeg.current.activeIndex).toBe(0); }); @@ -144,22 +163,21 @@ describe('useSelectionList', () => { { value: 'A', disabled: true, key: 'A' }, { value: 'B', disabled: true, key: 'B' }, ]; - const { result } = renderHook(() => - useSelectionList({ - items: allDisabled, - initialIndex: 1, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items: allDisabled, + initialIndex: 1, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(1); }); }); describe('Keyboard Navigation (Up/Down/J/K)', () => { it('should move down with "j" and "down" keys, skipping disabled items', () => { - const { result } = renderHook(() => - useSelectionList({ items, onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(0); pressKey('j'); expect(result.current.activeIndex).toBe(2); @@ -168,9 +186,11 @@ describe('useSelectionList', () => { }); it('should move up with "k" and "up" keys, skipping disabled items', () => { - const { result } = renderHook(() => - useSelectionList({ items, initialIndex: 3, onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items, + initialIndex: 3, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(3); pressKey('k'); expect(result.current.activeIndex).toBe(2); @@ -179,13 +199,11 @@ describe('useSelectionList', () => { }); it('should wrap navigation correctly', () => { - const { result } = renderHook(() => - useSelectionList({ - items, - initialIndex: items.length - 1, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items, + initialIndex: items.length - 1, + onSelect: mockOnSelect, + }); expect(result.current.activeIndex).toBe(3); pressKey('down'); expect(result.current.activeIndex).toBe(0); @@ -195,13 +213,11 @@ describe('useSelectionList', () => { }); it('should call onHighlight when index changes', () => { - renderHook(() => - useSelectionList({ - items, - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - }), - ); + renderSelectionListHook({ + items, + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }); pressKey('down'); expect(mockOnHighlight).toHaveBeenCalledTimes(1); expect(mockOnHighlight).toHaveBeenCalledWith('C'); @@ -209,13 +225,11 @@ describe('useSelectionList', () => { it('should not move or call onHighlight if navigation results in the same index (e.g., single item)', () => { const singleItem = [{ value: 'A', key: 'A' }]; - const { result } = renderHook(() => - useSelectionList({ - items: singleItem, - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - }), - ); + const { result } = renderSelectionListHook({ + items: singleItem, + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }); pressKey('down'); expect(result.current.activeIndex).toBe(0); expect(mockOnHighlight).not.toHaveBeenCalled(); @@ -226,13 +240,11 @@ describe('useSelectionList', () => { { value: 'A', disabled: true, key: 'A' }, { value: 'B', disabled: true, key: 'B' }, ]; - const { result } = renderHook(() => - useSelectionList({ - items: allDisabled, - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - }), - ); + const { result } = renderSelectionListHook({ + items: allDisabled, + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }); const initialIndex = result.current.activeIndex; pressKey('down'); expect(result.current.activeIndex).toBe(initialIndex); @@ -242,25 +254,21 @@ describe('useSelectionList', () => { describe('Selection (Enter)', () => { it('should call onSelect when "return" is pressed on enabled item', () => { - renderHook(() => - useSelectionList({ - items, - initialIndex: 2, - onSelect: mockOnSelect, - }), - ); + renderSelectionListHook({ + items, + initialIndex: 2, + onSelect: mockOnSelect, + }); pressKey('return'); expect(mockOnSelect).toHaveBeenCalledTimes(1); expect(mockOnSelect).toHaveBeenCalledWith('C'); }); it('should not call onSelect if the active item is disabled', () => { - const { result } = renderHook(() => - useSelectionList({ - items, - onSelect: mockOnSelect, - }), - ); + const { result } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + }); act(() => result.current.setActiveIndex(1)); @@ -271,13 +279,11 @@ describe('useSelectionList', () => { describe('Keyboard Navigation Robustness (Rapid Input)', () => { it('should handle rapid navigation and selection robustly (avoiding stale state)', () => { - const { result } = renderHook(() => - useSelectionList({ - items, // A, B(disabled), C, D. Initial index 0 (A). - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - }), - ); + const { result } = renderSelectionListHook({ + items, // A, B(disabled), C, D. Initial index 0 (A). + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }); // Simulate rapid inputs with separate act blocks to allow effects to run if (!activeKeypressHandler) throw new Error('Handler not active'); @@ -321,13 +327,11 @@ describe('useSelectionList', () => { }); it('should handle ultra-rapid input (multiple presses in single act) without stale state', () => { - const { result } = renderHook(() => - useSelectionList({ - items, // A, B(disabled), C, D. Initial index 0 (A). - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - }), - ); + const { result } = renderSelectionListHook({ + items, // A, B(disabled), C, D. Initial index 0 (A). + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + }); // Simulate ultra-rapid inputs where all keypresses happen faster than React can re-render act(() => { @@ -363,40 +367,41 @@ describe('useSelectionList', () => { describe('Focus Management (isFocused)', () => { it('should activate the keypress handler when focused (default) and items exist', () => { - const { result } = renderHook(() => - useSelectionList({ items, onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + }); expect(activeKeypressHandler).not.toBeNull(); pressKey('down'); expect(result.current.activeIndex).toBe(2); }); it('should not activate the keypress handler when isFocused is false', () => { - renderHook(() => - useSelectionList({ items, onSelect: mockOnSelect, isFocused: false }), - ); + renderSelectionListHook({ + items, + onSelect: mockOnSelect, + isFocused: false, + }); expect(activeKeypressHandler).toBeNull(); expect(() => pressKey('down')).toThrow(/keypress handler is not active/); }); it('should not activate the keypress handler when items list is empty', () => { - renderHook(() => - useSelectionList({ - items: [], - onSelect: mockOnSelect, - isFocused: true, - }), - ); + renderSelectionListHook({ + items: [], + onSelect: mockOnSelect, + isFocused: true, + }); expect(activeKeypressHandler).toBeNull(); expect(() => pressKey('down')).toThrow(/keypress handler is not active/); }); it('should activate/deactivate when isFocused prop changes', () => { - const { result, rerender } = renderHook( - (props: { isFocused: boolean }) => - useSelectionList({ items, onSelect: mockOnSelect, ...props }), - { initialProps: { isFocused: false } }, - ); + const { result, rerender } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + isFocused: false, + }); expect(activeKeypressHandler).toBeNull(); @@ -429,23 +434,22 @@ describe('useSelectionList', () => { const pressNumber = (num: string) => pressKey(num, num); it('should not respond to numbers if showNumbers is false (default)', () => { - const { result } = renderHook(() => - useSelectionList({ items: shortList, onSelect: mockOnSelect }), - ); + const { result } = renderSelectionListHook({ + items: shortList, + onSelect: mockOnSelect, + }); pressNumber('1'); expect(result.current.activeIndex).toBe(0); expect(mockOnSelect).not.toHaveBeenCalled(); }); it('should select item immediately if the number cannot be extended (unambiguous)', () => { - const { result } = renderHook(() => - useSelectionList({ - items: shortList, - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: shortList, + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + showNumbers: true, + }); pressNumber('3'); expect(result.current.activeIndex).toBe(2); @@ -456,15 +460,13 @@ describe('useSelectionList', () => { }); it('should highlight and wait for timeout if the number can be extended (ambiguous)', () => { - const { result } = renderHook(() => - useSelectionList({ - items: longList, - initialIndex: 1, // Start at index 1 so pressing "1" (index 0) causes a change - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: longList, + initialIndex: 1, // Start at index 1 so pressing "1" (index 0) causes a change + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + showNumbers: true, + }); pressNumber('1'); @@ -483,13 +485,11 @@ describe('useSelectionList', () => { }); it('should handle multi-digit input correctly', () => { - const { result } = renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('1'); expect(mockOnSelect).not.toHaveBeenCalled(); @@ -503,13 +503,11 @@ describe('useSelectionList', () => { }); it('should reset buffer if input becomes invalid (out of bounds)', () => { - const { result } = renderHook(() => - useSelectionList({ - items: shortList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: shortList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('5'); @@ -522,13 +520,11 @@ describe('useSelectionList', () => { }); it('should allow "0" as subsequent digit, but ignore as first digit', () => { - const { result } = renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('0'); expect(result.current.activeIndex).toBe(0); @@ -545,13 +541,11 @@ describe('useSelectionList', () => { }); it('should clear the initial "0" input after timeout', () => { - renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('0'); act(() => vi.advanceTimersByTime(1000)); // Timeout the '0' input @@ -564,14 +558,12 @@ describe('useSelectionList', () => { }); it('should highlight but not select a disabled item (immediate selection case)', () => { - const { result } = renderHook(() => - useSelectionList({ - items: shortList, // B (index 1, number 2) is disabled - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: shortList, // B (index 1, number 2) is disabled + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + showNumbers: true, + }); pressNumber('2'); @@ -589,13 +581,11 @@ describe('useSelectionList', () => { ...longList.slice(1), ]; - const { result } = renderHook(() => - useSelectionList({ - items: disabledAmbiguousList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: disabledAmbiguousList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('1'); expect(result.current.activeIndex).toBe(0); @@ -610,13 +600,11 @@ describe('useSelectionList', () => { }); it('should clear the number buffer if a non-numeric key (e.g., navigation) is pressed', () => { - const { result } = renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { result } = renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('1'); expect(vi.getTimerCount()).toBe(1); @@ -632,13 +620,11 @@ describe('useSelectionList', () => { }); it('should clear the number buffer if "return" is pressed', () => { - renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressNumber('1'); @@ -655,31 +641,25 @@ describe('useSelectionList', () => { }); describe('Reactivity (Dynamic Updates)', () => { - it('should update activeIndex when initialIndex prop changes', () => { - const { result, rerender } = renderHook( - ({ initialIndex }: { initialIndex: number }) => - useSelectionList({ - items, - onSelect: mockOnSelect, - initialIndex, - }), - { initialProps: { initialIndex: 0 } }, - ); + it('should update activeIndex when initialIndex prop changes', async () => { + const { result, rerender } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + initialIndex: 0, + }); rerender({ initialIndex: 2 }); - expect(result.current.activeIndex).toBe(2); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(2); + }); }); - it('should respect a new initialIndex even after user interaction', () => { - const { result, rerender } = renderHook( - ({ initialIndex }: { initialIndex: number }) => - useSelectionList({ - items, - onSelect: mockOnSelect, - initialIndex, - }), - { initialProps: { initialIndex: 0 } }, - ); + it('should respect a new initialIndex even after user interaction', async () => { + const { result, rerender } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + initialIndex: 0, + }); // User navigates, changing the active index pressKey('down'); @@ -689,35 +669,31 @@ describe('useSelectionList', () => { rerender({ initialIndex: 3 }); // The hook should now respect the new initial index - expect(result.current.activeIndex).toBe(3); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(3); + }); }); - it('should validate index when initialIndex prop changes to a disabled item', () => { - const { result, rerender } = renderHook( - ({ initialIndex }: { initialIndex: number }) => - useSelectionList({ - items, - onSelect: mockOnSelect, - initialIndex, - }), - { initialProps: { initialIndex: 0 } }, - ); + it('should validate index when initialIndex prop changes to a disabled item', async () => { + const { result, rerender } = renderSelectionListHook({ + items, + onSelect: mockOnSelect, + initialIndex: 0, + }); rerender({ initialIndex: 1 }); - expect(result.current.activeIndex).toBe(2); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(2); + }); }); - it('should adjust activeIndex if items change and the initialIndex is now out of bounds', () => { - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - initialIndex: 3, - items: testItems, - }), - { initialProps: { items } }, - ); + it('should adjust activeIndex if items change and the initialIndex is now out of bounds', async () => { + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + initialIndex: 3, + items, + }); expect(result.current.activeIndex).toBe(3); @@ -728,24 +704,22 @@ describe('useSelectionList', () => { rerender({ items: shorterItems }); // Length 2 // The useEffect syncs based on the initialIndex (3) which is now out of bounds. It defaults to 0. - expect(result.current.activeIndex).toBe(0); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(0); + }); }); - it('should adjust activeIndex if items change and the initialIndex becomes disabled', () => { + it('should adjust activeIndex if items change and the initialIndex becomes disabled', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - initialIndex: 1, - items: testItems, - }), - { initialProps: { items: initialItems } }, - ); + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + initialIndex: 1, + items: initialItems, + }); expect(result.current.activeIndex).toBe(1); @@ -756,25 +730,25 @@ describe('useSelectionList', () => { ]; rerender({ items: newItems }); - expect(result.current.activeIndex).toBe(2); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(2); + }); }); - it('should reset to 0 if items change to an empty list', () => { - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - initialIndex: 2, - items: testItems, - }), - { initialProps: { items } }, - ); + it('should reset to 0 if items change to an empty list', async () => { + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + initialIndex: 2, + items, + }); rerender({ items: [] }); - expect(result.current.activeIndex).toBe(0); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(0); + }); }); - it('should not reset activeIndex when items are deeply equal', () => { + it('should not reset activeIndex when items are deeply equal', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', disabled: true, key: 'B' }, @@ -782,16 +756,12 @@ describe('useSelectionList', () => { { value: 'D', key: 'D' }, ]; - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - initialIndex: 2, - items: testItems, - }), - { initialProps: { items: initialItems } }, - ); + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + initialIndex: 2, + items: initialItems, + }); expect(result.current.activeIndex).toBe(2); @@ -813,12 +783,14 @@ describe('useSelectionList', () => { rerender({ items: newItems }); // Active index should remain the same since items are deeply equal - expect(result.current.activeIndex).toBe(3); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(3); + }); // onHighlight should NOT be called since the index didn't change expect(mockOnHighlight).not.toHaveBeenCalled(); }); - it('should update activeIndex when items change structurally', () => { + it('should update activeIndex when items change structurally', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', disabled: true, key: 'B' }, @@ -826,16 +798,12 @@ describe('useSelectionList', () => { { value: 'D', key: 'D' }, ]; - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - onHighlight: mockOnHighlight, - initialIndex: 3, - items: testItems, - }), - { initialProps: { items: initialItems } }, - ); + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + onHighlight: mockOnHighlight, + initialIndex: 3, + items: initialItems, + }); expect(result.current.activeIndex).toBe(3); mockOnHighlight.mockClear(); @@ -850,25 +818,23 @@ describe('useSelectionList', () => { rerender({ items: newItems }); // Active index should update based on initialIndex and new items - expect(result.current.activeIndex).toBe(0); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(0); + }); }); - it('should handle partial changes in items array', () => { + it('should handle partial changes in items array', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - initialIndex: 1, - items: testItems, - }), - { initialProps: { items: initialItems } }, - ); + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + initialIndex: 1, + items: initialItems, + }); expect(result.current.activeIndex).toBe(1); @@ -882,24 +848,22 @@ describe('useSelectionList', () => { rerender({ items: newItems }); // Should find next valid index since current became disabled - expect(result.current.activeIndex).toBe(2); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(2); + }); }); - it('should update selection when a new item is added to the start of the list', () => { + it('should update selection when a new item is added to the start of the list', async () => { const initialItems = [ { value: 'A', key: 'A' }, { value: 'B', key: 'B' }, { value: 'C', key: 'C' }, ]; - const { result, rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => - useSelectionList({ - onSelect: mockOnSelect, - items: testItems, - }), - { initialProps: { items: initialItems } }, - ); + const { result, rerender } = renderSelectionListHook({ + onSelect: mockOnSelect, + items: initialItems, + }); pressKey('down'); expect(result.current.activeIndex).toBe(1); @@ -913,7 +877,9 @@ describe('useSelectionList', () => { rerender({ items: newItems }); - expect(result.current.activeIndex).toBe(2); + await vi.waitFor(() => { + expect(result.current.activeIndex).toBe(2); + }); }); it('should not re-initialize when items have identical keys but are different objects', () => { @@ -924,17 +890,26 @@ describe('useSelectionList', () => { let renderCount = 0; - const { rerender } = renderHook( - ({ items: testItems }: { items: Array> }) => { + const renderHookWithCount = (initialProps: { + items: Array>; + }) => { + function TestComponent(props: typeof initialProps) { renderCount++; - return useSelectionList({ + useSelectionList({ onSelect: mockOnSelect, onHighlight: mockOnHighlight, - items: testItems, + items: props.items, }); - }, - { initialProps: { items: initialItems } }, - ); + return null; + } + const { rerender } = render(); + return { + rerender: (newProps: Partial) => + rerender(), + }; + }; + + const { rerender } = renderHookWithCount({ items: initialItems }); // Initial render expect(renderCount).toBe(1); @@ -950,24 +925,6 @@ describe('useSelectionList', () => { }); }); - describe('Manual Control', () => { - it('should allow manual setting of active index via setActiveIndex', () => { - const { result } = renderHook(() => - useSelectionList({ items, onSelect: mockOnSelect }), - ); - - act(() => { - result.current.setActiveIndex(3); - }); - expect(result.current.activeIndex).toBe(3); - - act(() => { - result.current.setActiveIndex(1); - }); - expect(result.current.activeIndex).toBe(1); - }); - }); - describe('Cleanup', () => { beforeEach(() => { vi.useFakeTimers(); @@ -983,13 +940,11 @@ describe('useSelectionList', () => { (_, i) => ({ value: `Item ${i + 1}`, key: `Item ${i + 1}` }), ); - const { unmount } = renderHook(() => - useSelectionList({ - items: longList, - onSelect: mockOnSelect, - showNumbers: true, - }), - ); + const { unmount } = renderSelectionListHook({ + items: longList, + onSelect: mockOnSelect, + showNumbers: true, + }); pressKey('1', '1'); diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index ccb4bb7b6d..865bc7cf3f 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + import { renderHook, act, waitFor } from '@testing-library/react'; import { useShellHistory } from './useShellHistory.js'; import * as fs from 'node:fs/promises'; diff --git a/packages/cli/src/ui/hooks/useTimer.test.ts b/packages/cli/src/ui/hooks/useTimer.test.tsx similarity index 59% rename from packages/cli/src/ui/hooks/useTimer.test.ts rename to packages/cli/src/ui/hooks/useTimer.test.tsx index 20d44d1781..475116086b 100644 --- a/packages/cli/src/ui/hooks/useTimer.test.ts +++ b/packages/cli/src/ui/hooks/useTimer.test.tsx @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useTimer } from './useTimer.js'; describe('useTimer', () => { @@ -17,13 +18,43 @@ describe('useTimer', () => { vi.restoreAllMocks(); }); + const renderTimerHook = ( + initialIsActive: boolean, + initialResetKey: number, + ) => { + let hookResult: ReturnType; + function TestComponent({ + isActive, + resetKey, + }: { + isActive: boolean; + resetKey: number; + }) { + hookResult = useTimer(isActive, resetKey); + return null; + } + const { rerender, unmount } = render( + , + ); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: { isActive: boolean; resetKey: number }) => + rerender(), + unmount, + }; + }; + it('should initialize with 0', () => { - const { result } = renderHook(() => useTimer(false, 0)); + const { result } = renderTimerHook(false, 0); expect(result.current).toBe(0); }); it('should not increment time if isActive is false', () => { - const { result } = renderHook(() => useTimer(false, 0)); + const { result } = renderTimerHook(false, 0); act(() => { vi.advanceTimersByTime(5000); }); @@ -31,7 +62,7 @@ describe('useTimer', () => { }); it('should increment time every second if isActive is true', () => { - const { result } = renderHook(() => useTimer(true, 0)); + const { result } = renderTimerHook(true, 0); act(() => { vi.advanceTimersByTime(1000); }); @@ -43,13 +74,12 @@ describe('useTimer', () => { }); it('should reset to 0 and start incrementing when isActive becomes true from false', () => { - const { result, rerender } = renderHook( - ({ isActive, resetKey }) => useTimer(isActive, resetKey), - { initialProps: { isActive: false, resetKey: 0 } }, - ); + const { result, rerender } = renderTimerHook(false, 0); expect(result.current).toBe(0); - rerender({ isActive: true, resetKey: 0 }); + act(() => { + rerender({ isActive: true, resetKey: 0 }); + }); expect(result.current).toBe(0); // Should reset to 0 upon becoming active act(() => { @@ -59,16 +89,15 @@ describe('useTimer', () => { }); it('should reset to 0 when resetKey changes while active', () => { - const { result, rerender } = renderHook( - ({ isActive, resetKey }) => useTimer(isActive, resetKey), - { initialProps: { isActive: true, resetKey: 0 } }, - ); + const { result, rerender } = renderTimerHook(true, 0); act(() => { vi.advanceTimersByTime(3000); // 3s }); expect(result.current).toBe(3); - rerender({ isActive: true, resetKey: 1 }); // Change resetKey + act(() => { + rerender({ isActive: true, resetKey: 1 }); // Change resetKey + }); expect(result.current).toBe(0); // Should reset to 0 act(() => { @@ -78,39 +107,39 @@ describe('useTimer', () => { }); it('should be 0 if isActive is false, regardless of resetKey changes', () => { - const { result, rerender } = renderHook( - ({ isActive, resetKey }) => useTimer(isActive, resetKey), - { initialProps: { isActive: false, resetKey: 0 } }, - ); + const { result, rerender } = renderTimerHook(false, 0); expect(result.current).toBe(0); - rerender({ isActive: false, resetKey: 1 }); + act(() => { + rerender({ isActive: false, resetKey: 1 }); + }); expect(result.current).toBe(0); }); it('should clear timer on unmount', () => { - const { unmount } = renderHook(() => useTimer(true, 0)); + const { unmount } = renderTimerHook(true, 0); const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); unmount(); expect(clearIntervalSpy).toHaveBeenCalledOnce(); }); it('should preserve elapsedTime when isActive becomes false, and reset to 0 when it becomes active again', () => { - const { result, rerender } = renderHook( - ({ isActive, resetKey }) => useTimer(isActive, resetKey), - { initialProps: { isActive: true, resetKey: 0 } }, - ); + const { result, rerender } = renderTimerHook(true, 0); act(() => { vi.advanceTimersByTime(3000); // Advance to 3 seconds }); expect(result.current).toBe(3); - rerender({ isActive: false, resetKey: 0 }); + act(() => { + rerender({ isActive: false, resetKey: 0 }); + }); expect(result.current).toBe(3); // Time should be preserved when timer becomes inactive // Now make it active again, it should reset to 0 - rerender({ isActive: true, resetKey: 0 }); + act(() => { + rerender({ isActive: true, resetKey: 0 }); + }); expect(result.current).toBe(0); act(() => { vi.advanceTimersByTime(1000); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 9fd31b89f9..d80f8eceb2 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** @vitest-environment jsdom */ + /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.tsx similarity index 98% rename from packages/cli/src/ui/hooks/vim.test.ts rename to packages/cli/src/ui/hooks/vim.test.tsx index 2bfba0c31f..7588899b87 100644 --- a/packages/cli/src/ui/hooks/vim.test.ts +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -5,8 +5,9 @@ */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; import type React from 'react'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; import { useVim } from './vim.js'; import type { VimMode } from './vim.js'; import type { Key } from './useKeypress.js'; @@ -173,10 +174,25 @@ describe('useVim hook', () => { }; }; - const renderVimHook = (buffer?: Partial) => - renderHook(() => - useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit), - ); + const renderVimHook = (buffer?: Partial) => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useVim( + (buffer || mockBuffer) as TextBuffer, + mockHandleFinalSubmit, + ); + return null; + } + const { rerender } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: () => rerender(), + }; + }; const exitInsertMode = (result: { current: { @@ -1286,10 +1302,14 @@ describe('useVim hook', () => { }); describe('Shell command pass-through', () => { - it('should pass through ctrl+r in INSERT mode', () => { + it('should pass through ctrl+r in INSERT mode', async () => { mockVimContext.vimMode = 'INSERT'; const { result } = renderVimHook(); + await vi.waitFor(() => { + expect(result.current.mode).toBe('INSERT'); + }); + const handled = result.current.handleInput( createKey({ name: 'r', ctrl: true }), ); @@ -1297,20 +1317,29 @@ describe('useVim hook', () => { expect(handled).toBe(false); }); - it('should pass through ! in INSERT mode when buffer is empty', () => { + it('should pass through ! in INSERT mode when buffer is empty', async () => { mockVimContext.vimMode = 'INSERT'; const emptyBuffer = createMockBuffer(''); const { result } = renderVimHook(emptyBuffer); + await vi.waitFor(() => { + expect(result.current.mode).toBe('INSERT'); + }); + const handled = result.current.handleInput(createKey({ sequence: '!' })); expect(handled).toBe(false); }); - it('should handle ! as input in INSERT mode when buffer is not empty', () => { + it('should handle ! as input in INSERT mode when buffer is not empty', async () => { mockVimContext.vimMode = 'INSERT'; const nonEmptyBuffer = createMockBuffer('not empty'); const { result } = renderVimHook(nonEmptyBuffer); + + await vi.waitFor(() => { + expect(result.current.mode).toBe('INSERT'); + }); + const key = createKey({ sequence: '!', name: '!' }); act(() => { diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index fcffa292ff..aeac3ad329 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,18 +6,25 @@ /// import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], - environment: 'jsdom', + environment: 'node', globals: true, reporters: ['default', 'junit'], silent: true, outputFile: { junit: 'junit.xml', }, + alias: { + react: path.resolve(__dirname, '../../node_modules/react'), + }, setupFiles: ['./test-setup.ts'], coverage: { enabled: true, diff --git a/packages/core/src/agents/subagent-tool-wrapper.test.ts b/packages/core/src/agents/subagent-tool-wrapper.test.ts index 5cfd744dc2..f971dc5162 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.test.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.test.ts @@ -67,8 +67,7 @@ describe('SubagentToolWrapper', () => { it('should call convertInputConfigToJsonSchema with the correct agent inputConfig', () => { new SubagentToolWrapper(mockDefinition, mockConfig); - expect(convertInputConfigToJsonSchema).toHaveBeenCalledOnce(); - expect(convertInputConfigToJsonSchema).toHaveBeenCalledWith( + expect(convertInputConfigToJsonSchema).toHaveBeenCalledExactlyOnceWith( mockDefinition.inputConfig, ); }); @@ -115,8 +114,7 @@ describe('SubagentToolWrapper', () => { const invocation = wrapper.build(params); expect(invocation).toBeInstanceOf(SubagentInvocation); - expect(MockedSubagentInvocation).toHaveBeenCalledOnce(); - expect(MockedSubagentInvocation).toHaveBeenCalledWith( + expect(MockedSubagentInvocation).toHaveBeenCalledExactlyOnceWith( params, mockDefinition, mockConfig,