mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 01:21:10 -07:00
Improve code coverage for cli package (#13724)
This commit is contained in:
@@ -164,29 +164,23 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('DialogManager');
|
||||
});
|
||||
|
||||
it('should show Ctrl+C exit prompt when dialogs are visible and ctrlCPressedOnce is true', () => {
|
||||
const ctrlCUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlCPressedOnce: true,
|
||||
} as UIState;
|
||||
it.each([
|
||||
{ key: 'C', stateKey: 'ctrlCPressedOnce' },
|
||||
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
|
||||
])(
|
||||
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
|
||||
({ key, stateKey }) => {
|
||||
const uiState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
[stateKey]: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, ctrlCUIState);
|
||||
const { lastFrame } = renderWithProviders(<App />, uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
|
||||
});
|
||||
|
||||
it('should show Ctrl+D exit prompt when dialogs are visible and ctrlDPressedOnce is true', () => {
|
||||
const ctrlDUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
ctrlDPressedOnce: true,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, ctrlDUIState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
|
||||
});
|
||||
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
|
||||
},
|
||||
);
|
||||
|
||||
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
@@ -205,4 +199,33 @@ describe('App', () => {
|
||||
|
||||
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders default layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders screen reader layout correctly', () => {
|
||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<App />,
|
||||
mockUIState as UIState,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with dialogs visible', () => {
|
||||
const dialogUIState = {
|
||||
...mockUIState,
|
||||
dialogsVisible: true,
|
||||
} as UIState;
|
||||
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
204
packages/cli/src/ui/IdeIntegrationNudge.test.tsx
Normal file
204
packages/cli/src/ui/IdeIntegrationNudge.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('IdeIntegrationNudge', () => {
|
||||
const defaultProps = {
|
||||
ide: {
|
||||
name: 'vscode',
|
||||
displayName: 'VS Code',
|
||||
},
|
||||
onComplete: vi.fn(),
|
||||
};
|
||||
|
||||
const originalError = console.error;
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
/was not wrapped in act/.test(args[0])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
|
||||
});
|
||||
|
||||
it('renders correctly with default options', async () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
|
||||
expect(frame).toContain('Yes');
|
||||
expect(frame).toContain('No (esc)');
|
||||
expect(frame).toContain("No, don't ask again");
|
||||
});
|
||||
|
||||
it('handles "Yes" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// "Yes" is the first option and selected by default usually.
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'yes',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles "No" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Navigate down to "No (esc)"
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles "Dismiss" selection', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Navigate down to "No, don't ask again"
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await delay(100);
|
||||
});
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'dismiss',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Escape key press', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const { stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
// Press Escape
|
||||
await act(async () => {
|
||||
stdin.write('\u001B');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('displays correct text and handles selection when extension is pre-installed', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '1234');
|
||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const { lastFrame, stdin } = render(
|
||||
<KeypressProvider>
|
||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toContain(
|
||||
'If you select Yes, the CLI will have access to your open files',
|
||||
);
|
||||
expect(frame).not.toContain("we'll install an extension");
|
||||
|
||||
// Select "Yes"
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await delay(100);
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalledWith({
|
||||
userSelection: 'yes',
|
||||
isExtensionPreInstalled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
21
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
DialogManager"
|
||||
`;
|
||||
@@ -78,38 +78,33 @@ describe('ApiAuthDialog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onSubmit when the text input is submitted', () => {
|
||||
mockBuffer.text = 'submitted-key';
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'return',
|
||||
it.each([
|
||||
{
|
||||
keyName: 'return',
|
||||
sequence: '\r',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
expectedCall: onSubmit,
|
||||
args: ['submitted-key'],
|
||||
},
|
||||
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
|
||||
])(
|
||||
'calls $expectedCall.name when $keyName is pressed',
|
||||
({ keyName, sequence, expectedCall, args }) => {
|
||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('submitted-key');
|
||||
});
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
sequence,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
|
||||
it('calls onCancel when the text input is cancelled', () => {
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: 'escape',
|
||||
sequence: '\u001b',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
});
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
expect(expectedCall).toHaveBeenCalledWith(...args);
|
||||
},
|
||||
);
|
||||
|
||||
it('displays an error message', () => {
|
||||
const { lastFrame } = render(
|
||||
|
||||
@@ -104,48 +104,52 @@ describe('AuthDialog', () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('shows Cloud Shell option when in Cloud Shell environment', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
describe('Environment Variable Effects on Auth Options', () => {
|
||||
const cloudShellLabel = 'Use Cloud Shell user credentials';
|
||||
const metadataServerLabel =
|
||||
'Use metadata server application default credentials';
|
||||
const computeAdcItem = (label: string) => ({
|
||||
label,
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show metadata server application default credentials option in Cloud Shell environment', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).not.toContainEqual({
|
||||
label: 'Use metadata server application default credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows metadata server application default credentials option when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use metadata server application default credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show Cloud Shell option when when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).not.toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
value: AuthType.COMPUTE_ADC,
|
||||
key: AuthType.COMPUTE_ADC,
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
env: { CLOUD_SHELL: 'true' },
|
||||
shouldContain: [computeAdcItem(cloudShellLabel)],
|
||||
shouldNotContain: [computeAdcItem(metadataServerLabel)],
|
||||
desc: 'in Cloud Shell',
|
||||
},
|
||||
{
|
||||
env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' },
|
||||
shouldContain: [computeAdcItem(metadataServerLabel)],
|
||||
shouldNotContain: [computeAdcItem(cloudShellLabel)],
|
||||
desc: 'with GEMINI_CLI_USE_COMPUTE_ADC',
|
||||
},
|
||||
{
|
||||
env: {},
|
||||
shouldContain: [],
|
||||
shouldNotContain: [
|
||||
computeAdcItem(cloudShellLabel),
|
||||
computeAdcItem(metadataServerLabel),
|
||||
],
|
||||
desc: 'by default',
|
||||
},
|
||||
])(
|
||||
'correctly shows/hides COMPUTE_ADC options $desc',
|
||||
({ env, shouldContain, shouldNotContain }) => {
|
||||
process.env = { ...env };
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
for (const item of shouldContain) {
|
||||
expect(items).toContainEqual(item);
|
||||
}
|
||||
for (const item of shouldNotContain) {
|
||||
expect(items).not.toContainEqual(item);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('filters auth types when enforcedType is set', () => {
|
||||
@@ -163,31 +167,41 @@ describe('AuthDialog', () => {
|
||||
expect(initialIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('selects initial auth type from settings', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_API_KEY env var', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('defaults to Login with Google', () => {
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE);
|
||||
describe('Initial Auth Type Selection', () => {
|
||||
it.each([
|
||||
{
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_VERTEX_AI;
|
||||
},
|
||||
expected: AuthType.USE_VERTEX_AI,
|
||||
desc: 'from settings',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
|
||||
},
|
||||
{
|
||||
setup: () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
},
|
||||
expected: AuthType.USE_GEMINI,
|
||||
desc: 'from GEMINI_API_KEY env var',
|
||||
},
|
||||
{
|
||||
setup: () => {},
|
||||
expected: AuthType.LOGIN_WITH_GOOGLE,
|
||||
desc: 'defaults to Login with Google',
|
||||
},
|
||||
])('selects initial auth type $desc', ({ setup, expected }) => {
|
||||
setup();
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAuthSelect', () => {
|
||||
@@ -261,34 +275,66 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
describe('useKeypress', () => {
|
||||
it('does nothing on escape if authError is present', () => {
|
||||
props.authError = 'Some error';
|
||||
it.each([
|
||||
{
|
||||
desc: 'does nothing on escape if authError is present',
|
||||
setup: () => {
|
||||
props.authError = 'Some error';
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.onAuthError).not.toHaveBeenCalled();
|
||||
expect(p.setAuthState).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: 'calls onAuthError on escape if no auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.onAuthError).toHaveBeenCalledWith(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
|
||||
setup: () => {
|
||||
props.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_GEMINI;
|
||||
},
|
||||
expectations: (p: typeof props) => {
|
||||
expect(p.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
expect(p.settings.setValue).not.toHaveBeenCalled();
|
||||
},
|
||||
},
|
||||
])('$desc', ({ setup, expectations }) => {
|
||||
setup();
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).not.toHaveBeenCalled();
|
||||
expect(props.setAuthState).not.toHaveBeenCalled();
|
||||
expectations(props);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onAuthError on escape if no auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).toHaveBeenCalledWith(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
it('renders correctly with auth error', () => {
|
||||
props.authError = 'Something went wrong';
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onSelect(undefined) on escape if auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
expect(props.settings.setValue).not.toHaveBeenCalled();
|
||||
it('renders correctly with enforced auth type', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
90
packages/cli/src/ui/auth/AuthInProgress.test.tsx
Normal file
90
packages/cli/src/ui/auth/AuthInProgress.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { AuthInProgress } from './AuthInProgress.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/CliSpinner.js', () => ({
|
||||
CliSpinner: () => '[Spinner]',
|
||||
}));
|
||||
|
||||
describe('AuthInProgress', () => {
|
||||
const onTimeout = vi.fn();
|
||||
|
||||
const originalError = console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
console.error = (...args) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('was not wrapped in act')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalError.call(console, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders initial state with spinner', () => {
|
||||
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
|
||||
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
|
||||
});
|
||||
|
||||
it('calls onTimeout when ESC is pressed', () => {
|
||||
render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||
|
||||
keypressHandler({ name: 'escape' } as unknown as Key);
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onTimeout when Ctrl+C is pressed', () => {
|
||||
render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||
|
||||
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
|
||||
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(180000);
|
||||
});
|
||||
|
||||
expect(onTimeout).toHaveBeenCalled();
|
||||
await vi.waitUntil(
|
||||
() => lastFrame()?.includes('Authentication timed out'),
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('clears timer on unmount', () => {
|
||||
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
vi.advanceTimersByTime(180000);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
|
||||
│ │
|
||||
│ Something went wrong │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate for this project? │
|
||||
│ │
|
||||
│ (selected) Use Gemini API Key │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
│ │
|
||||
│ Terms of Services and Privacy Notice for Gemini CLI │
|
||||
│ │
|
||||
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
271
packages/cli/src/ui/auth/useAuth.test.tsx
Normal file
271
packages/cli/src/ui/auth/useAuth.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockLoadApiKey = vi.fn();
|
||||
const mockValidateAuthMethod = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
loadApiKey: () => mockLoadApiKey(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/auth.js', () => ({
|
||||
validateAuthMethod: (authType: AuthType) => mockValidateAuthMethod(authType),
|
||||
}));
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = '';
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('validateAuthMethodWithSettings', () => {
|
||||
it('should return error if auth type is enforced and does not match', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
enforcedType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toContain('Authentication is enforced to be oauth');
|
||||
});
|
||||
|
||||
it('should return null if useExternal is true', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
useExternal: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if authType is USE_GEMINI', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should call validateAuthMethod for other auth types', () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Error');
|
||||
const error = validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBe('Validation Error');
|
||||
expect(mockValidateAuthMethod).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAuthCommand', () => {
|
||||
const mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
const createSettings = (selectedType?: AuthType) =>
|
||||
({
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as LoadedSettings;
|
||||
|
||||
it('should initialize with Unauthenticated state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Unauthenticated);
|
||||
});
|
||||
|
||||
it('should set error if no auth type is selected and no env key', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(undefined), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toBe(
|
||||
'No authentication method selected.',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if no auth type is selected but env key exists', async () => {
|
||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(undefined), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue(null);
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate if USE_GEMINI and key is found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue('stored-key');
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate if USE_GEMINI and env key is found', async () => {
|
||||
mockLoadApiKey.mockResolvedValue(null);
|
||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.apiKeyDefaultValue).toBe('env-key');
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if validation fails', async () => {
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Failed');
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toBe('Validation Failed');
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE';
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain(
|
||||
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
|
||||
it('should authenticate successfully for valid auth type', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||
expect(result.current.authError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refreshAuth failure', async () => {
|
||||
(mockConfig.refreshAuth as Mock).mockRejectedValue(
|
||||
new Error('Auth Failed'),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.authError).toContain('Failed to login');
|
||||
expect(result.current.authState).toBe(AuthState.Updating);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -97,27 +97,95 @@ const mockConfig = {
|
||||
} as unknown as Config;
|
||||
|
||||
describe('AlternateBufferQuittingDisplay', () => {
|
||||
const baseUIState = {
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
slashCommands: [],
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
it('renders with active and pending tool messages', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
slashCommands: [],
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
renderMarkdown: false,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
|
||||
});
|
||||
|
||||
it('renders with empty history and no pending items', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('empty');
|
||||
});
|
||||
|
||||
it('renders with history but no pending items', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: mockHistory,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
|
||||
});
|
||||
|
||||
it('renders with pending items but no history', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history: [],
|
||||
pendingHistoryItems: mockPendingHistoryItems,
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
|
||||
});
|
||||
|
||||
it('renders with user and gemini messages', () => {
|
||||
const history: HistoryItem[] = [
|
||||
{ id: 1, type: 'user', text: 'Hello Gemini' },
|
||||
{ id: 2, type: 'gemini', text: 'Hello User!' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AlternateBufferQuittingDisplay />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUIState,
|
||||
history,
|
||||
pendingHistoryItems: [],
|
||||
},
|
||||
config: mockConfig,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,33 +33,28 @@ describe('<AnsiOutputText />', () => {
|
||||
expect(lastFrame()).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('correctly applies all the styles', () => {
|
||||
const data: AnsiOutput = [
|
||||
[
|
||||
createAnsiToken({ text: 'Bold', bold: true }),
|
||||
createAnsiToken({ text: 'Italic', italic: true }),
|
||||
createAnsiToken({ text: 'Underline', underline: true }),
|
||||
createAnsiToken({ text: 'Dim', dim: true }),
|
||||
createAnsiToken({ text: 'Inverse', inverse: true }),
|
||||
],
|
||||
];
|
||||
// Note: ink-testing-library doesn't render styles, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
// Note: ink-testing-library doesn't render styles, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
it.each([
|
||||
{ style: { bold: true }, text: 'Bold' },
|
||||
{ style: { italic: true }, text: 'Italic' },
|
||||
{ style: { underline: true }, text: 'Underline' },
|
||||
{ style: { dim: true }, text: 'Dim' },
|
||||
{ style: { inverse: true }, text: 'Inverse' },
|
||||
])('correctly applies style $text', ({ style, text }) => {
|
||||
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
|
||||
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
|
||||
expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse');
|
||||
expect(lastFrame()).toBe(text);
|
||||
});
|
||||
|
||||
it('correctly applies foreground and background colors', () => {
|
||||
const data: AnsiOutput = [
|
||||
[
|
||||
createAnsiToken({ text: 'Red FG', fg: '#ff0000' }),
|
||||
createAnsiToken({ text: 'Blue BG', bg: '#0000ff' }),
|
||||
],
|
||||
];
|
||||
// Note: ink-testing-library doesn't render colors, so we can only check the text.
|
||||
// We are testing that it renders without crashing.
|
||||
it.each([
|
||||
{ color: { fg: '#ff0000' }, text: 'Red FG' },
|
||||
{ color: { bg: '#0000ff' }, text: 'Blue BG' },
|
||||
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
|
||||
])('correctly applies color $text', ({ color, text }) => {
|
||||
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
|
||||
const { lastFrame } = render(<AnsiOutputText data={data} width={80} />);
|
||||
expect(lastFrame()).toBe('Red FGBlue BG');
|
||||
expect(lastFrame()).toBe(text);
|
||||
});
|
||||
|
||||
it('handles empty lines and empty tokens', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages 1`] = `
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
@@ -29,3 +29,91 @@ Tips for getting started:
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭─────────────────────────────────────────────────────────────────────────────╮
|
||||
│ o tool3 Description for tool 3 │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
|
||||
> Hello Gemini
|
||||
|
||||
✦ Hello User!"
|
||||
`;
|
||||
|
||||
@@ -9,67 +9,269 @@ import {
|
||||
extensionUpdatesReducer,
|
||||
type ExtensionUpdatesState,
|
||||
ExtensionUpdateState,
|
||||
initialExtensionUpdatesState,
|
||||
} from './extensions.js';
|
||||
|
||||
describe('extensionUpdatesReducer', () => {
|
||||
it('should handle RESTARTED action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
batchChecksInProgress: 0,
|
||||
scheduledUpdate: null,
|
||||
};
|
||||
describe('SET_STATE', () => {
|
||||
it.each([
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
ExtensionUpdateState.UPDATED,
|
||||
ExtensionUpdateState.ERROR,
|
||||
])('should handle SET_STATE action for state: %s', (state) => {
|
||||
const action = {
|
||||
type: 'SET_STATE' as const,
|
||||
payload: { name: 'ext1', state },
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: state,
|
||||
notified: false,
|
||||
});
|
||||
});
|
||||
|
||||
const expectedStatus = {
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
};
|
||||
it('should not update state if SET_STATE payload is identical to existing state', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual(expectedStatus);
|
||||
const action = {
|
||||
type: 'SET_STATE' as const,
|
||||
payload: { name: 'ext1', state: ExtensionUpdateState.UPDATE_AVAILABLE },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
lastUpdateTime: 0,
|
||||
lastUpdateCheck: 0,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
batchChecksInProgress: 0,
|
||||
scheduledUpdate: null,
|
||||
};
|
||||
describe('SET_NOTIFIED', () => {
|
||||
it.each([true, false])(
|
||||
'should handle SET_NOTIFIED action with notified: %s',
|
||||
(notified) => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: !notified,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'ext1', notified },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toEqual(initialState);
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should not update state if SET_NOTIFIED payload is identical to existing state', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'ext1', notified: true },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should ignore SET_NOTIFIED if extension does not exist', () => {
|
||||
const action = {
|
||||
type: 'SET_NOTIFIED' as const,
|
||||
payload: { name: 'non-existent', notified: true },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
expect(newState).toBe(initialExtensionUpdatesState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Checks', () => {
|
||||
it('should handle BATCH_CHECK_START action', () => {
|
||||
const action = { type: 'BATCH_CHECK_START' as const };
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
expect(newState.batchChecksInProgress).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle BATCH_CHECK_END action', () => {
|
||||
const initialState = {
|
||||
...initialExtensionUpdatesState,
|
||||
batchChecksInProgress: 1,
|
||||
};
|
||||
const action = { type: 'BATCH_CHECK_END' as const };
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
expect(newState.batchChecksInProgress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scheduled Updates', () => {
|
||||
it('should handle SCHEDULE_UPDATE action', () => {
|
||||
const callback = () => {};
|
||||
const action = {
|
||||
type: 'SCHEDULE_UPDATE' as const,
|
||||
payload: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onComplete: callback,
|
||||
},
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(
|
||||
initialExtensionUpdatesState,
|
||||
action,
|
||||
);
|
||||
|
||||
expect(newState.scheduledUpdate).toEqual({
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [callback],
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge SCHEDULE_UPDATE with existing scheduled update', () => {
|
||||
const callback1 = () => {};
|
||||
const callback2 = () => {};
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
scheduledUpdate: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [callback1],
|
||||
},
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'SCHEDULE_UPDATE' as const,
|
||||
payload: {
|
||||
names: ['ext2'],
|
||||
all: true,
|
||||
onComplete: callback2,
|
||||
},
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.scheduledUpdate).toEqual({
|
||||
names: ['ext1', 'ext2'],
|
||||
all: true, // Should be true if any update is all: true
|
||||
onCompleteCallbacks: [callback1, callback2],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CLEAR_SCHEDULED_UPDATE action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
scheduledUpdate: {
|
||||
names: ['ext1'],
|
||||
all: false,
|
||||
onCompleteCallbacks: [],
|
||||
},
|
||||
};
|
||||
|
||||
const action = { type: 'CLEAR_SCHEDULED_UPDATE' as const };
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.scheduledUpdate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RESTARTED', () => {
|
||||
it('should handle RESTARTED action', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState.extensionStatuses.get('ext1')).toEqual({
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
notified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change state for RESTARTED action if status is not UPDATED_NEEDS_RESTART', () => {
|
||||
const initialState: ExtensionUpdatesState = {
|
||||
...initialExtensionUpdatesState,
|
||||
extensionStatuses: new Map([
|
||||
[
|
||||
'ext1',
|
||||
{
|
||||
status: ExtensionUpdateState.UPDATED,
|
||||
notified: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
const action = {
|
||||
type: 'RESTARTED' as const,
|
||||
payload: { name: 'ext1' },
|
||||
};
|
||||
|
||||
const newState = extensionUpdatesReducer(initialState, action);
|
||||
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > handles nested/complex markdown gracefully (best effort) 1`] = `
|
||||
"Bold *Italic
|
||||
*"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders bold text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders inline code correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders italic text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders links correctly 1`] = `"Google (https://google.com)"`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders mixed markdown correctly 1`] = `
|
||||
"Bold
|
||||
and
|
||||
Italic
|
||||
and
|
||||
Code
|
||||
and
|
||||
Link (https://example.com)"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders plain text correctly 1`] = `"Hello World"`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders raw URLs correctly 1`] = `
|
||||
"Visit
|
||||
https://google.com"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders strikethrough text correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > renders underline correctly 1`] = `
|
||||
"Hello
|
||||
World"
|
||||
`;
|
||||
|
||||
exports[`InlineMarkdownRenderer > RenderInline > respects defaultColor prop 1`] = `"Hello"`;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TableRenderer > handles empty rows 1`] = `
|
||||
"
|
||||
┌──────┬──────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├──────┼──────┼────────┤
|
||||
└──────┴──────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles markdown content in cells 1`] = `
|
||||
"
|
||||
┌───────┬──────────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├───────┼──────────┼────────┤
|
||||
│ Alice │ Engineer │ Active │
|
||||
└───────┴──────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles rows with missing cells 1`] = `
|
||||
"
|
||||
┌───────┬──────────┬────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├───────┼──────────┼────────┤
|
||||
│ Alice │ Engineer │
|
||||
│ Bob │
|
||||
└───────┴──────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a simple table correctly 1`] = `
|
||||
"
|
||||
┌─────────┬──────────┬──────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├─────────┼──────────┼──────────┤
|
||||
│ Alice │ Engineer │ Active │
|
||||
│ Bob │ Designer │ Inactive │
|
||||
│ Charlie │ Manager │ Active │
|
||||
└─────────┴──────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > truncates content when terminal width is small 1`] = `
|
||||
"
|
||||
┌────────┬─────────┬─────────┐
|
||||
│ Name │ Role │ Status │
|
||||
├────────┼─────────┼─────────┤
|
||||
│ Alice │ Engi... │ Active │
|
||||
│ Bob │ Desi... │ Inac... │
|
||||
│ Cha... │ Manager │ Active │
|
||||
└────────┴─────────┴─────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > truncates long markdown content correctly 1`] = `
|
||||
"
|
||||
┌───────────────────────────┬─────┬────┐
|
||||
│ Name │ Rol │ St │
|
||||
├───────────────────────────┼─────┼────┤
|
||||
│ Alice with a very long... │ Eng │ Ac │
|
||||
└───────────────────────────┴─────┴────┘
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`terminalSetup > configureVSCodeStyle > should create new keybindings file if none exists 1`] = `
|
||||
[
|
||||
{
|
||||
"args": {
|
||||
"text": "\\
|
||||
",
|
||||
},
|
||||
"command": "workbench.action.terminal.sendSequence",
|
||||
"key": "ctrl+enter",
|
||||
"when": "terminalFocus",
|
||||
},
|
||||
{
|
||||
"args": {
|
||||
"text": "\\
|
||||
",
|
||||
},
|
||||
"command": "workbench.action.terminal.sendSequence",
|
||||
"key": "shift+enter",
|
||||
"when": "terminalFocus",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ui-sizing > calculateMainAreaWidth > should match snapshot for interpolation range 1`] = `
|
||||
{
|
||||
"100": 95,
|
||||
"104": 98,
|
||||
"108": 101,
|
||||
"112": 104,
|
||||
"116": 107,
|
||||
"120": 110,
|
||||
"124": 113,
|
||||
"128": 116,
|
||||
"132": 119,
|
||||
"80": 78,
|
||||
"84": 82,
|
||||
"88": 85,
|
||||
"92": 88,
|
||||
"96": 92,
|
||||
}
|
||||
`;
|
||||
145
packages/cli/src/ui/utils/kittyProtocolDetector.test.ts
Normal file
145
packages/cli/src/ui/utils/kittyProtocolDetector.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeSync: vi.fn(),
|
||||
enableKittyKeyboardProtocol: vi.fn(),
|
||||
disableKittyKeyboardProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: mocks.writeSync,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
enableKittyKeyboardProtocol: mocks.enableKittyKeyboardProtocol,
|
||||
disableKittyKeyboardProtocol: mocks.disableKittyKeyboardProtocol,
|
||||
}));
|
||||
|
||||
describe('kittyProtocolDetector', () => {
|
||||
let originalStdin: NodeJS.ReadStream & { fd?: number };
|
||||
let originalStdout: NodeJS.WriteStream & { fd?: number };
|
||||
let stdinListeners: Record<string, (data: Buffer) => void> = {};
|
||||
|
||||
// Module functions
|
||||
let detectAndEnableKittyProtocol: typeof import('./kittyProtocolDetector.js').detectAndEnableKittyProtocol;
|
||||
let isKittyProtocolEnabled: typeof import('./kittyProtocolDetector.js').isKittyProtocolEnabled;
|
||||
let enableSupportedProtocol: typeof import('./kittyProtocolDetector.js').enableSupportedProtocol;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mod = await import('./kittyProtocolDetector.js');
|
||||
detectAndEnableKittyProtocol = mod.detectAndEnableKittyProtocol;
|
||||
isKittyProtocolEnabled = mod.isKittyProtocolEnabled;
|
||||
enableSupportedProtocol = mod.enableSupportedProtocol;
|
||||
|
||||
// Mock process.stdin and stdout
|
||||
originalStdin = process.stdin;
|
||||
originalStdout = process.stdout;
|
||||
|
||||
stdinListeners = {};
|
||||
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
value: {
|
||||
isTTY: true,
|
||||
isRaw: false,
|
||||
setRawMode: vi.fn(),
|
||||
on: vi.fn((event, handler) => {
|
||||
stdinListeners[event] = handler;
|
||||
}),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'stdout', {
|
||||
value: {
|
||||
isTTY: true,
|
||||
fd: 1,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'stdin', { value: originalStdin });
|
||||
Object.defineProperty(process, 'stdout', { value: originalStdout });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should resolve immediately if not TTY', async () => {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false });
|
||||
await detectAndEnableKittyProtocol();
|
||||
expect(mocks.writeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enable protocol if response indicates support', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Simulate response
|
||||
expect(stdinListeners['data']).toBeDefined();
|
||||
|
||||
// Send progressive enhancement response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
|
||||
// Send device attributes response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
expect(isKittyProtocolEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enable protocol if timeout occurs', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Fast forward time past timeout
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait longer if progressive enhancement received but not attributes', async () => {
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
|
||||
// Send progressive enhancement response
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
|
||||
// Should not resolve yet
|
||||
vi.advanceTimersByTime(300); // Original timeout passed
|
||||
|
||||
// Send device attributes response late
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle re-enabling protocol', async () => {
|
||||
// First, simulate successful detection to set kittySupported = true
|
||||
const promise = detectAndEnableKittyProtocol();
|
||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
||||
await promise;
|
||||
|
||||
// Reset mocks to clear previous calls
|
||||
mocks.enableKittyKeyboardProtocol.mockClear();
|
||||
|
||||
// Now test re-enabling
|
||||
enableSupportedProtocol();
|
||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
161
packages/cli/src/ui/utils/terminalSetup.test.ts
Normal file
161
packages/cli/src/ui/utils/terminalSetup.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exec: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
homedir: vi.fn(),
|
||||
platform: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: mocks.exec,
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
mkdir: mocks.mkdir,
|
||||
readFile: mocks.readFile,
|
||||
writeFile: mocks.writeFile,
|
||||
copyFile: mocks.copyFile,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: mocks.homedir,
|
||||
platform: mocks.platform,
|
||||
}));
|
||||
|
||||
vi.mock('./kittyProtocolDetector.js', () => ({
|
||||
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('terminalSetup', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
// Default mocks
|
||||
mocks.homedir.mockReturnValue('/home/user');
|
||||
mocks.platform.mockReturnValue('darwin');
|
||||
mocks.mkdir.mockResolvedValue(undefined);
|
||||
mocks.copyFile.mockResolvedValue(undefined);
|
||||
mocks.exec.mockImplementation((cmd, cb) => cb(null, { stdout: '' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detectTerminal', () => {
|
||||
it('should detect VS Code from env var', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('VS Code');
|
||||
});
|
||||
|
||||
it('should detect Cursor from env var', async () => {
|
||||
process.env['CURSOR_TRACE_ID'] = 'some-id';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('Cursor');
|
||||
});
|
||||
|
||||
it('should detect Windsurf from env var', async () => {
|
||||
process.env['VSCODE_GIT_ASKPASS_MAIN'] = '/path/to/windsurf/askpass';
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('Windsurf');
|
||||
});
|
||||
|
||||
it('should detect from parent process', async () => {
|
||||
mocks.platform.mockReturnValue('linux');
|
||||
mocks.exec.mockImplementation((cmd, cb) => {
|
||||
cb(null, { stdout: 'code\n' });
|
||||
});
|
||||
|
||||
const result = await terminalSetup();
|
||||
expect(result.message).toContain('VS Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureVSCodeStyle', () => {
|
||||
it('should create new keybindings file if none exists', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
|
||||
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
|
||||
expect(writtenContent).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should append to existing keybindings', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockResolvedValue('[]');
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
|
||||
expect(writtenContent).toHaveLength(2); // Shift+Enter and Ctrl+Enter
|
||||
});
|
||||
|
||||
it('should not modify if bindings already exist', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const existingBindings = [
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
},
|
||||
];
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail gracefully if json is invalid', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
mocks.readFile.mockResolvedValue('{ invalid json');
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('invalid JSON');
|
||||
});
|
||||
|
||||
it('should handle comments in JSON', async () => {
|
||||
process.env['TERM_PROGRAM'] = 'vscode';
|
||||
const jsonWithComments = '// This is a comment\n[]';
|
||||
mocks.readFile.mockResolvedValue(jsonWithComments);
|
||||
|
||||
const result = await terminalSetup();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mocks.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,35 +204,6 @@ async function configureVSCodeStyle(
|
||||
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
|
||||
};
|
||||
|
||||
// Check if ANY shift+enter or ctrl+enter bindings already exist
|
||||
const existingShiftEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'shift+enter';
|
||||
});
|
||||
|
||||
const existingCtrlEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'ctrl+enter';
|
||||
});
|
||||
|
||||
if (existingShiftEnter || existingCtrlEnter) {
|
||||
const messages: string[] = [];
|
||||
if (existingShiftEnter) {
|
||||
messages.push(`- Shift+Enter binding already exists`);
|
||||
}
|
||||
if (existingCtrlEnter) {
|
||||
messages.push(`- Ctrl+Enter binding already exists`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
|
||||
messages.join('\n') +
|
||||
'\n' +
|
||||
`Please check and modify manually if needed: ${keybindingsFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if our specific bindings already exist
|
||||
const hasOurShiftEnter = keybindings.some((kb) => {
|
||||
const binding = kb as {
|
||||
@@ -260,22 +231,55 @@ async function configureVSCodeStyle(
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasOurShiftEnter || !hasOurCtrlEnter) {
|
||||
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
|
||||
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
|
||||
|
||||
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
|
||||
return {
|
||||
success: true,
|
||||
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
|
||||
requiresRestart: true,
|
||||
};
|
||||
} else {
|
||||
if (hasOurShiftEnter && hasOurCtrlEnter) {
|
||||
return {
|
||||
success: true,
|
||||
message: `${terminalName} keybindings already configured.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if ANY shift+enter or ctrl+enter bindings already exist (that are NOT ours)
|
||||
const existingShiftEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'shift+enter';
|
||||
});
|
||||
|
||||
const existingCtrlEnter = keybindings.find((kb) => {
|
||||
const binding = kb as { key?: string };
|
||||
return binding.key === 'ctrl+enter';
|
||||
});
|
||||
|
||||
if (existingShiftEnter || existingCtrlEnter) {
|
||||
const messages: string[] = [];
|
||||
// Only report conflict if it's not our binding (though we checked above, partial matches might exist)
|
||||
if (existingShiftEnter && !hasOurShiftEnter) {
|
||||
messages.push(`- Shift+Enter binding already exists`);
|
||||
}
|
||||
if (existingCtrlEnter && !hasOurCtrlEnter) {
|
||||
messages.push(`- Ctrl+Enter binding already exists`);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
|
||||
messages.join('\n') +
|
||||
'\n' +
|
||||
`Please check and modify manually if needed: ${keybindingsFile}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
|
||||
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
|
||||
|
||||
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
|
||||
return {
|
||||
success: true,
|
||||
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
|
||||
requiresRestart: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
71
packages/cli/src/ui/utils/ui-sizing.test.ts
Normal file
71
packages/cli/src/ui/utils/ui-sizing.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { calculateMainAreaWidth } from './ui-sizing.js';
|
||||
import { type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isAlternateBufferEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
isAlternateBufferEnabled: mocks.isAlternateBufferEnabled,
|
||||
}));
|
||||
|
||||
describe('ui-sizing', () => {
|
||||
const createSettings = (useFullWidth?: boolean): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
useFullWidth,
|
||||
},
|
||||
},
|
||||
}) as unknown as LoadedSettings;
|
||||
|
||||
describe('calculateMainAreaWidth', () => {
|
||||
it.each([
|
||||
// width, useFullWidth, alternateBuffer, expected
|
||||
[80, true, false, 80],
|
||||
[100, true, false, 100],
|
||||
[80, true, true, 79], // -1 for alternate buffer
|
||||
[100, true, true, 99],
|
||||
|
||||
// Default behavior (useFullWidth undefined or true)
|
||||
[100, undefined, false, 100],
|
||||
|
||||
// useFullWidth: false (Smart sizing)
|
||||
[80, false, false, 78], // 98% of 80
|
||||
[132, false, false, 119], // 90% of 132
|
||||
[200, false, false, 180], // 90% of 200 (>= 132)
|
||||
|
||||
// Interpolation check
|
||||
[106, false, false, 100], // Approx middle
|
||||
])(
|
||||
'should return %i when width=%i, useFullWidth=%s, altBuffer=%s',
|
||||
(width, useFullWidth, altBuffer, expected) => {
|
||||
mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer);
|
||||
const settings = createSettings(useFullWidth);
|
||||
|
||||
expect(calculateMainAreaWidth(width, settings)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should match snapshot for interpolation range', () => {
|
||||
mocks.isAlternateBufferEnabled.mockReturnValue(false);
|
||||
const settings = createSettings(false);
|
||||
|
||||
const results: Record<number, number> = {};
|
||||
// Test range from 80 to 132
|
||||
for (let w = 80; w <= 132; w += 4) {
|
||||
results[w] = calculateMainAreaWidth(w, settings);
|
||||
}
|
||||
|
||||
expect(results).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user