First batch of fixing tests to use best practices. (#11964)

This commit is contained in:
Jacob Richman
2025-10-25 14:41:53 -07:00
committed by GitHub
parent 8352980f01
commit ee66732ad2
48 changed files with 1128 additions and 1113 deletions
+4 -4
View File
@@ -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"`,
),
@@ -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';
+1 -2
View File
@@ -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".',
);
});
@@ -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';
@@ -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);
});
@@ -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 {
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
/// <reference types="vitest/globals" />
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
/**
*
*
@@ -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();
});
});
@@ -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"
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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<typeof useShellCommandProcessor>;
function TestComponent() {
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
onExecMock,
@@ -102,8 +104,18 @@ describe('useShellCommandProcessor', () => {
mockConfig,
mockGeminiClient,
setShellInputFocusedMock,
),
);
);
return null;
}
render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
};
};
const createMockServiceResult = (
overrides: Partial<ShellExecutionResult> = {},
@@ -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<typeof useSlashCommandProcessor>;
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(<TestComponent />);
return {
get current() {
return hookResult;
},
unmount,
rerender: () => rerender(<TestComponent />),
};
};
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 () => {
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import {
describe,
it,
@@ -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<typeof useCommandCompletion> & {
textBuffer: ReturnType<typeof useTextBuffer>;
};
function TestComponent() {
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
shellModeActive,
mockConfig,
);
hookResult = { ...completion, textBuffer };
return null;
}
render(<TestComponent />);
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<typeof useCommandCompletion> & {
textBuffer: ReturnType<typeof useTextBuffer>;
};
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(<TestComponent />);
// 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<typeof useCommandCompletion> & {
textBuffer: ReturnType<typeof useTextBuffer>;
};
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(<TestComponent />);
// 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<typeof useCommandCompletion> & {
textBuffer: ReturnType<typeof useTextBuffer>;
};
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(<TestComponent />);
// 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',
);
});
@@ -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<typeof useTestableConsoleMessages>;
function TestComponent() {
hookResult = useTestableConsoleMessages();
return null;
}
const { unmount } = render(<TestComponent />);
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(() => {
@@ -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<HistoryItem, 'id'>, timestamp: number) => void
>;
let result: ReturnType<typeof useEditorSettings>;
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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);
});
});
@@ -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<typeof os>();
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
await vi.waitFor(() => {
expect(addItem).toHaveBeenCalledTimes(1);
expect(addItem).toHaveBeenCalledWith(
{
@@ -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';
@@ -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<typeof useFocus>;
function TestComponent() {
hookResult = useFocus();
return null;
}
const { unmount } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<TestComponent />
</KeypressProvider>,
);
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(() => {
@@ -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';
@@ -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';
@@ -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<typeof useGitBranchName>;
function TestComponent() {
hookResult = useGitBranchName(cwd);
return null;
}
const { rerender, unmount } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: () => rerender(<TestComponent />),
unmount,
};
};
it('should return branch name', async () => {
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).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),
@@ -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';
@@ -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<typeof useIdeTrustListener>;
function TestComponent() {
hookResult = useIdeTrustListener();
return null;
}
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: () => rerender(<TestComponent />),
};
};
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 () => {
@@ -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';
@@ -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';
@@ -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(
<KeypressProvider kittyProtocolEnabled={false}>
<TestComponent />
</KeypressProvider>,
);
};
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));
@@ -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<typeof useLoadingIndicator>;
function TestComponent({
streamingState,
}: {
streamingState: StreamingState;
}) {
hookResult = useLoadingIndicator(streamingState);
return null;
}
const { rerender } = render(
<TestComponent streamingState={initialStreamingState} />,
);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: { streamingState: StreamingState }) =>
rerender(<TestComponent {...newProps} />),
};
};
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 () => {
@@ -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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
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(<TestComponent />);
vi.advanceTimersByTime(MEMORY_CHECK_INTERVAL);
expect(addItem).toHaveBeenCalledTimes(1);
});
@@ -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<typeof useMessageQueue>;
function TestComponent(props: typeof initialProps) {
hookResult = useMessageQueue(props);
return null;
}
const { rerender } = render(<TestComponent {...initialProps} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: Partial<typeof initialProps>) =>
rerender(<TestComponent {...initialProps} {...newProps} />),
};
};
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(() => {
@@ -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);
});
});
@@ -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<typeof useModelCommand>;
function TestComponent() {
result = useModelCommand();
return null;
}
it('should initialize with the model dialog closed', () => {
render(<TestComponent />);
expect(result.isModelDialogOpen).toBe(false);
});
it('should open the model dialog when openModelDialog is called', () => {
render(<TestComponent />);
act(() => {
result.openModelDialog();
});
expect(result.isModelDialogOpen).toBe(true);
});
it('should close the model dialog when closeModelDialog is called', () => {
render(<TestComponent />);
// Open it first
act(() => {
result.openModelDialog();
});
expect(result.isModelDialogOpen).toBe(true);
// Then close it
act(() => {
result.closeModelDialog();
});
expect(result.isModelDialogOpen).toBe(false);
});
});
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
/// <reference types="vitest/globals" />
import {
@@ -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 {
@@ -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<typeof usePrivacySettings>;
function TestComponent() {
hookResult = usePrivacySettings(mockConfig);
return null;
}
render(<TestComponent />);
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);
});
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import {
vi,
describe,
@@ -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';
@@ -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<SelectionListItem<string>>;
onSelect: (item: string) => void;
onHighlight?: (item: string) => void;
initialIndex?: number;
isFocused?: boolean;
showNumbers?: boolean;
}) => {
let hookResult: ReturnType<typeof useSelectionList>;
function TestComponent(props: typeof initialProps) {
hookResult = useSelectionList(props);
return null;
}
const { rerender, unmount } = render(<TestComponent {...initialProps} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: Partial<typeof initialProps>) =>
rerender(<TestComponent {...initialProps} {...newProps} />),
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) =>
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<SelectionListItem<string>> }) => {
const renderHookWithCount = (initialProps: {
items: Array<SelectionListItem<string>>;
}) => {
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(<TestComponent {...initialProps} />);
return {
rerender: (newProps: Partial<typeof initialProps>) =>
rerender(<TestComponent {...initialProps} {...newProps} />),
};
};
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');
@@ -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';
@@ -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<typeof useTimer>;
function TestComponent({
isActive,
resetKey,
}: {
isActive: boolean;
resetKey: number;
}) {
hookResult = useTimer(isActive, resetKey);
return null;
}
const { rerender, unmount } = render(
<TestComponent isActive={initialIsActive} resetKey={initialResetKey} />,
);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: { isActive: boolean; resetKey: number }) =>
rerender(<TestComponent {...newProps} />),
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);
@@ -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';
@@ -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<TextBuffer>) =>
renderHook(() =>
useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit),
);
const renderVimHook = (buffer?: Partial<TextBuffer>) => {
let hookResult: ReturnType<typeof useVim>;
function TestComponent() {
hookResult = useVim(
(buffer || mockBuffer) as TextBuffer,
mockHandleFinalSubmit,
);
return null;
}
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: () => rerender(<TestComponent />),
};
};
const 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(() => {
+8 -1
View File
@@ -6,18 +6,25 @@
/// <reference types="vitest" />
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,