mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-10 21:30:40 -07:00
feat(plan): add exit_plan_mode ui and prompt (#18162)
This commit is contained in:
535
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal file
535
packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanContent,
|
||||
processSingleFileContent,
|
||||
type FileSystemService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
validatePlanPath: vi.fn(async () => null),
|
||||
validatePlanContent: vi.fn(async () => null),
|
||||
processSingleFileContent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('ExitPlanModeDialog', () => {
|
||||
const mockTargetDir = '/mock/project';
|
||||
const mockPlansDir = '/mock/project/plans';
|
||||
const mockPlanFullPath = '/mock/project/plans/test-plan.md';
|
||||
|
||||
const samplePlanContent = `## Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add tests in \`src/auth/__tests__/\`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options`;
|
||||
|
||||
const longPlanContent = `## Overview
|
||||
|
||||
Implement a comprehensive authentication system with multiple providers.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create \`src/auth/AuthService.ts\` with login/logout methods
|
||||
2. Add session storage in \`src/storage/SessionStore.ts\`
|
||||
3. Update \`src/commands/index.ts\` to check auth status
|
||||
4. Add OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
|
||||
5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
|
||||
6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
|
||||
7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
|
||||
8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
|
||||
9. Implement session timeout handling in \`src/auth/SessionManager.ts\`
|
||||
10. Add audit logging for auth events in \`src/auth/AuditLogger.ts\`
|
||||
11. Create user profile management in \`src/auth/UserProfile.ts\`
|
||||
12. Add role-based access control in \`src/auth/RBACService.ts\`
|
||||
13. Implement password policy enforcement in \`src/auth/PasswordPolicy.ts\`
|
||||
14. Add brute force protection in \`src/auth/BruteForceGuard.ts\`
|
||||
15. Create secure cookie handling in \`src/auth/CookieManager.ts\`
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- \`src/index.ts\` - Add auth middleware
|
||||
- \`src/config.ts\` - Add auth configuration options
|
||||
- \`src/routes/api.ts\` - Add auth endpoints
|
||||
- \`src/middleware/cors.ts\` - Update CORS for auth headers
|
||||
- \`src/utils/crypto.ts\` - Add encryption utilities
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests for each auth provider
|
||||
- Integration tests for full auth flows
|
||||
- Security penetration testing
|
||||
- Load testing for session management`;
|
||||
|
||||
let onApprove: ReturnType<typeof vi.fn>;
|
||||
let onFeedback: ReturnType<typeof vi.fn>;
|
||||
let onCancel: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: samplePlanContent,
|
||||
returnDisplay: 'Read file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
|
||||
onApprove = vi.fn();
|
||||
onFeedback = vi.fn();
|
||||
onCancel = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderDialog = (options?: { useAlternateBuffer?: boolean }) =>
|
||||
renderWithProviders(
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>,
|
||||
{
|
||||
...options,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
|
||||
'useAlternateBuffer: $useAlternateBuffer',
|
||||
({ useAlternateBuffer }) => {
|
||||
it('renders correctly with plan content', async () => {
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
// Advance timers to pass the debounce period
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||
mockPlanFullPath,
|
||||
mockPlansDir,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onApprove with DEFAULT when second option is selected', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onFeedback when feedback is typed and submitted', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type feedback
|
||||
for (const char of 'Add tests') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFeedback).toHaveBeenCalledWith('Add tests');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCancel when Esc is pressed', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b'); // Escape
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays error state when file read fails', async () => {
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: '',
|
||||
returnDisplay: '',
|
||||
error: 'File not found',
|
||||
});
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Error reading plan: File not found');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays error state when plan file is empty', async () => {
|
||||
vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.');
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Error reading plan: Plan file is empty.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles long plan content appropriately', async () => {
|
||||
vi.mocked(processSingleFileContent).mockResolvedValue({
|
||||
llmContent: longPlanContent,
|
||||
returnDisplay: 'Read file',
|
||||
});
|
||||
|
||||
const { lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'Implement a comprehensive authentication system',
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('allows number key quick selection', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Press '2' to select second option directly
|
||||
writeKey(stdin, '2');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears feedback text when Ctrl+C is pressed while editing', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test feedback') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('test feedback');
|
||||
});
|
||||
|
||||
// Press Ctrl+C to clear
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('test feedback');
|
||||
expect(lastFrame()).toContain('Type your feedback...');
|
||||
});
|
||||
|
||||
// Dialog should still be open (not cancelled)
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bubbles up Ctrl+C when feedback is empty while editing', async () => {
|
||||
const onBubbledQuit = vi.fn();
|
||||
|
||||
const BubbleListener = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
onBubbledQuit();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<BubbleListener>
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>
|
||||
</BubbleListener>,
|
||||
{
|
||||
useAlternateBuffer,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('test');
|
||||
});
|
||||
|
||||
// First Ctrl+C to clear text
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(onBubbledQuit).not.toHaveBeenCalled();
|
||||
|
||||
// Second Ctrl+C to exit (should bubble)
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBubbledQuit).toHaveBeenCalled();
|
||||
});
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit empty feedback when Enter is pressed', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
|
||||
// Press Enter without typing anything
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// Wait a bit to ensure no callback was triggered
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
expect(onApprove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows arrow navigation while typing feedback to change selection', async () => {
|
||||
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add user authentication');
|
||||
});
|
||||
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
// Now use up arrow to navigate back to a different option
|
||||
writeKey(stdin, '\x1b[A'); // Up arrow
|
||||
|
||||
// Press Enter to select the second option (manually accept edits)
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
|
||||
});
|
||||
expect(onFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user