diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 1a62c904..e780ec5f 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -13,6 +13,7 @@ import { clearCommand } from '../ui/commands/clearCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; +import { aboutCommand } from '../ui/commands/aboutCommand.js'; // Mock the command modules to isolate the service from the command implementations. vi.mock('../ui/commands/memoryCommand.js', () => ({ @@ -33,6 +34,9 @@ vi.mock('../ui/commands/themeCommand.js', () => ({ vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: { name: 'privacy', description: 'Mock Privacy' }, })); +vi.mock('../ui/commands/aboutCommand.js', () => ({ + aboutCommand: { name: 'about', description: 'Mock About' }, +})); describe('CommandService', () => { describe('when using default production loader', () => { @@ -58,7 +62,7 @@ describe('CommandService', () => { const tree = commandService.getCommands(); // Post-condition assertions - expect(tree.length).toBe(6); + expect(tree.length).toBe(7); const commandNames = tree.map((cmd) => cmd.name); expect(commandNames).toContain('auth'); @@ -67,19 +71,20 @@ describe('CommandService', () => { expect(commandNames).toContain('clear'); expect(commandNames).toContain('theme'); expect(commandNames).toContain('privacy'); + expect(commandNames).toContain('about'); }); it('should overwrite any existing commands when called again', async () => { // Load once await commandService.loadCommands(); - expect(commandService.getCommands().length).toBe(6); + expect(commandService.getCommands().length).toBe(7); // Load again await commandService.loadCommands(); const tree = commandService.getCommands(); // Should not append, but overwrite - expect(tree.length).toBe(6); + expect(tree.length).toBe(7); }); }); @@ -91,8 +96,9 @@ describe('CommandService', () => { await commandService.loadCommands(); const loadedTree = commandService.getCommands(); - expect(loadedTree.length).toBe(6); + expect(loadedTree.length).toBe(7); expect(loadedTree).toEqual([ + aboutCommand, authCommand, clearCommand, helpCommand, diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 0e2a8acb..ef31952d 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -11,8 +11,10 @@ import { clearCommand } from '../ui/commands/clearCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; +import { aboutCommand } from '../ui/commands/aboutCommand.js'; const loadBuiltInCommands = async (): Promise => [ + aboutCommand, authCommand, clearCommand, helpCommand, diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts new file mode 100644 index 00000000..48dd6db3 --- /dev/null +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { aboutCommand } from './aboutCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import * as versionUtils from '../../utils/version.js'; +import { MessageType } from '../types.js'; + +vi.mock('../../utils/version.js', () => ({ + getCliVersion: vi.fn(), +})); + +describe('aboutCommand', () => { + let mockContext: CommandContext; + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn(), + }, + settings: { + merged: { + selectedAuthType: 'test-auth', + }, + }, + }, + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); + + vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version'); + vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( + 'test-model', + ); + process.env.GOOGLE_CLOUD_PROJECT = 'test-gcp-project'; + Object.defineProperty(process, 'platform', { + value: 'test-os', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + process.env = originalEnv; + vi.clearAllMocks(); + }); + + it('should have the correct name and description', () => { + expect(aboutCommand.name).toBe('about'); + expect(aboutCommand.description).toBe('show version info'); + }); + + it('should call addItem with all version info', async () => { + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ABOUT, + cliVersion: 'test-version', + osVersion: 'test-os', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + gcpProject: 'test-gcp-project', + }, + expect.any(Number), + ); + }); + + it('should show the correct sandbox environment variable', async () => { + process.env.SANDBOX = 'gemini-sandbox'; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxEnv: 'gemini-sandbox', + }), + expect.any(Number), + ); + }); + + it('should show sandbox-exec profile when applicable', async () => { + process.env.SANDBOX = 'sandbox-exec'; + process.env.SEATBELT_PROFILE = 'test-profile'; + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxEnv: 'sandbox-exec (test-profile)', + }), + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts new file mode 100644 index 00000000..3cb8c2f6 --- /dev/null +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getCliVersion } from '../../utils/version.js'; +import { SlashCommand } from './types.js'; +import process from 'node:process'; +import { MessageType, type HistoryItemAbout } from '../types.js'; + +export const aboutCommand: SlashCommand = { + name: 'about', + description: 'show version info', + action: async (context) => { + const osVersion = process.platform; + let sandboxEnv = 'no sandbox'; + if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { + sandboxEnv = process.env.SANDBOX; + } else if (process.env.SANDBOX === 'sandbox-exec') { + sandboxEnv = `sandbox-exec (${ + process.env.SEATBELT_PROFILE || 'unknown' + })`; + } + const modelVersion = context.services.config?.getModel() || 'Unknown'; + const cliVersion = await getCliVersion(); + const selectedAuthType = + context.services.settings.merged.selectedAuthType || ''; + const gcpProject = process.env.GOOGLE_CLOUD_PROJECT || ''; + + const aboutItem: Omit = { + type: MessageType.ABOUT, + cliVersion, + osVersion, + sandboxEnv, + modelVersion, + selectedAuthType, + gcpProject, + }; + + context.ui.addItem(aboutItem, Date.now()); + }, +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index d920117d..6946bde0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -277,89 +277,6 @@ describe('useSlashCommandProcessor', () => { }); }); - describe('/about command', () => { - it('should show the about box with all details including auth and project', async () => { - // Arrange - mockGetCliVersionFn.mockResolvedValue('test-version'); - process.env.SANDBOX = 'gemini-sandbox'; - process.env.GOOGLE_CLOUD_PROJECT = 'test-gcp-project'; - vi.mocked(mockConfig.getModel).mockReturnValue('test-model-from-config'); - - const settings = { - merged: { - selectedAuthType: 'test-auth-type', - contextFileName: 'GEMINI.md', - }, - } as unknown as LoadedSettings; - - const { result } = renderHook(() => - useSlashCommandProcessor( - mockConfig, - settings, - [], - mockAddItem, - mockClearItems, - mockLoadHistory, - mockRefreshStatic, - mockSetShowHelp, - mockOnDebugMessage, - mockOpenThemeDialog, - mockOpenAuthDialog, - mockOpenEditorDialog, - mockCorgiMode, - false, - mockSetQuittingMessages, - vi.fn(), // mockOpenPrivacyNotice - ), - ); - - // Act - await act(async () => { - await result.current.handleSlashCommand('/about'); - }); - - // Assert - expect(mockAddItem).toHaveBeenCalledTimes(2); // user message + about message - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: 'about', - cliVersion: 'test-version', - osVersion: 'test-platform', - sandboxEnv: 'gemini-sandbox', - modelVersion: 'test-model-from-config', - selectedAuthType: 'test-auth-type', - gcpProject: 'test-gcp-project', - }), - expect.any(Number), - ); - }); - - it('should show sandbox-exec profile when applicable', async () => { - // Arrange - mockGetCliVersionFn.mockResolvedValue('test-version'); - process.env.SANDBOX = 'sandbox-exec'; - process.env.SEATBELT_PROFILE = 'test-profile'; - vi.mocked(mockConfig.getModel).mockReturnValue('test-model-from-config'); - - const { result } = getProcessorHook(); - - // Act - await act(async () => { - await result.current.handleSlashCommand('/about'); - }); - - // Assert - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - sandboxEnv: 'sandbox-exec (test-profile)', - }), - expect.any(Number), - ); - }); - }); - describe('Other commands', () => { it('/editor should open editor dialog and return handled', async () => { const { handleSlashCommand } = getProcessor(); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6465a49d..e8d773b4 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -584,35 +584,6 @@ export const useSlashCommandProcessor = ( toggleCorgiMode(); }, }, - { - name: 'about', - description: 'show version info', - action: async (_mainCommand, _subCommand, _args) => { - const osVersion = process.platform; - let sandboxEnv = 'no sandbox'; - if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { - sandboxEnv = process.env.SANDBOX; - } else if (process.env.SANDBOX === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env.SEATBELT_PROFILE || 'unknown' - })`; - } - const modelVersion = config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const selectedAuthType = settings.merged.selectedAuthType || ''; - const gcpProject = process.env.GOOGLE_CLOUD_PROJECT || ''; - addMessage({ - type: MessageType.ABOUT, - timestamp: new Date(), - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - }); - }, - }, { name: 'bug', description: 'submit a bug report', @@ -1021,7 +992,6 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, savedChatTags, config, - settings, showToolDescriptions, session, gitService,