Download packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 40.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx
-
curl -L -o useExecutionLifecycle.test.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx
40.4 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { act } from 'react'; | |
| import { render } from '../../test-utils/render.js'; | |
| import { waitFor } from '../../test-utils/async.js'; | |
| import { | |
| vi, | |
| describe, | |
| it, | |
| expect, | |
| beforeEach, | |
| afterEach, | |
| type Mock, | |
| } from 'vitest'; | |
| import { NoopSandboxManager, escapeShellArg } from '@google/gemini-cli-core'; | |
| const mockIsBinary = vi.hoisted(() => vi.fn()); | |
| const mockShellExecutionService = vi.hoisted(() => vi.fn()); | |
| const mockShellKill = vi.hoisted(() => vi.fn()); | |
| const mockShellBackground = vi.hoisted(() => vi.fn()); | |
| const mockShellSubscribe = vi.hoisted(() => | |
| vi.fn< | |
| (pid: number, listener: (event: ShellOutputEvent) => void) => () => void | |
| >(() => vi.fn()), | |
| ); // Returns unsubscribe | |
| const mockShellOnExit = vi.hoisted(() => | |
| vi.fn< | |
| ( | |
| pid: number, | |
| callback: (exitCode: number, signal?: number) => void, | |
| ) => () => void | |
| >(() => vi.fn()), | |
| ); | |
| const mockLifecycleSubscribe = vi.hoisted(() => | |
| vi.fn< | |
| (pid: number, listener: (event: ShellOutputEvent) => void) => () => void | |
| >(() => vi.fn()), | |
| ); | |
| const mockLifecycleOnExit = vi.hoisted(() => | |
| vi.fn< | |
| ( | |
| pid: number, | |
| callback: (exitCode: number, signal?: number) => void, | |
| ) => () => void | |
| >(() => vi.fn()), | |
| ); | |
| const mockLifecycleKill = vi.hoisted(() => vi.fn()); | |
| const mockLifecycleBackground = vi.hoisted(() => vi.fn()); | |
| const mockLifecycleOnBackground = vi.hoisted(() => vi.fn()); | |
| const mockLifecycleOffBackground = vi.hoisted(() => vi.fn()); | |
| vi.mock('@google/gemini-cli-core', async (importOriginal) => { | |
| const actual = | |
| await importOriginal<typeof import('@google/gemini-cli-core')>(); | |
| return { | |
| ...actual, | |
| ShellExecutionService: { | |
| execute: mockShellExecutionService, | |
| kill: mockShellKill, | |
| background: mockShellBackground, | |
| subscribe: mockShellSubscribe, | |
| onExit: mockShellOnExit, | |
| }, | |
| ExecutionLifecycleService: { | |
| subscribe: mockLifecycleSubscribe, | |
| onExit: mockLifecycleOnExit, | |
| kill: mockLifecycleKill, | |
| background: mockLifecycleBackground, | |
| onBackground: mockLifecycleOnBackground, | |
| offBackground: mockLifecycleOffBackground, | |
| }, | |
| isBinary: mockIsBinary, | |
| }; | |
| }); | |
| vi.mock('node:fs', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('node:fs')>(); | |
| const mockFs = { | |
| ...actual, | |
| existsSync: vi.fn(), | |
| mkdtempSync: vi.fn(), | |
| unlinkSync: vi.fn(), | |
| readFileSync: vi.fn(), | |
| rmSync: vi.fn(), | |
| }; | |
| return { | |
| ...mockFs, | |
| default: mockFs, | |
| }; | |
| }); | |
| vi.mock('node:os', async (importOriginal) => { | |
| const actual = await importOriginal<typeof import('node:os')>(); | |
| const mocked = { | |
| ...actual, | |
| homedir: vi.fn(() => '/home/user'), | |
| platform: vi.fn(() => 'linux'), | |
| tmpdir: vi.fn(() => '/tmp'), | |
| }; | |
| return { | |
| ...mocked, | |
| default: mocked, | |
| }; | |
| }); | |
| vi.mock('node:crypto'); | |
| import { | |
| useExecutionLifecycle, | |
| OUTPUT_UPDATE_INTERVAL_MS, | |
| } from './useExecutionLifecycle.js'; | |
| import { | |
| type Config, | |
| type GeminiClient, | |
| type ShellExecutionResult, | |
| type ShellOutputEvent, | |
| type AnsiOutput, | |
| CoreToolCallStatus, | |
| } from '@google/gemini-cli-core'; | |
| import * as fs from 'node:fs'; | |
| import * as os from 'node:os'; | |
| import * as path from 'node:path'; | |
| import * as crypto from 'node:crypto'; | |
| describe('useExecutionLifecycle', () => { | |
| let addItemToHistoryMock: Mock; | |
| let setPendingHistoryItemMock: Mock; | |
| let onExecMock: Mock; | |
| let onDebugMessageMock: Mock; | |
| let mockConfig: Config; | |
| let mockGeminiClient: GeminiClient; | |
| let mockShellOutputCallback: (event: ShellOutputEvent) => void; | |
| let resolveExecutionPromise: (result: ShellExecutionResult) => void; | |
| let setShellInputFocusedMock: Mock; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| addItemToHistoryMock = vi.fn(); | |
| setPendingHistoryItemMock = vi.fn(); | |
| onExecMock = vi.fn(); | |
| onDebugMessageMock = vi.fn(); | |
| setShellInputFocusedMock = vi.fn(); | |
| mockConfig = { | |
| getTargetDir: () => '/test/dir', | |
| getEnableInteractiveShell: () => false, | |
| getSessionId: () => 'test-session-id', | |
| getShellExecutionConfig: () => ({ | |
| terminalHeight: 20, | |
| terminalWidth: 80, | |
| sandboxManager: new NoopSandboxManager(), | |
| sanitizationConfig: { | |
| allowedEnvironmentVariables: [], | |
| blockedEnvironmentVariables: [], | |
| enableEnvironmentVariableRedaction: false, | |
| }, | |
| }), | |
| } as unknown as Config; | |
| mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient; | |
| vi.mocked(os.platform).mockReturnValue('linux'); | |
| vi.mocked(os.tmpdir).mockReturnValue('/tmp'); | |
| (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( | |
| Buffer.from('abcdef', 'hex'), | |
| ); | |
| mockIsBinary.mockReturnValue(false); | |
| vi.mocked(fs.existsSync).mockReturnValue(false); | |
| vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-shell-abcdef'); | |
| mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { | |
| mockShellOutputCallback = callback; | |
| return Promise.resolve({ | |
| pid: 12345, | |
| result: new Promise((resolve) => { | |
| resolveExecutionPromise = resolve; | |
| }), | |
| }); | |
| }); | |
| }); | |
| const renderProcessorHook = async () => { | |
| let hookResult: ReturnType<typeof useExecutionLifecycle>; | |
| let renderCount = 0; | |
| function TestComponent({ | |
| isWaitingForConfirmation, | |
| }: { | |
| isWaitingForConfirmation?: boolean; | |
| }) { | |
| renderCount++; | |
| hookResult = useExecutionLifecycle( | |
| addItemToHistoryMock, | |
| setPendingHistoryItemMock, | |
| onExecMock, | |
| onDebugMessageMock, | |
| mockConfig, | |
| mockGeminiClient, | |
| setShellInputFocusedMock, | |
| undefined, | |
| undefined, | |
| undefined, | |
| isWaitingForConfirmation, | |
| ); | |
| return null; | |
| } | |
| const { rerender } = await render(<TestComponent />); | |
| return { | |
| result: { | |
| get current() { | |
| return hookResult; | |
| }, | |
| }, | |
| getRenderCount: () => renderCount, | |
| rerender: (isWaitingForConfirmation?: boolean) => | |
| rerender( | |
| <TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />, | |
| ), | |
| }; | |
| }; | |
| const createMockServiceResult = ( | |
| overrides: Partial<ShellExecutionResult> = {}, | |
| ): ShellExecutionResult => ({ | |
| rawOutput: Buffer.from(overrides.output || ''), | |
| output: 'Success', | |
| exitCode: 0, | |
| signal: null, | |
| error: null, | |
| aborted: false, | |
| pid: 12345, | |
| executionMethod: 'child_process', | |
| ...overrides, | |
| }); | |
| it('should initiate command execution and set pending state', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('ls -l', new AbortController().signal); | |
| }); | |
| expect(addItemToHistoryMock).toHaveBeenCalledWith( | |
| { type: 'user_shell', text: 'ls -l' }, | |
| expect.any(Number), | |
| ); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith({ | |
| type: 'tool_group', | |
| tools: [ | |
| expect.objectContaining({ | |
| name: 'Shell Command', | |
| status: CoreToolCallStatus.Executing, | |
| }), | |
| ], | |
| }); | |
| const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp'); | |
| const escapedTmpFile = escapeShellArg(tmpFile, 'bash'); | |
| const wrappedCommand = `{\nls -l\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`; | |
| expect(mockShellExecutionService).toHaveBeenCalledWith( | |
| wrappedCommand, | |
| '/test/dir', | |
| expect.any(Function), | |
| expect.any(Object), | |
| false, | |
| expect.objectContaining({ | |
| sessionId: 'test-session-id', | |
| }), | |
| ); | |
| expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise)); | |
| }); | |
| it('should pass the config sessionId into shell execution config', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('top', new AbortController().signal); | |
| }); | |
| expect(mockShellExecutionService).toHaveBeenCalledWith( | |
| expect.any(String), | |
| '/test/dir', | |
| expect.any(Function), | |
| expect.any(Object), | |
| false, | |
| expect.objectContaining({ | |
| sessionId: 'test-session-id', | |
| }), | |
| ); | |
| }); | |
| it('should handle successful execution and update history correctly', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'echo "ok"', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| resolveExecutionPromise(createMockServiceResult({ output: 'ok' })); | |
| }); | |
| await act(async () => await execPromise); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); | |
| expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); // Initial + final | |
| expect(addItemToHistoryMock.mock.calls[1][0]).toEqual( | |
| expect.objectContaining({ | |
| tools: [ | |
| expect.objectContaining({ | |
| status: CoreToolCallStatus.Success, | |
| resultDisplay: 'ok', | |
| }), | |
| ], | |
| }), | |
| ); | |
| expect(mockGeminiClient.addHistory).toHaveBeenCalled(); | |
| expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); | |
| }); | |
| it('should handle command failure and display error status', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'bad-cmd', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| resolveExecutionPromise( | |
| createMockServiceResult({ exitCode: 127, output: 'not found' }), | |
| ); | |
| }); | |
| await act(async () => await execPromise); | |
| const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0]; | |
| expect(finalHistoryItem.tools[0].status).toBe(CoreToolCallStatus.Error); | |
| expect(finalHistoryItem.tools[0].resultDisplay).toContain( | |
| 'Command exited with code 127', | |
| ); | |
| expect(finalHistoryItem.tools[0].resultDisplay).toContain('not found'); | |
| expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); | |
| }); | |
| describe('UI Streaming and Throttling', () => { | |
| beforeEach(() => { | |
| vi.useFakeTimers({ toFake: ['Date'] }); | |
| }); | |
| afterEach(() => { | |
| vi.useRealTimers(); | |
| }); | |
| it('should update UI for text streams (non-interactive)', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand( | |
| 'stream', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp'); | |
| const escapedTmpFile = escapeShellArg(tmpFile, 'bash'); | |
| const wrappedCommand = `{\nstream\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`; | |
| expect(mockShellExecutionService).toHaveBeenCalledWith( | |
| wrappedCommand, | |
| '/test/dir', | |
| expect.any(Function), | |
| expect.any(Object), | |
| false, // enableInteractiveShell | |
| expect.any(Object), | |
| ); | |
| // Wait for the async PID update to happen. | |
| // Call 1: Initial, Call 2: PID update | |
| await waitFor(() => { | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); | |
| }); | |
| // Get the state after the PID update to feed into the stream updaters | |
| const pidUpdateFn = setPendingHistoryItemMock.mock.calls[1][0]; | |
| const initialState = setPendingHistoryItemMock.mock.calls[0][0]; | |
| const stateAfterPid = pidUpdateFn(initialState); | |
| // Simulate first output chunk | |
| act(() => { | |
| mockShellOutputCallback({ | |
| type: 'data', | |
| chunk: 'hello', | |
| }); | |
| }); | |
| // A UI update should have occurred. | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(3); | |
| const streamUpdateFn1 = setPendingHistoryItemMock.mock.calls[2][0]; | |
| const stateAfterStream1 = streamUpdateFn1(stateAfterPid); | |
| expect(stateAfterStream1.tools[0].resultDisplay).toBe('hello'); | |
| // Simulate second output chunk | |
| act(() => { | |
| mockShellOutputCallback({ | |
| type: 'data', | |
| chunk: ' world', | |
| }); | |
| }); | |
| // Another UI update should have occurred. | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(4); | |
| const streamUpdateFn2 = setPendingHistoryItemMock.mock.calls[3][0]; | |
| const stateAfterStream2 = streamUpdateFn2(stateAfterStream1); | |
| expect(stateAfterStream2.tools[0].resultDisplay).toBe('hello world'); | |
| }); | |
| it('should show binary progress messages correctly', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'cat img', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| // Should immediately show the detection message | |
| act(() => { | |
| mockShellOutputCallback({ type: 'binary_detected' }); | |
| }); | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); | |
| }); | |
| // Send another event to trigger the update | |
| act(() => { | |
| mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 0 }); | |
| }); | |
| // The state update is functional, so we test it by executing it. | |
| const updaterFn1 = setPendingHistoryItemMock.mock.lastCall?.[0]; | |
| if (!updaterFn1) { | |
| throw new Error('setPendingHistoryItem was not called'); | |
| } | |
| const initialState = setPendingHistoryItemMock.mock.calls[0][0]; | |
| const stateAfterBinaryDetected = updaterFn1(initialState); | |
| expect(stateAfterBinaryDetected).toEqual( | |
| expect.objectContaining({ | |
| tools: [ | |
| expect.objectContaining({ | |
| resultDisplay: '[Binary output detected. Halting stream...]', | |
| }), | |
| ], | |
| }), | |
| ); | |
| // Now test progress updates | |
| await act(async () => { | |
| await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); | |
| }); | |
| act(() => { | |
| mockShellOutputCallback({ | |
| type: 'binary_progress', | |
| bytesReceived: 2048, | |
| }); | |
| }); | |
| const updaterFn2 = setPendingHistoryItemMock.mock.lastCall?.[0]; | |
| if (!updaterFn2) { | |
| throw new Error('setPendingHistoryItem was not called'); | |
| } | |
| const stateAfterProgress = updaterFn2(stateAfterBinaryDetected); | |
| expect(stateAfterProgress).toEqual( | |
| expect.objectContaining({ | |
| tools: [ | |
| expect.objectContaining({ | |
| resultDisplay: '[Receiving binary output... 2.0 KB received]', | |
| }), | |
| ], | |
| }), | |
| ); | |
| }); | |
| }); | |
| it('should not wrap the command on Windows', async () => { | |
| vi.mocked(os.platform).mockReturnValue('win32'); | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('dir', new AbortController().signal); | |
| }); | |
| expect(mockShellExecutionService).toHaveBeenCalledWith( | |
| 'dir', | |
| '/test/dir', | |
| expect.any(Function), | |
| expect.any(Object), | |
| false, | |
| expect.any(Object), | |
| ); | |
| await act(async () => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await act(async () => await onExecMock.mock.calls[0][0]); | |
| }); | |
| it('should handle command abort and display cancelled status', async () => { | |
| const { result } = await renderProcessorHook(); | |
| const abortController = new AbortController(); | |
| act(() => { | |
| result.current.handleShellCommand('sleep 5', abortController.signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| abortController.abort(); | |
| resolveExecutionPromise( | |
| createMockServiceResult({ aborted: true, output: 'Canceled' }), | |
| ); | |
| }); | |
| await act(async () => await execPromise); | |
| // With the new logic, cancelled commands are not added to history by this hook | |
| // to avoid duplication/flickering, as they are handled by useGeminiStream. | |
| expect(addItemToHistoryMock).toHaveBeenCalledTimes(1); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); | |
| expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); | |
| }); | |
| it('should handle binary output result correctly', async () => { | |
| const { result } = await renderProcessorHook(); | |
| const binaryBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]); | |
| mockIsBinary.mockReturnValue(true); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'cat image.png', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| resolveExecutionPromise( | |
| createMockServiceResult({ rawOutput: binaryBuffer }), | |
| ); | |
| }); | |
| await act(async () => await execPromise); | |
| const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0]; | |
| expect(finalHistoryItem.tools[0].status).toBe(CoreToolCallStatus.Success); | |
| expect(finalHistoryItem.tools[0].resultDisplay).toBe( | |
| '[Command produced binary output, which is not shown.]', | |
| ); | |
| }); | |
| it('should prepend warnings to AnsiOutput array', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| const ansiOutput: AnsiOutput = [ | |
| [ | |
| { | |
| text: 'ansi line 1', | |
| fg: '', | |
| bg: '', | |
| bold: false, | |
| dim: false, | |
| italic: false, | |
| underline: false, | |
| inverse: false, | |
| isUninitialized: false, | |
| }, | |
| ], | |
| ]; | |
| act(() => { | |
| resolveExecutionPromise( | |
| createMockServiceResult({ | |
| exitCode: 1, | |
| output: 'ansi line 1', | |
| ansiOutput, | |
| }), | |
| ); | |
| }); | |
| await act(async () => await execPromise); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); | |
| expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); | |
| const historyCall = addItemToHistoryMock.mock.calls[1][0]; | |
| const display = historyCall.tools[0].resultDisplay; | |
| expect(Array.isArray(display)).toBe(true); | |
| expect(display.length).toBe(3); // Error line, empty line, original output | |
| expect(display[0][0].text).toBe('Command exited with code 1.'); | |
| expect(display[2][0].text).toBe('ansi line 1'); | |
| }); | |
| it('should handle promise rejection and show an error', async () => { | |
| const { result } = await renderProcessorHook(); | |
| const testError = new Error('Unexpected failure'); | |
| mockShellExecutionService.mockImplementation(() => ({ | |
| pid: 12345, | |
| result: Promise.reject(testError), | |
| })); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'a-command', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| await act(async () => await execPromise); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); | |
| expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); | |
| expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({ | |
| type: 'error', | |
| text: 'An unexpected error occurred: Unexpected failure', | |
| }); | |
| expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); | |
| }); | |
| it('should handle synchronous errors during execution and clean up resources', async () => { | |
| const testError = new Error('Synchronous spawn error'); | |
| mockShellExecutionService.mockImplementation(() => { | |
| throw testError; | |
| }); | |
| // Mock that the temp file was created before the error was thrown | |
| vi.mocked(fs.existsSync).mockReturnValue(true); | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'a-command', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| await act(async () => await execPromise); | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); | |
| expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); | |
| expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({ | |
| type: 'error', | |
| text: 'An unexpected error occurred: Synchronous spawn error', | |
| }); | |
| const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp'); | |
| // Verify that the temporary file was cleaned up | |
| expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); | |
| expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); | |
| }); | |
| describe('Directory Change Warning', () => { | |
| it('should show a warning if the working directory changes', async () => { | |
| const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp'); | |
| vi.mocked(fs.existsSync).mockReturnValue(true); | |
| vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand( | |
| 'cd new', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await act(async () => await execPromise); | |
| const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0]; | |
| expect(finalHistoryItem.tools[0].resultDisplay).toContain( | |
| "WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.", | |
| ); | |
| expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); | |
| }); | |
| it('should NOT show a warning if the directory does not change', async () => { | |
| vi.mocked(fs.existsSync).mockReturnValue(true); | |
| vi.mocked(fs.readFileSync).mockReturnValue('/test/dir'); // The same directory | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| act(() => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await act(async () => await execPromise); | |
| const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0]; | |
| expect(finalHistoryItem.tools[0].resultDisplay).not.toContain('WARNING'); | |
| }); | |
| }); | |
| describe('ActiveShellPtyId management', () => { | |
| beforeEach(() => { | |
| // The real service returns a promise that resolves with the pid and result promise | |
| mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { | |
| mockShellOutputCallback = callback; | |
| return Promise.resolve({ | |
| pid: 12345, | |
| result: new Promise((resolve) => { | |
| resolveExecutionPromise = resolve; | |
| }), | |
| }); | |
| }); | |
| }); | |
| it('should have activeShellPtyId as null initially', async () => { | |
| const { result } = await renderProcessorHook(); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should set activeShellPtyId when a command with a PID starts', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| expect(result.current.activeShellPtyId).toBe(12345); | |
| }); | |
| it('should update the pending history item with the ptyId', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| await waitFor(() => { | |
| // Wait for the second call which is the functional update | |
| expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); | |
| }); | |
| // The state update is functional, so we test it by executing it. | |
| const updaterFn = setPendingHistoryItemMock.mock.lastCall?.[0]; | |
| expect(typeof updaterFn).toBe('function'); | |
| // The initial state is the first call to setPendingHistoryItem | |
| const initialState = setPendingHistoryItemMock.mock.calls[0][0]; | |
| const stateAfterPid = updaterFn(initialState); | |
| expect(stateAfterPid.tools[0].ptyId).toBe(12345); | |
| }); | |
| it('should reset activeShellPtyId to null after successful execution', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| expect(result.current.activeShellPtyId).toBe(12345); | |
| await act(async () => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await act(async () => await execPromise); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should reset activeShellPtyId to null after failed execution', async () => { | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand( | |
| 'bad-cmd', | |
| new AbortController().signal, | |
| ); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| expect(result.current.activeShellPtyId).toBe(12345); | |
| await act(async () => { | |
| resolveExecutionPromise(createMockServiceResult({ exitCode: 1 })); | |
| }); | |
| await act(async () => await execPromise); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should reset activeShellPtyId to null if execution promise rejects', async () => { | |
| let rejectResultPromise: (reason?: unknown) => void; | |
| mockShellExecutionService.mockImplementation(() => | |
| Promise.resolve({ | |
| pid: 12345, | |
| result: new Promise((_, reject) => { | |
| rejectResultPromise = reject; | |
| }), | |
| }), | |
| ); | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('cmd', new AbortController().signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| expect(result.current.activeShellPtyId).toBe(12345); | |
| await act(async () => { | |
| rejectResultPromise(new Error('Failure')); | |
| }); | |
| await act(async () => await execPromise); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should not set activeShellPtyId on synchronous execution error and should remain null', async () => { | |
| mockShellExecutionService.mockImplementation(() => { | |
| throw new Error('Sync Error'); | |
| }); | |
| const { result } = await renderProcessorHook(); | |
| expect(result.current.activeShellPtyId).toBeNull(); // Pre-condition | |
| act(() => { | |
| result.current.handleShellCommand('cmd', new AbortController().signal); | |
| }); | |
| const execPromise = onExecMock.mock.calls[0][0]; | |
| // The hook's state should not have changed to a PID | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| await act(async () => await execPromise); // Let the promise resolve | |
| // And it should still be null after everything is done | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should not set activeShellPtyId if service does not return a PID', async () => { | |
| mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { | |
| mockShellOutputCallback = callback; | |
| return Promise.resolve({ | |
| pid: undefined, // No PID | |
| result: new Promise((resolve) => { | |
| resolveExecutionPromise = resolve; | |
| }), | |
| }); | |
| }); | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| // Let microtasks run | |
| await act(async () => {}); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| }); | |
| describe('Background Shell Management', () => { | |
| it('should register a background shell and update count', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| expect(result.current.backgroundTaskCount).toBe(1); | |
| const shell = result.current.backgroundTasks.get(1001); | |
| expect(shell).toEqual( | |
| expect.objectContaining({ | |
| pid: 1001, | |
| command: 'bg-cmd', | |
| output: 'initial', | |
| }), | |
| ); | |
| expect(mockLifecycleOnExit).toHaveBeenCalledWith( | |
| 1001, | |
| expect.any(Function), | |
| ); | |
| expect(mockLifecycleSubscribe).toHaveBeenCalledWith( | |
| 1001, | |
| expect.any(Function), | |
| ); | |
| }); | |
| it('should toggle background shell visibility', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| }); | |
| it('should show info message when toggling background shells if none are active', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(addItemToHistoryMock).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: 'info', | |
| text: 'No background tasks are currently active.', | |
| }), | |
| expect.any(Number), | |
| ); | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| }); | |
| it('should dismiss a background shell and remove it from state', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| await act(async () => { | |
| await result.current.dismissBackgroundTask(1001); | |
| }); | |
| expect(mockLifecycleKill).toHaveBeenCalledWith(1001); | |
| expect(result.current.backgroundTaskCount).toBe(0); | |
| expect(result.current.backgroundTasks.has(1001)).toBe(false); | |
| }); | |
| it('should handle backgrounding the current shell', async () => { | |
| // Simulate an active shell | |
| mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { | |
| mockShellOutputCallback = callback; | |
| return Promise.resolve({ | |
| pid: 555, | |
| result: new Promise((resolve) => { | |
| resolveExecutionPromise = resolve; | |
| }), | |
| }); | |
| }); | |
| const { result } = await renderProcessorHook(); | |
| await act(async () => { | |
| result.current.handleShellCommand('top', new AbortController().signal); | |
| }); | |
| expect(result.current.activeShellPtyId).toBe(555); | |
| act(() => { | |
| result.current.backgroundCurrentExecution(); | |
| }); | |
| expect(mockShellBackground).toHaveBeenCalledWith(555); | |
| // The actual state update happens when the promise resolves with backgrounded: true | |
| // which is handled in handleShellCommand's .then block. | |
| // We simulate that here: | |
| await act(async () => { | |
| resolveExecutionPromise( | |
| createMockServiceResult({ | |
| backgrounded: true, | |
| pid: 555, | |
| output: 'running...', | |
| }), | |
| ); | |
| }); | |
| // Wait for promise resolution | |
| await act(async () => await onExecMock.mock.calls[0][0]); | |
| expect(result.current.backgroundTaskCount).toBe(1); | |
| expect(result.current.activeShellPtyId).toBeNull(); | |
| }); | |
| it('should auto-dismiss background task on successful exit', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(888, 'auto-exit', ''); | |
| }); | |
| // Find the exit callback registered | |
| const exitCallback = mockLifecycleOnExit.mock.calls.find( | |
| (call) => call[0] === 888, | |
| )?.[1]; | |
| expect(exitCallback).toBeDefined(); | |
| if (exitCallback) { | |
| act(() => { | |
| exitCallback(0); | |
| }); | |
| } | |
| // Should be auto-dismissed from the panel | |
| expect(result.current.backgroundTaskCount).toBe(0); | |
| expect(result.current.backgroundTasks.has(888)).toBe(false); | |
| }); | |
| it('should auto-dismiss background task on failed exit', async () => { | |
| const { result } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(999, 'fail-exit', ''); | |
| }); | |
| const exitCallback = mockLifecycleOnExit.mock.calls.find( | |
| (call) => call[0] === 999, | |
| )?.[1]; | |
| expect(exitCallback).toBeDefined(); | |
| if (exitCallback) { | |
| act(() => { | |
| exitCallback(1); | |
| }); | |
| } | |
| // Should be auto-dismissed from the panel | |
| expect(result.current.backgroundTaskCount).toBe(0); | |
| expect(result.current.backgroundTasks.has(999)).toBe(false); | |
| }); | |
| it('should NOT trigger re-render on background shell output when visible', async () => { | |
| const { result, getRenderCount } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| // Show the background shells | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| const initialRenderCount = getRenderCount(); | |
| const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( | |
| (call) => call[0] === 1001, | |
| )?.[1]; | |
| expect(subscribeCallback).toBeDefined(); | |
| if (subscribeCallback) { | |
| act(() => { | |
| subscribeCallback({ type: 'data', chunk: ' + updated' }); | |
| }); | |
| } | |
| expect(getRenderCount()).toBeGreaterThan(initialRenderCount); | |
| const shell = result.current.backgroundTasks.get(1001); | |
| expect(shell?.output).toBe('initial + updated'); | |
| }); | |
| it('should NOT trigger re-render on background shell output when hidden', async () => { | |
| const { result, getRenderCount } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| // Ensure background shells are hidden (default) | |
| const initialRenderCount = getRenderCount(); | |
| const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( | |
| (call) => call[0] === 1001, | |
| )?.[1]; | |
| expect(subscribeCallback).toBeDefined(); | |
| if (subscribeCallback) { | |
| act(() => { | |
| subscribeCallback({ type: 'data', chunk: ' + updated' }); | |
| }); | |
| } | |
| expect(getRenderCount()).toBeGreaterThan(initialRenderCount); | |
| const shell = result.current.backgroundTasks.get(1001); | |
| expect(shell?.output).toBe('initial + updated'); | |
| }); | |
| it('should trigger re-render on binary progress when visible', async () => { | |
| const { result, getRenderCount } = await renderProcessorHook(); | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| // Show the background shells | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| const initialRenderCount = getRenderCount(); | |
| const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( | |
| (call) => call[0] === 1001, | |
| )?.[1]; | |
| expect(subscribeCallback).toBeDefined(); | |
| if (subscribeCallback) { | |
| act(() => { | |
| subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 }); | |
| }); | |
| } | |
| expect(getRenderCount()).toBeGreaterThan(initialRenderCount); | |
| const shell = result.current.backgroundTasks.get(1001); | |
| expect(shell?.isBinary).toBe(true); | |
| expect(shell?.binaryBytesReceived).toBe(1024); | |
| }); | |
| it('should NOT hide background shell when model is responding without confirmation', async () => { | |
| const { result, rerender } = await renderProcessorHook(); | |
| // 1. Register and show background shell | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| // 2. Simulate model responding (not waiting for confirmation) | |
| act(() => { | |
| rerender(false); // isWaitingForConfirmation = false | |
| }); | |
| // Should stay visible | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| }); | |
| it('should hide background shell when waiting for confirmation and restore after delay', async () => { | |
| const { result, rerender } = await renderProcessorHook(); | |
| // 1. Register and show background shell | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| // 2. Simulate tool confirmation showing up | |
| act(() => { | |
| rerender(true); // isWaitingForConfirmation = true | |
| }); | |
| // Should be hidden | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| // 3. Simulate confirmation accepted (waiting for PTY start) | |
| act(() => { | |
| rerender(false); | |
| }); | |
| // Should STAY hidden during the 300ms gap | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| // 4. Wait for restore delay | |
| await waitFor(() => | |
| expect(result.current.isBackgroundTaskVisible).toBe(true), | |
| ); | |
| }); | |
| it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => { | |
| const { result } = await renderProcessorHook(); | |
| // 1. Register and show background shell | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| // 2. Start foreground shell | |
| act(() => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| // Wait for PID to be set | |
| await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); | |
| // Should be hidden automatically | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| // 3. Complete foreground shell | |
| act(() => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await waitFor(() => expect(result.current.activeShellPtyId).toBe(null)); | |
| // Should be restored automatically (after delay) | |
| await waitFor(() => | |
| expect(result.current.isBackgroundTaskVisible).toBe(true), | |
| ); | |
| }); | |
| it('should NOT restore background shell if it was manually hidden during foreground execution', async () => { | |
| const { result } = await renderProcessorHook(); | |
| // 1. Register and show background shell | |
| act(() => { | |
| result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); | |
| }); | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| // 2. Start foreground shell | |
| act(() => { | |
| result.current.handleShellCommand('ls', new AbortController().signal); | |
| }); | |
| await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); | |
| expect(result.current.isBackgroundTaskVisible).toBe(false); | |
| // 3. Manually toggle visibility (e.g. user wants to peek) | |
| act(() => { | |
| result.current.toggleBackgroundTasks(); | |
| }); | |
| expect(result.current.isBackgroundTaskVisible).toBe(true); | |
| // 4. Complete foreground shell | |
| act(() => { | |
| resolveExecutionPromise(createMockServiceResult()); | |
| }); | |
| await waitFor(() => expect(result.current.activeShellPtyId).toBe(null)); | |
| // It should NOT change visibility because manual toggle cleared the auto-restore flag | |
| // After delay it should stay true (as it was manually toggled to true) | |
| await waitFor(() => | |
| expect(result.current.isBackgroundTaskVisible).toBe(true), | |
| ); | |
| }); | |
| }); | |
| }); | |