summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/commands/restoreCommand.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/commands/restoreCommand.test.ts')
-rw-r--r--packages/cli/src/ui/commands/restoreCommand.test.ts237
1 files changed, 237 insertions, 0 deletions
diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts
new file mode 100644
index 00000000..53cd7d18
--- /dev/null
+++ b/packages/cli/src/ui/commands/restoreCommand.test.ts
@@ -0,0 +1,237 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ vi,
+ describe,
+ it,
+ expect,
+ beforeEach,
+ afterEach,
+ Mocked,
+ Mock,
+} from 'vitest';
+import * as fs from 'fs/promises';
+import { restoreCommand } from './restoreCommand.js';
+import { type CommandContext } from './types.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { Config, GitService } from '@google/gemini-cli-core';
+
+vi.mock('fs/promises', () => ({
+ readdir: vi.fn(),
+ readFile: vi.fn(),
+ mkdir: vi.fn(),
+}));
+
+describe('restoreCommand', () => {
+ let mockContext: CommandContext;
+ let mockConfig: Config;
+ let mockGitService: GitService;
+ const mockFsPromises = fs as Mocked<typeof fs>;
+ let mockSetHistory: ReturnType<typeof vi.fn>;
+
+ beforeEach(() => {
+ mockSetHistory = vi.fn().mockResolvedValue(undefined);
+ mockGitService = {
+ restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined),
+ } as unknown as GitService;
+
+ mockConfig = {
+ getCheckpointingEnabled: vi.fn().mockReturnValue(true),
+ getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini'),
+ getGeminiClient: vi.fn().mockReturnValue({
+ setHistory: mockSetHistory,
+ }),
+ } as unknown as Config;
+
+ mockContext = createMockCommandContext({
+ services: {
+ config: mockConfig,
+ git: mockGitService,
+ },
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return null if checkpointing is not enabled', () => {
+ (mockConfig.getCheckpointingEnabled as Mock).mockReturnValue(false);
+ const command = restoreCommand(mockConfig);
+ expect(command).toBeNull();
+ });
+
+ it('should return the command if checkpointing is enabled', () => {
+ const command = restoreCommand(mockConfig);
+ expect(command).not.toBeNull();
+ expect(command?.name).toBe('restore');
+ expect(command?.description).toBeDefined();
+ expect(command?.action).toBeDefined();
+ expect(command?.completion).toBeDefined();
+ });
+
+ describe('action', () => {
+ it('should return an error if temp dir is not found', async () => {
+ (mockConfig.getProjectTempDir as Mock).mockReturnValue(undefined);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, '');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'Could not determine the .gemini directory path.',
+ });
+ });
+
+ it('should inform when no checkpoints are found if no args are passed', async () => {
+ mockFsPromises.readdir.mockResolvedValue([]);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, '');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'No restorable tool calls found.',
+ });
+ expect(mockFsPromises.mkdir).toHaveBeenCalledWith(
+ '/tmp/gemini/checkpoints',
+ {
+ recursive: true,
+ },
+ );
+ });
+
+ it('should list available checkpoints if no args are passed', async () => {
+ mockFsPromises.readdir.mockResolvedValue([
+ 'test1.json',
+ 'test2.json',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ] as any);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, '');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'info',
+ content: 'Available tool calls to restore:\n\ntest1\ntest2',
+ });
+ });
+
+ it('should return an error if the specified file is not found', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockFsPromises.readdir.mockResolvedValue(['test1.json'] as any);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, 'test2');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: 'File not found: test2.json',
+ });
+ });
+
+ it('should handle file read errors gracefully', async () => {
+ const readError = new Error('Read failed');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockFsPromises.readdir.mockResolvedValue(['test1.json'] as any);
+ mockFsPromises.readFile.mockRejectedValue(readError);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, 'test1');
+ expect(result).toEqual({
+ type: 'message',
+ messageType: 'error',
+ content: `Could not read restorable tool calls. This is the error: ${readError}`,
+ });
+ });
+
+ it('should restore a tool call and project state', async () => {
+ const toolCallData = {
+ history: [{ type: 'user', text: 'do a thing' }],
+ clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }],
+ commitHash: 'abcdef123',
+ toolCall: { name: 'run_shell_command', args: 'ls' },
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockFsPromises.readdir.mockResolvedValue(['my-checkpoint.json'] as any);
+ mockFsPromises.readFile.mockResolvedValue(JSON.stringify(toolCallData));
+
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, 'my-checkpoint');
+
+ // Check history restoration
+ expect(mockContext.ui.loadHistory).toHaveBeenCalledWith(
+ toolCallData.history,
+ );
+ expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
+
+ // Check git restoration
+ expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith(
+ toolCallData.commitHash,
+ );
+ expect(mockContext.ui.addItem).toHaveBeenCalledWith(
+ {
+ type: 'info',
+ text: 'Restored project to the state before the tool call.',
+ },
+ expect.any(Number),
+ );
+
+ // Check returned action
+ expect(result).toEqual({
+ type: 'tool',
+ toolName: 'run_shell_command',
+ toolArgs: 'ls',
+ });
+ });
+
+ it('should restore even if only toolCall is present', async () => {
+ const toolCallData = {
+ toolCall: { name: 'run_shell_command', args: 'ls' },
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockFsPromises.readdir.mockResolvedValue(['my-checkpoint.json'] as any);
+ mockFsPromises.readFile.mockResolvedValue(JSON.stringify(toolCallData));
+
+ const command = restoreCommand(mockConfig);
+ const result = await command?.action?.(mockContext, 'my-checkpoint');
+
+ expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
+ expect(mockSetHistory).not.toHaveBeenCalled();
+ expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled();
+
+ expect(result).toEqual({
+ type: 'tool',
+ toolName: 'run_shell_command',
+ toolArgs: 'ls',
+ });
+ });
+ });
+
+ describe('completion', () => {
+ it('should return an empty array if temp dir is not found', async () => {
+ (mockConfig.getProjectTempDir as Mock).mockReturnValue(undefined);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.completion?.(mockContext, '');
+ expect(result).toEqual([]);
+ });
+
+ it('should return an empty array on readdir error', async () => {
+ mockFsPromises.readdir.mockRejectedValue(new Error('ENOENT'));
+ const command = restoreCommand(mockConfig);
+ const result = await command?.completion?.(mockContext, '');
+ expect(result).toEqual([]);
+ });
+
+ it('should return a list of checkpoint names', async () => {
+ mockFsPromises.readdir.mockResolvedValue([
+ 'test1.json',
+ 'test2.json',
+ 'not-a-checkpoint.txt',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ] as any);
+ const command = restoreCommand(mockConfig);
+ const result = await command?.completion?.(mockContext, '');
+ expect(result).toEqual(['test1', 'test2']);
+ });
+ });
+});