/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { format } from 'node:util'; import { type Argv } from 'yargs'; import { handleUninstall, uninstallCommand } from './uninstall.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { getErrorMessage } from '@google/gemini-cli-core'; // 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()); const mockGetExtensions = 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, getExtensions: mockGetExtensions, setRequestConsent: vi.fn(), setRequestSetting: vi.fn(), })), }; }); // Mock dependencies const emitConsoleLog = vi.hoisted(() => vi.fn()); const debugLogger = vi.hoisted(() => ({ log: vi.fn((message, ...args) => { emitConsoleLog('log', format(message, ...args)); }), error: vi.fn((message, ...args) => { emitConsoleLog('error', format(message, ...args)); }), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, coreEvents: { emitConsoleLog, }, debugLogger, getErrorMessage: vi.fn(), }; }); vi.mock('../../config/settings.js'); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: vi.fn(), })); vi.mock('../../config/extensions/extensionSettings.js', () => ({ promptForSetting: vi.fn(), })); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); describe('extensions uninstall command', () => { const mockLoadSettings = vi.mocked(loadSettings); const mockGetErrorMessage = vi.mocked(getErrorMessage); const mockExtensionManager = vi.mocked(ExtensionManager); beforeEach(async () => { mockLoadSettings.mockReturnValue({ merged: {}, } as unknown as LoadedSettings); }); afterEach(() => { mockLoadExtensions.mockClear(); mockUninstallExtension.mockClear(); mockGetExtensions.mockClear(); vi.clearAllMocks(); }); describe('handleUninstall', () => { it('should uninstall a single extension', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ names: ['my-extension'] }); expect(mockExtensionManager).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/test/dir', }), ); expect(mockLoadExtensions).toHaveBeenCalled(); expect(mockUninstallExtension).toHaveBeenCalledWith( 'my-extension', false, ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "my-extension" successfully uninstalled.', ); mockCwd.mockRestore(); }); 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(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext2" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext3" successfully uninstalled.', ); mockCwd.mockRestore(); }); it('should uninstall all extensions when --all flag is used', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); mockGetExtensions.mockReturnValue([{ name: 'ext1' }, { name: 'ext2' }]); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ all: true }); expect(mockUninstallExtension).toHaveBeenCalledTimes(2); expect(mockUninstallExtension).toHaveBeenCalledWith('ext1', false); expect(mockUninstallExtension).toHaveBeenCalledWith('ext2', false); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext2" successfully uninstalled.', ); mockCwd.mockRestore(); }); it('should log a message if no extensions are installed and --all flag is used', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockGetExtensions.mockReturnValue([]); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); await handleUninstall({ all: true }); expect(mockUninstallExtension).not.toHaveBeenCalled(); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', 'No extensions currently installed.', ); 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('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(emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "ext1" successfully uninstalled.', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Failed to uninstall "ext2": Extension not found', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'log', '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(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Failed to uninstall "ext1": Extension not found', ); expect(emitConsoleLog).toHaveBeenCalledWith( 'error', '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(emitConsoleLog).toHaveBeenCalledWith( 'error', 'Initialization failed message', ); expect(mockProcessExit).toHaveBeenCalledWith(1); mockProcessExit.mockRestore(); mockCwd.mockRestore(); }); }); describe('uninstallCommand', () => { const command = uninstallCommand; it('should have correct command and describe', () => { expect(command.command).toBe('uninstall [names..]'); expect(command.describe).toBe('Uninstalls one or more extensions.'); }); describe('builder', () => { interface MockYargs { positional: Mock; option: Mock; check: Mock; } let yargsMock: MockYargs; beforeEach(() => { yargsMock = { positional: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(), check: vi.fn().mockReturnThis(), }; }); it('should configure arguments and options', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); 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.option).toHaveBeenCalledWith('all', { type: 'boolean', describe: 'Uninstall all installed extensions.', default: false, }); expect(yargsMock.check).toHaveBeenCalled(); }); it('check function should throw for missing names and no --all flag', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(() => checkCallback({ names: [], all: false })).toThrow( 'Please include at least one extension name to uninstall as a positional argument, or use the --all flag.', ); }); it('check function should pass if --all flag is used even without names', () => { (command.builder as (yargs: Argv) => Argv)( yargsMock as unknown as Argv, ); const checkCallback = yargsMock.check.mock.calls[0][0]; expect(() => checkCallback({ names: [], all: true })).not.toThrow(); }); }); it('handler should call handleUninstall', async () => { mockLoadExtensions.mockResolvedValue(undefined); mockUninstallExtension.mockResolvedValue(undefined); const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir'); interface TestArgv { names?: string[]; all?: boolean; _: string[]; $0: string; } const argv: TestArgv = { names: ['my-extension'], all: false, _: [], $0: '', }; await (command.handler as unknown as (args: TestArgv) => Promise)( argv, ); expect(mockUninstallExtension).toHaveBeenCalledWith( 'my-extension', false, ); mockCwd.mockRestore(); }); }); });