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:
JAYADITYA
2025-11-18 20:50:25 +05:30
committed by GitHub
parent ecf8fba10e
commit 7d33baabe4
2 changed files with 169 additions and 46 deletions

View File

@@ -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();
});
});

View File

@@ -13,7 +13,7 @@ import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
interface UninstallArgs {
name: string; // can be extension name or source URL.
names: string[]; // can be extension names or source URLs.
}
export async function handleUninstall(args: UninstallArgs) {
@@ -26,8 +26,23 @@ export async function handleUninstall(args: UninstallArgs) {
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
await extensionManager.uninstallExtension(args.name, false);
debugLogger.log(`Extension "${args.name}" successfully uninstalled.`);
const errors: Array<{ name: string; error: string }> = [];
for (const name of [...new Set(args.names)]) {
try {
await extensionManager.uninstallExtension(name, false);
debugLogger.log(`Extension "${name}" successfully uninstalled.`);
} catch (error) {
errors.push({ name, error: getErrorMessage(error) });
}
}
if (errors.length > 0) {
for (const { name, error } of errors) {
debugLogger.error(`Failed to uninstall "${name}": ${error}`);
}
process.exit(1);
}
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
@@ -35,25 +50,27 @@ export async function handleUninstall(args: UninstallArgs) {
}
export const uninstallCommand: CommandModule = {
command: 'uninstall <name>',
describe: 'Uninstalls an extension.',
command: 'uninstall <names..>',
describe: 'Uninstalls one or more extensions.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name or source path of the extension to uninstall.',
.positional('names', {
describe:
'The name(s) or source path(s) of the extension(s) to uninstall.',
type: 'string',
array: true,
})
.check((argv) => {
if (!argv.name) {
if (!argv.names || (argv.names as string[]).length === 0) {
throw new Error(
'Please include the name of the extension to uninstall as a positional argument.',
'Please include at least one extension name to uninstall as a positional argument.',
);
}
return true;
}),
handler: async (argv) => {
await handleUninstall({
name: argv['name'] as string,
names: argv['names'] as string[],
});
},
};