diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 562744a0deb..c10b3896d1e 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -9,9 +9,14 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js import { MessageType } from '../types.js'; import { extensionsCommand } from './extensionsCommand.js'; import { type CommandContext } from './types.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { type ExtensionUpdateAction } from '../state/extensions.js'; +import open from 'open'; +vi.mock('open', () => ({ + default: vi.fn(), +})); + vi.mock('../../config/extensions/update.js', () => ({ updateExtension: vi.fn(), checkForAllExtensionUpdates: vi.fn(), @@ -26,6 +31,7 @@ describe('extensionsCommand', () => { beforeEach(() => { vi.resetAllMocks(); mockGetExtensions.mockReturnValue([]); + vi.mocked(open).mockClear(); mockContext = createMockCommandContext({ services: { config: { @@ -39,6 +45,11 @@ describe('extensionsCommand', () => { }); }); + afterEach(() => { + // Restore any stubbed environment variables, similar to docsCommand.test.ts + vi.unstubAllEnvs(); + }); + describe('list', () => { it('should add an EXTENSIONS_LIST item to the UI', async () => { if (!extensionsCommand.action) throw new Error('Action not defined'); @@ -302,4 +313,89 @@ describe('extensionsCommand', () => { }); }); }); + + describe('explore', () => { + const exploreAction = extensionsCommand.subCommands?.find( + (cmd) => cmd.name === 'explore', + )?.action; + + if (!exploreAction) { + throw new Error('Explore action not found'); + } + + it("should add an info message and call 'open' in a non-sandbox environment", async () => { + // Ensure no special environment variables that would affect behavior + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', ''); + + await exploreAction(mockContext, ''); + + const extensionsUrl = 'https://geminicli.com/extensions/'; + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }, + expect.any(Number), + ); + + expect(open).toHaveBeenCalledWith(extensionsUrl); + }); + + it('should only add an info message in a sandbox environment', async () => { + // Simulate a sandbox environment + vi.stubEnv('NODE_ENV', ''); + vi.stubEnv('SANDBOX', 'gemini-sandbox'); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }, + expect.any(Number), + ); + + // Ensure 'open' was not called in the sandbox + expect(open).not.toHaveBeenCalled(); + }); + + it('should add an info message and not call open in NODE_ENV test environment', async () => { + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('SANDBOX', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }, + expect.any(Number), + ); + + // Ensure 'open' was not called in test environment + expect(open).not.toHaveBeenCalled(); + }); + + it('should handle errors when opening the browser', async () => { + vi.stubEnv('NODE_ENV', ''); + const extensionsUrl = 'https://geminicli.com/extensions/'; + const errorMessage = 'Failed to open browser'; + vi.mocked(open).mockRejectedValue(new Error(errorMessage)); + + await exploreAction(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }, + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 612de23cc6d..45ea3e47b67 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -13,6 +13,8 @@ import { type SlashCommand, CommandKind, } from './types.js'; +import open from 'open'; +import process from 'node:process'; async function listAction(context: CommandContext) { const historyItem: HistoryItemExtensionsList = { @@ -112,6 +114,51 @@ function updateAction(context: CommandContext, args: string): Promise { return updateComplete.then((_) => {}); } +async function exploreAction(context: CommandContext) { + const extensionsUrl = 'https://geminicli.com/extensions/'; + + // Only check for NODE_ENV for explicit test mode, not for unit test framework + if (process.env['NODE_ENV'] === 'test') { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, + }, + Date.now(), + ); + } else if ( + process.env['SANDBOX'] && + process.env['SANDBOX'] !== 'sandbox-exec' + ) { + context.ui.addItem( + { + type: MessageType.INFO, + text: `View available extensions at ${extensionsUrl}`, + }, + Date.now(), + ); + } else { + context.ui.addItem( + { + type: MessageType.INFO, + text: `Opening extensions page in your browser: ${extensionsUrl}`, + }, + Date.now(), + ); + try { + await open(extensionsUrl); + } catch (_error) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, + }, + Date.now(), + ); + } + } +} + const listExtensionsCommand: SlashCommand = { name: 'list', description: 'List active extensions', @@ -141,11 +188,22 @@ const updateExtensionsCommand: SlashCommand = { }, }; +const exploreExtensionsCommand: SlashCommand = { + name: 'explore', + description: 'Open extensions page in your browser', + kind: CommandKind.BUILT_IN, + action: exploreAction, +}; + export const extensionsCommand: SlashCommand = { name: 'extensions', description: 'Manage extensions', kind: CommandKind.BUILT_IN, - subCommands: [listExtensionsCommand, updateExtensionsCommand], + subCommands: [ + listExtensionsCommand, + updateExtensionsCommand, + exploreExtensionsCommand, + ], action: (context, args) => // Default to list if no subcommand is provided listExtensionsCommand.action!(context, args),