From 7ff4402dbd56b381726d8e782befe693f10bdb98 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Sat, 14 Mar 2026 18:35:15 +0100 Subject: [PATCH] chore: add tests --- .../src/ui/components/PoliciesDialog.test.tsx | 373 ++++++++++++++++++ .../cli/src/ui/components/PoliciesDialog.tsx | 2 +- .../PoliciesDialog.test.tsx.snap | 37 ++ 3 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/components/PoliciesDialog.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/PoliciesDialog.test.tsx.snap diff --git a/packages/cli/src/ui/components/PoliciesDialog.test.tsx b/packages/cli/src/ui/components/PoliciesDialog.test.tsx new file mode 100644 index 0000000000..1d38747d90 --- /dev/null +++ b/packages/cli/src/ui/components/PoliciesDialog.test.tsx @@ -0,0 +1,373 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { PoliciesDialog } from './PoliciesDialog.js'; +import { PolicyDecision } from '@google/gemini-cli-core'; +import type { PolicyRule } from '@google/gemini-cli-core'; + +function makeRule( + overrides: Partial & { decision: PolicyDecision }, +): PolicyRule { + return { + toolName: undefined, + priority: 0, + source: undefined, + argsPattern: undefined, + ...overrides, + } as PolicyRule; +} + +// Realistic user/workspace/extension policies (default rules are filtered +// out before reaching the dialog — see policiesCommand.ts). +const ALLOW_RULES: PolicyRule[] = [ + makeRule({ + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + priority: 4.1, + source: 'User: allowed-tools.toml', + argsPattern: new RegExp('"command":"git\\ show(?:[\\s"]|\\\\")'), + }), + makeRule({ + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + priority: 4.1, + source: 'User: allowed-tools.toml', + argsPattern: new RegExp('"command":"git\\ diff(?:[\\s"]|\\\\")'), + }), + makeRule({ + decision: PolicyDecision.ALLOW, + toolName: 'run_shell_command', + priority: 4.0, + source: 'Workspace: .gemini/settings.json', + argsPattern: new RegExp('"command":"npm\\ test(?:[\\s"]|\\\\")'), + }), +]; + +const ASK_RULES: PolicyRule[] = [ + makeRule({ + decision: PolicyDecision.ASK_USER, + toolName: 'write_file', + priority: 3, + source: 'Workspace: .gemini/policies/write-guard.toml', + }), + makeRule({ + decision: PolicyDecision.ASK_USER, + toolName: 'run_shell_command', + priority: 2, + source: 'Workspace: .gemini/policies/shell.toml', + }), +]; + +const DENY_RULES: PolicyRule[] = [ + makeRule({ + decision: PolicyDecision.DENY, + toolName: 'run_shell_command', + priority: 10, + source: 'Admin: admin-policies.toml', + argsPattern: new RegExp('"command":"rm\\ -rf(?:[\\s"]|\\\\")'), + }), +]; + +const ALL_RULES = [...ALLOW_RULES, ...ASK_RULES, ...DENY_RULES]; + +const TOOL_DISPLAY_NAMES = new Map([ + ['run_shell_command', 'Shell'], + ['write_file', 'WriteFile'], +]); + +describe('PoliciesDialog', () => { + let onClose: () => void; + + beforeEach(() => { + onClose = vi.fn(); + }); + + it('renders with Allow tab active by default', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + expect(output).toContain('Policies'); + expect(output).toContain('Allow'); + // Should show resolved display names, not internal names + expect(output).toContain('Shell'); + expect(output).not.toContain('run_shell_command'); + }); + + it('shows formatted shell command constraints', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('git show*'); + expect(output).toContain('git diff*'); + expect(output).toContain('npm test*'); + }); + + it('displays policy source on each item', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('User: allowed-tools.toml'); + expect(output).toContain('Workspace: .gemini/settings.json'); + }); + + it('displays correct count for Allow tab', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('3 Allow policies'); + }); + + it('switches to Ask tab with right arrow', async () => { + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + + act(() => { + stdin.write('\x1B[C'); // Right arrow + }); + + await waitFor(() => { + const output = lastFrame(); + expect(output).toContain('WriteFile'); + expect(output).toContain('2 Ask policies'); + }); + }); + + it('switches to Deny tab and shows deny rules', async () => { + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + + // Right arrow twice: Allow → Ask → Deny + act(() => { + stdin.write('\x1B[C'); + }); + act(() => { + stdin.write('\x1B[C'); + }); + + await waitFor(() => { + const output = lastFrame(); + expect(output).toContain('rm -rf*'); + expect(output).toContain('Admin: admin-policies.toml'); + expect(output).toContain('1 Deny policy'); + }); + }); + + it('wraps tabs with left arrow from first tab', async () => { + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + + // Left from Allow wraps to Deny + act(() => { + stdin.write('\x1B[D'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('1 Deny policy'); + }); + }); + + it('shows empty state when a tab has no rules', async () => { + // Only allow rules — Ask and Deny tabs will be empty + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + + // Navigate to Ask tab + act(() => { + stdin.write('\x1B[C'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('No ask policies.'); + }); + }); + + it('navigates list items with up/down arrows', async () => { + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + // First item is active (highest priority first) + expect(lastFrame()).toContain('●'); + + // Move down + act(() => { + stdin.write('\x1B[B'); + }); + + await waitFor(() => { + // Active indicator should still be present on a different item + expect(lastFrame()).toContain('●'); + }); + }); + + it('closes on Escape when search is empty', async () => { + const { stdin } = renderWithProviders( + , + { width: 80 }, + ); + + act(() => { + stdin.write('\x1B'); + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows "all tools" for wildcard rules (no toolName)', async () => { + const wildcardRule = makeRule({ + decision: PolicyDecision.ALLOW, + priority: 1, + source: 'Workspace: .gemini/policies/global.toml', + }); + + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('all tools'); + }); + + it('falls back to internal name for unmapped tools (e.g. MCP)', async () => { + const mcpRule = makeRule({ + decision: PolicyDecision.ALLOW, + toolName: 'mcp_my-server_search', + priority: 5, + source: 'User: mcp-policies.toml', + }); + + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + // No display name mapping exists, so internal name is shown + expect(lastFrame()).toContain('mcp_my-server_search'); + }); + + it('renders with no rules at all (empty allow tab)', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('No allow policies.'); + }); + + it('sorts rules by priority descending within a tab', async () => { + // All three allow rules have priorities 4.1, 4.1, 4.0 + // The 4.1 rules (git show, git diff) should appear before 4.0 (npm test) + const { lastFrame, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + const output = lastFrame(); + const gitShowIdx = output.indexOf('git show*'); + const npmTestIdx = output.indexOf('npm test*'); + expect(gitShowIdx).toBeLessThan(npmTestIdx); + }); +}); diff --git a/packages/cli/src/ui/components/PoliciesDialog.tsx b/packages/cli/src/ui/components/PoliciesDialog.tsx index 1b19a34588..f3aef1ce7e 100644 --- a/packages/cli/src/ui/components/PoliciesDialog.tsx +++ b/packages/cli/src/ui/components/PoliciesDialog.tsx @@ -413,7 +413,7 @@ export function PoliciesDialog({ {showHelpText && ( - (↑↓ navigate, type to search, Tab to cycle tabs, Esc close) + (Use ↑↓ to navigate, ←/→ or Tab to cycle, Esc to close) )} diff --git a/packages/cli/src/ui/components/__snapshots__/PoliciesDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PoliciesDialog.test.tsx.snap new file mode 100644 index 0000000000..d83739eebc --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PoliciesDialog.test.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PoliciesDialog > renders with Allow tab active by default 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Policies: ← Allow (3) │ Ask (2) │ Deny (1) → │ +│ │ +│ Tools that run automatically without confirmation. │ +│ ╭──────────────────────────────────────────────────────────────────────────╮ │ +│ │ Search to filter... │ │ +│ ╰──────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ ● Shell(git show*) │ +│ User: allowed-tools.toml │ +│ Shell(git diff*) │ +│ User: allowed-tools.toml │ +│ Shell(npm test*) │ +│ Workspace: .gemini/settings.json │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ 3 Allow policies │ +│ (↑↓ navigate, type to search, Tab to cycle tabs, Esc close) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`;