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,