diff --git a/packages/cli/src/config/project-policy-cli.test.ts b/packages/cli/src/config/project-policy-cli.test.ts index 6d7d5d5ac0..b219eafee1 100644 --- a/packages/cli/src/config/project-policy-cli.test.ts +++ b/packages/cli/src/config/project-policy-cli.test.ts @@ -32,6 +32,14 @@ vi.mock('@google/gemini-cli-core', async () => { checkers: [], }), getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: vi.fn().mockResolvedValue({ + status: 'match', // IntegrityStatus.MATCH + hash: 'test-hash', + fileCount: 1, + }), + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, }; }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 70c7277f06..7a270b8b74 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -122,7 +122,7 @@ import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; -import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; +import { RELAUNCH_EXIT_CODE, relaunchApp } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; @@ -1462,6 +1462,10 @@ Logging in with Google... Restarting Gemini CLI to continue. policyUpdateConfirmationRequest.newHash, ); setIsRestartingPolicyUpdate(true); + // Give time for the UI to render the restarting message + setTimeout(async () => { + await relaunchApp(); + }, 250); } else { setIsPolicyUpdateDialogOpen(false); } diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx new file mode 100644 index 0000000000..ffc49e443b --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { + PolicyUpdateDialog, + PolicyUpdateChoice, +} from './PolicyUpdateDialog.js'; + +describe('PolicyUpdateDialog', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with default props', () => { + const onSelect = vi.fn(); + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('New or changed project policies detected'); + expect(output).toContain('Location: /test/path'); + expect(output).toContain('Accept and Load'); + expect(output).toContain('Ignore'); + }); + + it('calls onSelect with ACCEPT when accept option is chosen', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + // Accept is the first option, so pressing enter should select it + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.ACCEPT); + }); + }); + + it('calls onSelect with IGNORE when ignore option is chosen', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + // Move down to Ignore option + await act(async () => { + stdin.write('\x1B[B'); // Down arrow + }); + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + }); + }); + + it('calls onSelect with IGNORE when Escape is pressed', async () => { + const onSelect = vi.fn(); + const { stdin } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B'); // Escape key + }); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith(PolicyUpdateChoice.IGNORE); + }); + }); + + it('displays restarting message when isRestarting is true', () => { + const onSelect = vi.fn(); + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain( + 'Gemini CLI is restarting to apply the policy changes...', + ); + }); +}); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx index c7116c5a00..c05044bd9c 100644 --- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -1,20 +1,15 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { Box, Text } from 'ink'; import type React from 'react'; -import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import * as process from 'node:process'; -import { relaunchApp } from '../../utils/processUtils.js'; -import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; export enum PolicyUpdateChoice { ACCEPT = 'accept', @@ -34,33 +29,10 @@ export const PolicyUpdateDialog: React.FC = ({ identifier, isRestarting, }) => { - const [exiting, setExiting] = useState(false); - - useEffect(() => { - let timer: ReturnType; - if (isRestarting) { - timer = setTimeout(async () => { - await relaunchApp(); - }, 250); - } - return () => { - if (timer) clearTimeout(timer); - }; - }, [isRestarting]); - - const handleExit = useCallback(() => { - setExiting(true); - // Give time for the UI to render the exiting message - setTimeout(async () => { - await runExitCleanup(); - process.exit(ExitCodes.FATAL_CANCELLATION_ERROR); - }, 100); - }, []); - useKeypress( (key) => { if (key.name === 'escape') { - handleExit(); + onSelect(PolicyUpdateChoice.IGNORE); return true; } return false; @@ -114,14 +86,6 @@ export const PolicyUpdateDialog: React.FC = ({ )} - {exiting && ( - - - A selection must be made to continue. Exiting since escape was - pressed. - - - )} ); }; diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 780b2da121..6acb59f70b 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -61,7 +61,7 @@ export function getPolicyDirectories( // Admin tier (highest priority) dirs.push(Storage.getSystemPoliciesDir()); - // User tier (second higheset priority) + // User tier (second highest priority) if (policyPaths && policyPaths.length > 0) { dirs.push(...policyPaths); } else {