Add experimental in-CLI extension install and uninstall subcommands (#15178)

Co-authored-by: Christine Betts <chrstn@google.com>
This commit is contained in:
christine betts
2025-12-23 13:48:27 -06:00
committed by GitHub
parent 5f28614760
commit 563d81e08e
6 changed files with 435 additions and 49 deletions

View File

@@ -26,9 +26,21 @@ import {
type MockedFunction,
} from 'vitest';
import { type ExtensionUpdateAction } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
inferInstallMetadata: vi.fn(),
};
});
import open from 'open';
vi.mock('open', () => ({
@@ -42,6 +54,8 @@ vi.mock('../../config/extensions/update.js', () => ({
const mockDisableExtension = vi.fn();
const mockEnableExtension = vi.fn();
const mockInstallExtension = vi.fn();
const mockUninstallExtension = vi.fn();
const mockGetExtensions = vi.fn();
const inactiveExt: GeminiCLIExtension = {
@@ -102,6 +116,8 @@ describe('extensionsCommand', () => {
Object.assign(actual, {
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
installOrUpdateExtension: mockInstallExtension,
uninstallExtension: mockUninstallExtension,
getExtensions: mockGetExtensions,
});
return actual;
@@ -477,29 +493,189 @@ describe('extensionsCommand', () => {
});
describe('when enableExtensionReloading is true', () => {
it('should include enable and disable subcommands', () => {
it('should include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(true);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).toContain('enable');
expect(subCommandNames).toContain('disable');
expect(subCommandNames).toContain('install');
expect(subCommandNames).toContain('uninstall');
});
});
describe('when enableExtensionReloading is false', () => {
it('should not include enable and disable subcommands', () => {
it('should not include enable, disable, install, and uninstall subcommands', () => {
const command = extensionsCommand(false);
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('when enableExtensionReloading is not provided', () => {
it('should not include enable and disable subcommands by default', () => {
it('should not include enable, disable, install, and uninstall subcommands by default', () => {
const command = extensionsCommand();
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
expect(subCommandNames).not.toContain('enable');
expect(subCommandNames).not.toContain('disable');
expect(subCommandNames).not.toContain('install');
expect(subCommandNames).not.toContain('uninstall');
});
});
describe('install', () => {
let installAction: SlashCommand['action'];
beforeEach(() => {
installAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'install',
)?.action;
expect(installAction).not.toBeNull();
mockContext.invocation!.name = 'install';
});
it('should show usage if no extension name is provided', async () => {
await installAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions install <source>',
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
it('should call installExtension and show success message', async () => {
const packageName = 'test-extension-package';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockResolvedValue({ name: packageName });
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Installing extension from "${packageName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${packageName}" installed successfully.`,
},
expect.any(Number),
);
});
it('should show error message on installation failure', async () => {
const packageName = 'failed-extension';
const errorMessage = 'install failed';
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: packageName,
type: 'git',
});
mockInstallExtension.mockRejectedValue(new Error(errorMessage));
await installAction!(mockContext, packageName);
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'git',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
},
expect.any(Number),
);
});
it('should show error message for invalid source', async () => {
const invalidSource = 'a;b';
await installAction!(mockContext, invalidSource);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Invalid source: ${invalidSource}`,
},
expect.any(Number),
);
expect(mockInstallExtension).not.toHaveBeenCalled();
});
});
describe('uninstall', () => {
let uninstallAction: SlashCommand['action'];
beforeEach(() => {
uninstallAction = extensionsCommand(true).subCommands?.find(
(cmd) => cmd.name === 'uninstall',
)?.action;
expect(uninstallAction).not.toBeNull();
mockContext.invocation!.name = 'uninstall';
});
it('should show usage if no extension name is provided', async () => {
await uninstallAction!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions uninstall <extension-name>',
},
expect.any(Number),
);
expect(mockUninstallExtension).not.toHaveBeenCalled();
});
it('should call uninstallExtension and show success message', async () => {
const extensionName = 'test-extension';
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Uninstalling extension "${extensionName}"...`,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Extension "${extensionName}" uninstalled successfully.`,
},
expect.any(Number),
);
});
it('should show error message on uninstallation failure', async () => {
const extensionName = 'failed-extension';
const errorMessage = 'uninstall failed';
mockUninstallExtension.mockRejectedValue(new Error(errorMessage));
await uninstallAction!(mockContext, extensionName);
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`,
},
expect.any(Number),
);
});
});