diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index d639b7442f..09beb6d0d7 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -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(); + 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 '); - expect(command.describe).toBe('Uninstalls an extension.'); + expect(command.command).toBe('uninstall '); + 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(); }); }); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index c768c95164..52f9ad37e0 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -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 ', - describe: 'Uninstalls an extension.', + command: 'uninstall ', + 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[], }); }, };