/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { act } from 'react'; import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; import { ScrollProvider } from '../contexts/ScrollProvider.js'; import { Box } from 'ink'; // Mock dependencies const mockDismissBackgroundShell = vi.fn(); const mockSetActiveBackgroundShellPid = vi.fn(); const mockSetIsBackgroundShellListOpen = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ dismissBackgroundShell: mockDismissBackgroundShell, setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ShellExecutionService: { resizePty: vi.fn(), subscribe: vi.fn(() => vi.fn()), }, }; }); // Mock AnsiOutputText since it's a complex component vi.mock('./AnsiOutput.js', () => ({ AnsiOutputText: ({ data }: { data: string | unknown }) => { if (typeof data === 'string') return <>{data}; // Simple serialization for object data return <>{JSON.stringify(data)}; }, })); // Mock useKeypress let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> = []; vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn((handler, { isActive }) => { keypressHandlers.push({ handler, isActive }); }), })); const simulateKey = (key: Partial) => { const fullKey: Key = createMockKey(key); keypressHandlers.forEach(({ handler, isActive }) => { if (isActive) { handler(fullKey); } }); }; vi.mock('../contexts/MouseContext.js', () => ({ useMouseContext: vi.fn(() => ({ subscribe: vi.fn(), unsubscribe: vi.fn(), })), useMouse: vi.fn(), })); // Mock ScrollableList vi.mock('./shared/ScrollableList.js', () => ({ SCROLL_TO_ITEM_END: 999999, ScrollableList: vi.fn( ({ data, renderItem, }: { data: BackgroundShell[]; renderItem: (props: { item: BackgroundShell; index: number; }) => React.ReactNode; }) => ( {data.map((item: BackgroundShell, index: number) => ( {renderItem({ item, index })} ))} ), ), })); afterEach(() => { vi.restoreAllMocks(); }); const createMockKey = (overrides: Partial): Key => ({ name: '', ctrl: false, alt: false, cmd: false, shift: false, insertable: false, sequence: '', ...overrides, }); describe('', () => { const mockShells = new Map(); const shell1: BackgroundShell = { pid: 1001, command: 'npm start', output: 'Starting server...', isBinary: false, binaryBytesReceived: 0, status: 'running', }; const shell2: BackgroundShell = { pid: 1002, command: 'tail -f log.txt', output: 'Log entry 1', isBinary: false, binaryBytesReceived: 0, status: 'running', }; beforeEach(() => { vi.clearAllMocks(); mockShells.clear(); mockShells.set(shell1.pid, shell1); mockShells.set(shell2.pid, shell2); keypressHandlers = []; }); it('renders the output of the active shell', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders tabs for multiple shells', async () => { const width = 100; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('highlights the focused state', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; const { rerender, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 76, 21, ); rerender( , ); await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, 27, ); unmount(); }); it('renders the process list when isListOpenProp is true', async () => { const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); unmount(); }); it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); // Initial state: shell1 (active) is highlighted // Move to shell2 await act(async () => { simulateKey({ name: 'down' }); }); await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); }); it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; const { waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); }); it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('keeps exit code status color even when selected', async () => { const exitedShell: BackgroundShell = { pid: 1003, command: 'exit 0', output: '', isBinary: false, binaryBytesReceived: 0, status: 'exited', exitCode: 0, }; mockShells.set(exitedShell.pid, exitedShell); const width = 80; const { lastFrame, waitUntilReady, unmount } = render( , width, ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); });