mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-31 08:20:54 -07:00
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
/**
|
|
* @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<typeof import('../../config/extension-manager.js')>();
|
|
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<typeof import('@google/gemini-cli-core')>();
|
|
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<void>)(
|
|
argv,
|
|
);
|
|
|
|
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
|
'my-extension',
|
|
false,
|
|
);
|
|
mockCwd.mockRestore();
|
|
});
|
|
});
|
|
});
|