mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-19 17:50:37 -07:00
feat :uninstall multiple extensions (#13016)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -19,8 +19,30 @@ import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../config/extension-manager.js');
|
||||
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
|
||||
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
|
||||
// The hoisted mocks persist across vi.clearAllMocks() calls, which is necessary
|
||||
// for testing partial failure scenarios in the multiple extension uninstall feature.
|
||||
|
||||
// Hoisted mocks - these survive vi.clearAllMocks()
|
||||
const mockUninstallExtension = vi.hoisted(() => vi.fn());
|
||||
const mockLoadExtensions = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock dependencies with hoisted functions
|
||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
loadExtensions: mockLoadExtensions,
|
||||
setRequestConsent: vi.fn(),
|
||||
setRequestSetting: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/settings.js');
|
||||
vi.mock('../../utils/errors.js');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -52,63 +74,142 @@ describe('extensions uninstall command', () => {
|
||||
let mockDebugLogger: MockDebugLogger;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDebugLogger = (await import('@google/gemini-cli-core'))
|
||||
.debugLogger as unknown as MockDebugLogger;
|
||||
mockLoadSettings.mockReturnValue({
|
||||
merged: {},
|
||||
} as unknown as LoadedSettings);
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.uninstallExtension = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockLoadExtensions.mockClear();
|
||||
mockUninstallExtension.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleUninstall', () => {
|
||||
it('should uninstall an extension', async () => {
|
||||
it('should uninstall a single extension', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ name: 'my-extension' });
|
||||
await handleUninstall({ names: ['my-extension'] });
|
||||
|
||||
expect(mockExtensionManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceDir: '/test/dir',
|
||||
}),
|
||||
);
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
expect(
|
||||
mockExtensionManager.prototype.uninstallExtension,
|
||||
).toHaveBeenCalledWith('my-extension', false);
|
||||
expect(mockLoadExtensions).toHaveBeenCalled();
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'my-extension',
|
||||
false,
|
||||
);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "my-extension" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when uninstallation fails', async () => {
|
||||
it('should uninstall multiple extensions', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith('ext3', false);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "ext2" successfully uninstalled.',
|
||||
);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "ext3" successfully uninstalled.',
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should report errors for failed uninstalls but continue with others', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Uninstall failed');
|
||||
(
|
||||
mockExtensionManager.prototype.uninstallExtension as Mock
|
||||
).mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Uninstall failed message');
|
||||
|
||||
await handleUninstall({ name: 'my-extension' });
|
||||
const error = new Error('Extension not found');
|
||||
// Chain sequential mock behaviors - this works with hoisted mocks
|
||||
mockUninstallExtension
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockGetErrorMessage.mockReturnValue('Extension not found');
|
||||
|
||||
await handleUninstall({ names: ['ext1', 'ext2', 'ext3'] });
|
||||
|
||||
expect(mockUninstallExtension).toHaveBeenCalledTimes(3);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "ext1" successfully uninstalled.',
|
||||
);
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Uninstall failed message',
|
||||
'Failed to uninstall "ext2": Extension not found',
|
||||
);
|
||||
expect(mockDebugLogger.log).toHaveBeenCalledWith(
|
||||
'Extension "ext3" successfully uninstalled.',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with error code if all uninstalls fail', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Extension not found');
|
||||
mockUninstallExtension.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Extension not found');
|
||||
|
||||
await handleUninstall({ names: ['ext1', 'ext2'] });
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to uninstall "ext1": Extension not found',
|
||||
);
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to uninstall "ext2": Extension not found',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when initialization fails', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => {}) as (
|
||||
code?: string | number | null | undefined,
|
||||
) => never);
|
||||
const error = new Error('Initialization failed');
|
||||
mockLoadExtensions.mockRejectedValue(error);
|
||||
mockGetErrorMessage.mockReturnValue('Initialization failed message');
|
||||
|
||||
await handleUninstall({ names: ['my-extension'] });
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Initialization failed message',
|
||||
);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
mockProcessExit.mockRestore();
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,8 +217,8 @@ describe('extensions uninstall command', () => {
|
||||
const command = uninstallCommand as CommandModule;
|
||||
|
||||
it('should have correct command and describe', () => {
|
||||
expect(command.command).toBe('uninstall <name>');
|
||||
expect(command.describe).toBe('Uninstalls an extension.');
|
||||
expect(command.command).toBe('uninstall <names..>');
|
||||
expect(command.describe).toBe('Uninstalls one or more extensions.');
|
||||
});
|
||||
|
||||
describe('builder', () => {
|
||||
@@ -138,36 +239,41 @@ describe('extensions uninstall command', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
|
||||
describe: 'The name or source path of the extension to uninstall.',
|
||||
expect(yargsMock.positional).toHaveBeenCalledWith('names', {
|
||||
describe:
|
||||
'The name(s) or source path(s) of the extension(s) to uninstall.',
|
||||
type: 'string',
|
||||
array: true,
|
||||
});
|
||||
expect(yargsMock.check).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check function should throw for missing name', () => {
|
||||
it('check function should throw for missing names', () => {
|
||||
(command.builder as (yargs: Argv) => Argv)(
|
||||
yargsMock as unknown as Argv,
|
||||
);
|
||||
const checkCallback = yargsMock.check.mock.calls[0][0];
|
||||
expect(() => checkCallback({ name: '' })).toThrow(
|
||||
'Please include the name of the extension to uninstall as a positional argument.',
|
||||
expect(() => checkCallback({ names: [] })).toThrow(
|
||||
'Please include at least one extension name to uninstall as a positional argument.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleUninstall', async () => {
|
||||
mockLoadExtensions.mockResolvedValue(undefined);
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
interface TestArgv {
|
||||
name: string;
|
||||
names: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const argv: TestArgv = { name: 'my-extension', _: [], $0: '' };
|
||||
const argv: TestArgv = { names: ['my-extension'], _: [], $0: '' };
|
||||
await (command.handler as unknown as (args: TestArgv) => void)(argv);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.prototype.uninstallExtension,
|
||||
).toHaveBeenCalledWith('my-extension', false);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'my-extension',
|
||||
false,
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user