Skip to content
98 changes: 97 additions & 1 deletion packages/cli/src/ui/commands/extensionsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -26,6 +31,7 @@ describe('extensionsCommand', () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetExtensions.mockReturnValue([]);
vi.mocked(open).mockClear();
mockContext = createMockCommandContext({
services: {
config: {
Expand All @@ -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');
Expand Down Expand Up @@ -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),
);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

While the existing tests cover the success paths well, a key scenario is missing. It's important to also test the failure case where open() might reject (e.g., if a browser cannot be opened). Adding a test for this scenario would ensure your new error handling logic works as expected and prevents unhandled promise rejections.

});
60 changes: 59 additions & 1 deletion packages/cli/src/ui/commands/extensionsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -112,6 +114,51 @@ function updateAction(context: CommandContext, args: string): Promise<void> {
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}`,
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Failed to open browser. Check out the extensions gallery at ${extensions Url}

Date.now(),
);
}
}
}

const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
Expand Down Expand Up @@ -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),
Expand Down