| |
| |
| |
| |
| |
|
|
| import { type ReactElement } from 'react'; |
|
|
| import type { |
| ExtensionLoader, |
| GeminiCLIExtension, |
| } from '@google/gemini-cli-core'; |
| import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; |
| import { MessageType } from '../types.js'; |
| import { |
| completeExtensions, |
| completeExtensionsAndScopes, |
| extensionsCommand, |
| } from './extensionsCommand.js'; |
| import { |
| ConfigExtensionDialog, |
| type ConfigExtensionDialogProps, |
| } from '../components/ConfigExtensionDialog.js'; |
| import { |
| ExtensionRegistryView, |
| type ExtensionRegistryViewProps, |
| } from '../components/views/ExtensionRegistryView.js'; |
| import { type CommandContext, type SlashCommand } from './types.js'; |
|
|
| import { |
| describe, |
| it, |
| expect, |
| vi, |
| beforeEach, |
| afterEach, |
| type MockedFunction, |
| } from 'vitest'; |
| import { type ExtensionUpdateAction } from '../state/extensions.js'; |
| import { |
| ExtensionManager, |
| inferInstallMetadata, |
| } from '../../config/extension-manager.js'; |
| import { SettingScope } from '../../config/settings.js'; |
| import { stat } from 'node:fs/promises'; |
| import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; |
| import { waitFor } from '../../test-utils/async.js'; |
|
|
| vi.mock('../../config/extension-manager.js', async (importOriginal) => { |
| const actual = |
| await importOriginal<typeof import('../../config/extension-manager.js')>(); |
| return { |
| ...actual, |
| inferInstallMetadata: vi.fn(), |
| }; |
| }); |
|
|
| import open from 'open'; |
| import type { Stats } from 'node:fs'; |
|
|
| vi.mock('open', () => ({ |
| default: vi.fn(), |
| })); |
|
|
| vi.mock('node:fs/promises', () => ({ |
| stat: vi.fn(), |
| })); |
|
|
| vi.mock('../../config/extensions/extensionSettings.js', () => ({ |
| ExtensionSettingScope: { |
| USER: 'user', |
| WORKSPACE: 'workspace', |
| }, |
| getScopedEnvContents: vi.fn().mockResolvedValue({}), |
| promptForSetting: vi.fn(), |
| updateSetting: vi.fn(), |
| })); |
|
|
| vi.mock('prompts', () => ({ |
| default: vi.fn(), |
| })); |
|
|
| vi.mock('../../config/extensions/update.js', () => ({ |
| updateExtension: vi.fn(), |
| checkForAllExtensionUpdates: vi.fn(), |
| })); |
|
|
| const mockDisableExtension = vi.fn(); |
| const mockEnableExtension = vi.fn(); |
| const mockInstallExtension = vi.fn(); |
| const mockUninstallExtension = vi.fn(); |
| const mockGetExtensions = vi.fn(); |
|
|
| const inactiveExt: GeminiCLIExtension = { |
| name: 'ext-one', |
| id: 'ext-one-id', |
| version: '1.0.0', |
| isActive: false, |
| path: '/test/dir/ext-one', |
| contextFiles: [], |
| installMetadata: { |
| type: 'git', |
| autoUpdate: false, |
| source: 'https://github.com/some/extension.git', |
| }, |
| }; |
| const activeExt: GeminiCLIExtension = { |
| name: 'ext-two', |
| id: 'ext-two-id', |
| version: '1.0.0', |
| isActive: true, |
| path: '/test/dir/ext-two', |
| contextFiles: [], |
| installMetadata: { |
| type: 'git', |
| autoUpdate: false, |
| source: 'https://github.com/some/extension.git', |
| }, |
| }; |
| const allExt: GeminiCLIExtension = { |
| name: 'all-ext', |
| id: 'all-ext-id', |
| version: '1.0.0', |
| isActive: true, |
| path: '/test/dir/all-ext', |
| contextFiles: [], |
| installMetadata: { |
| type: 'git', |
| autoUpdate: false, |
| source: 'https://github.com/some/extension.git', |
| }, |
| }; |
|
|
| describe('extensionsCommand', () => { |
| let mockContext: CommandContext; |
| const mockDispatchExtensionState = vi.fn(); |
| let mockExtensionLoader: unknown; |
| let mockReloadSkills: MockedFunction<() => Promise<void>>; |
| let mockReloadAgents: MockedFunction<() => Promise<void>>; |
|
|
| beforeEach(() => { |
| vi.resetAllMocks(); |
|
|
| mockExtensionLoader = Object.create(ExtensionManager.prototype); |
| Object.assign(mockExtensionLoader as object, { |
| enableExtension: mockEnableExtension, |
| disableExtension: mockDisableExtension, |
| installOrUpdateExtension: mockInstallExtension, |
| uninstallExtension: mockUninstallExtension, |
| getExtensions: mockGetExtensions, |
| loadExtensionConfig: vi.fn().mockResolvedValue({ |
| name: 'test-ext', |
| settings: [{ name: 'setting1', envVar: 'SETTING1' }], |
| }), |
| }); |
|
|
| mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]); |
| vi.mocked(open).mockClear(); |
| mockReloadAgents = vi.fn().mockResolvedValue(undefined); |
| mockReloadSkills = vi.fn().mockResolvedValue(undefined); |
|
|
| mockContext = createMockCommandContext({ |
| services: { |
| agentContext: { |
| config: { |
| getExtensions: mockGetExtensions, |
| getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), |
| getWorkingDir: () => '/test/dir', |
| reloadSkills: mockReloadSkills, |
| getAgentRegistry: vi.fn().mockReturnValue({ |
| reload: mockReloadAgents, |
| }), |
| }, |
| }, |
| }, |
| ui: { |
| dispatchExtensionStateUpdate: mockDispatchExtensionState, |
| removeComponent: vi.fn(), |
| }, |
| }); |
| }); |
|
|
| afterEach(() => { |
| |
| vi.unstubAllEnvs(); |
| }); |
|
|
| describe('list', () => { |
| it('should add an EXTENSIONS_LIST item to the UI', async () => { |
| const command = extensionsCommand(); |
| if (!command.action) throw new Error('Action not defined'); |
| await command.action(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| }); |
|
|
| it('should show a message if no extensions are installed', async () => { |
| mockGetExtensions.mockReturnValue([]); |
| const command = extensionsCommand(); |
| if (!command.action) throw new Error('Action not defined'); |
| await command.action(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', |
| }); |
| }); |
| }); |
|
|
| describe('completeExtensions', () => { |
| it.each([ |
| { |
| description: 'should return matching extension names', |
| partialArg: 'ext', |
| expected: ['ext-one', 'ext-two'], |
| }, |
| { |
| description: 'should return --all when partialArg matches', |
| partialArg: '--al', |
| expected: ['--all'], |
| }, |
| { |
| description: |
| 'should return both extension names and --all when both match', |
| partialArg: 'all', |
| expected: ['--all', 'all-ext'], |
| }, |
| { |
| description: 'should return an empty array if no matches', |
| partialArg: 'nomatch', |
| expected: [], |
| }, |
| { |
| description: |
| 'should suggest only disabled extension names for the enable command', |
| partialArg: 'ext', |
| expected: ['ext-one'], |
| command: 'enable', |
| }, |
| { |
| description: |
| 'should suggest only enabled extension names for the disable command', |
| partialArg: 'ext', |
| expected: ['ext-two'], |
| command: 'disable', |
| }, |
| ])('$description', async ({ partialArg, expected, command }) => { |
| if (command) { |
| mockContext.invocation!.name = command; |
| } |
| const suggestions = completeExtensions(mockContext, partialArg); |
| expect(suggestions).toEqual(expected); |
| }); |
| }); |
|
|
| describe('completeExtensionsAndScopes', () => { |
| it('expands the list of suggestions with --scope args', () => { |
| const suggestions = completeExtensionsAndScopes(mockContext, 'ext'); |
| expect(suggestions).toEqual([ |
| 'ext-one --scope user', |
| 'ext-one --scope workspace', |
| 'ext-one --scope session', |
| 'ext-two --scope user', |
| 'ext-two --scope workspace', |
| 'ext-two --scope session', |
| ]); |
| }); |
| }); |
|
|
| describe('update', () => { |
| const updateAction = extensionsCommand().subCommands?.find( |
| (cmd) => cmd.name === 'update', |
| )?.action; |
|
|
| if (!updateAction) { |
| throw new Error('Update action not found'); |
| } |
|
|
| it('should show usage if no args are provided', async () => { |
| await updateAction(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions update <extension-names>|--all', |
| }); |
| }); |
|
|
| it('should show a message if no extensions are installed', async () => { |
| mockGetExtensions.mockReturnValue([]); |
| await updateAction(mockContext, 'ext-one'); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', |
| }); |
| }); |
|
|
| it('should inform user if there are no extensions to update with --all', async () => { |
| mockDispatchExtensionState.mockImplementationOnce( |
| async (action: ExtensionUpdateAction) => { |
| if (action.type === 'SCHEDULE_UPDATE') { |
| action.payload.onComplete([]); |
| } |
| }, |
| ); |
|
|
| await updateAction(mockContext, '--all'); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: 'No extensions to update.', |
| }); |
| }); |
|
|
| it('should call setPendingItem and addItem in a finally block on success', async () => { |
| mockDispatchExtensionState.mockImplementationOnce( |
| async (action: ExtensionUpdateAction) => { |
| if (action.type === 'SCHEDULE_UPDATE') { |
| action.payload.onComplete([ |
| { |
| name: 'ext-one', |
| originalVersion: '1.0.0', |
| updatedVersion: '1.0.1', |
| }, |
| { |
| name: 'ext-two', |
| originalVersion: '2.0.0', |
| updatedVersion: '2.0.1', |
| }, |
| ]); |
| } |
| }, |
| ); |
| await updateAction(mockContext, '--all'); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| }); |
|
|
| it('should call setPendingItem and addItem in a finally block on failure', async () => { |
| mockDispatchExtensionState.mockImplementationOnce((_) => { |
| throw new Error('Something went wrong'); |
| }); |
| await updateAction(mockContext, '--all'); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Something went wrong', |
| }); |
| }); |
|
|
| it('should update a single extension by name', async () => { |
| mockDispatchExtensionState.mockImplementationOnce( |
| async (action: ExtensionUpdateAction) => { |
| if (action.type === 'SCHEDULE_UPDATE') { |
| action.payload.onComplete([ |
| { |
| name: 'ext-one', |
| originalVersion: '1.0.0', |
| updatedVersion: '1.0.1', |
| }, |
| ]); |
| } |
| }, |
| ); |
| await updateAction(mockContext, 'ext-one'); |
| expect(mockDispatchExtensionState).toHaveBeenCalledWith({ |
| type: 'SCHEDULE_UPDATE', |
| payload: { |
| all: false, |
| names: ['ext-one'], |
| onComplete: expect.any(Function), |
| }, |
| }); |
| }); |
|
|
| it('should update multiple extensions by name', async () => { |
| mockDispatchExtensionState.mockImplementationOnce( |
| async (action: ExtensionUpdateAction) => { |
| if (action.type === 'SCHEDULE_UPDATE') { |
| action.payload.onComplete([ |
| { |
| name: 'ext-one', |
| originalVersion: '1.0.0', |
| updatedVersion: '1.0.1', |
| }, |
| { |
| name: 'ext-two', |
| originalVersion: '1.0.0', |
| updatedVersion: '1.0.1', |
| }, |
| ]); |
| } |
| }, |
| ); |
| await updateAction(mockContext, 'ext-one ext-two'); |
| expect(mockDispatchExtensionState).toHaveBeenCalledWith({ |
| type: 'SCHEDULE_UPDATE', |
| payload: { |
| all: false, |
| names: ['ext-one', 'ext-two'], |
| onComplete: expect.any(Function), |
| }, |
| }); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.EXTENSIONS_LIST, |
| extensions: expect.any(Array), |
| }); |
| }); |
| }); |
|
|
| describe('explore', () => { |
| const exploreAction = extensionsCommand().subCommands?.find( |
| (cmd) => cmd.name === 'explore', |
| )?.action; |
|
|
| if (!exploreAction) { |
| throw new Error('Explore action not found'); |
| } |
|
|
| it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { |
| mockContext.services.settings.merged.experimental.extensionRegistry = |
| true; |
|
|
| const result = await exploreAction(mockContext, ''); |
|
|
| expect(result).toBeDefined(); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
|
|
| const component = |
| result.component as ReactElement<ExtensionRegistryViewProps>; |
| expect(component.type).toBe(ExtensionRegistryView); |
| expect(component.props.extensionManager).toBe(mockExtensionLoader); |
| }); |
|
|
| it('should handle onSelect and onClose in ExtensionRegistryView', async () => { |
| mockContext.services.settings.merged.experimental.extensionRegistry = |
| true; |
|
|
| const result = await exploreAction(mockContext, ''); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
|
|
| const component = |
| result.component as ReactElement<ExtensionRegistryViewProps>; |
|
|
| const extension = { |
| extensionName: 'test-ext', |
| url: 'https://github.com/test/ext.git', |
| } as RegistryExtension; |
|
|
| vi.mocked(inferInstallMetadata).mockResolvedValue({ |
| source: extension.url, |
| type: 'git', |
| }); |
| mockInstallExtension.mockResolvedValue({ name: extension.url }); |
|
|
| |
| await component.props.onSelect?.(extension); |
|
|
| await waitFor(() => { |
| expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); |
| expect(mockInstallExtension).toHaveBeenCalledWith( |
| { |
| source: extension.url, |
| type: 'git', |
| }, |
| undefined, |
| undefined, |
| ); |
| }); |
| expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); |
|
|
| |
| component.props.onClose?.(); |
| expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it("should add an info message and call 'open' in a non-sandbox environment", async () => { |
| |
| vi.stubEnv('NODE_ENV', ''); |
| vi.stubEnv('SANDBOX', ''); |
|
|
| await exploreAction(mockContext, ''); |
|
|
| const extensionsUrl = 'https://geminicli.com/extensions/'; |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Opening extensions page in your browser: ${extensionsUrl}`, |
| }); |
|
|
| expect(open).toHaveBeenCalledWith(extensionsUrl); |
| }); |
|
|
| it('should only add an info message in a sandbox environment', async () => { |
| |
| vi.stubEnv('NODE_ENV', ''); |
| vi.stubEnv('SANDBOX', 'gemini-sandbox'); |
| const extensionsUrl = 'https://geminicli.com/extensions/'; |
|
|
| await exploreAction(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `View available extensions at ${extensionsUrl}`, |
| }); |
|
|
| |
| expect(open).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should add an info message and not call open in NODE_ENV test environment', async () => { |
| vi.stubEnv('NODE_ENV', 'test'); |
| vi.stubEnv('SANDBOX', ''); |
| const extensionsUrl = 'https://geminicli.com/extensions/'; |
|
|
| await exploreAction(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Would open extensions page in your browser: ${extensionsUrl} (skipped in test environment)`, |
| }); |
|
|
| |
| expect(open).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should handle errors when opening the browser', async () => { |
| vi.stubEnv('NODE_ENV', ''); |
| const extensionsUrl = 'https://geminicli.com/extensions/'; |
| const errorMessage = 'Failed to open browser'; |
| vi.mocked(open).mockRejectedValue(new Error(errorMessage)); |
|
|
| await exploreAction(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`, |
| }); |
| }); |
| }); |
|
|
| describe('when enableExtensionReloading is true', () => { |
| it('should include enable, disable, install, link, and uninstall subcommands', () => { |
| const command = extensionsCommand(true); |
| const subCommandNames = command.subCommands?.map((cmd) => cmd.name); |
| expect(subCommandNames).toContain('enable'); |
| expect(subCommandNames).toContain('disable'); |
| expect(subCommandNames).toContain('install'); |
| expect(subCommandNames).toContain('link'); |
| expect(subCommandNames).toContain('uninstall'); |
| }); |
| }); |
|
|
| describe('when enableExtensionReloading is false', () => { |
| it('should not include enable, disable, install, link, and uninstall subcommands', () => { |
| const command = extensionsCommand(false); |
| const subCommandNames = command.subCommands?.map((cmd) => cmd.name); |
| expect(subCommandNames).not.toContain('enable'); |
| expect(subCommandNames).not.toContain('disable'); |
| expect(subCommandNames).not.toContain('install'); |
| expect(subCommandNames).not.toContain('link'); |
| expect(subCommandNames).not.toContain('uninstall'); |
| }); |
| }); |
|
|
| describe('when enableExtensionReloading is not provided', () => { |
| it('should not include enable, disable, install, link, and uninstall subcommands by default', () => { |
| const command = extensionsCommand(); |
| const subCommandNames = command.subCommands?.map((cmd) => cmd.name); |
| expect(subCommandNames).not.toContain('enable'); |
| expect(subCommandNames).not.toContain('disable'); |
| expect(subCommandNames).not.toContain('install'); |
| expect(subCommandNames).not.toContain('link'); |
| expect(subCommandNames).not.toContain('uninstall'); |
| }); |
| }); |
|
|
| describe('install', () => { |
| let installAction: SlashCommand['action']; |
|
|
| beforeEach(() => { |
| installAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'install', |
| )?.action; |
|
|
| expect(installAction).not.toBeNull(); |
|
|
| mockContext.invocation!.name = 'install'; |
| }); |
|
|
| it('should show usage if no extension name is provided', async () => { |
| await installAction!(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions install <source>', |
| }); |
| expect(mockInstallExtension).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should call installExtension and show success message', async () => { |
| const packageName = 'test-extension-package'; |
| vi.mocked(inferInstallMetadata).mockResolvedValue({ |
| source: packageName, |
| type: 'git', |
| }); |
| mockInstallExtension.mockResolvedValue({ name: packageName }); |
| await installAction!(mockContext, packageName); |
| expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); |
| expect(mockInstallExtension).toHaveBeenCalledWith( |
| { |
| source: packageName, |
| type: 'git', |
| }, |
| undefined, |
| undefined, |
| ); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Installing extension from "${packageName}"...`, |
| }); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Extension "${packageName}" installed successfully.`, |
| }); |
| }); |
|
|
| it('should show error message on installation failure', async () => { |
| const packageName = 'failed-extension'; |
| const errorMessage = 'install failed'; |
| vi.mocked(inferInstallMetadata).mockResolvedValue({ |
| source: packageName, |
| type: 'git', |
| }); |
| mockInstallExtension.mockRejectedValue(new Error(errorMessage)); |
|
|
| await installAction!(mockContext, packageName); |
| expect(inferInstallMetadata).toHaveBeenCalledWith(packageName); |
| expect(mockInstallExtension).toHaveBeenCalledWith( |
| { |
| source: packageName, |
| type: 'git', |
| }, |
| undefined, |
| undefined, |
| ); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: `Failed to install extension from "${packageName}": ${errorMessage}`, |
| }); |
| }); |
|
|
| it('should show error message for invalid source', async () => { |
| const invalidSource = 'a;b'; |
| await installAction!(mockContext, invalidSource); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: `Invalid source: ${invalidSource}`, |
| }); |
| expect(mockInstallExtension).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('link', () => { |
| let linkAction: SlashCommand['action']; |
|
|
| beforeEach(() => { |
| linkAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'link', |
| )?.action; |
|
|
| expect(linkAction).not.toBeNull(); |
| mockContext.invocation!.name = 'link'; |
| }); |
|
|
| it('should show usage if no extension is provided', async () => { |
| await linkAction!(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions link <source>', |
| }); |
| expect(mockInstallExtension).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should call installExtension and show success message', async () => { |
| const packageName = 'test-extension-package'; |
| mockInstallExtension.mockResolvedValue({ name: packageName }); |
| vi.mocked(stat).mockResolvedValue({ |
| size: 100, |
| } as Stats); |
| await linkAction!(mockContext, packageName); |
| expect(mockInstallExtension).toHaveBeenCalledWith( |
| { |
| source: packageName, |
| type: 'link', |
| }, |
| undefined, |
| undefined, |
| ); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Linking extension from "${packageName}"...`, |
| }); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Extension "${packageName}" linked successfully.`, |
| }); |
| }); |
|
|
| it('should show error message on linking failure', async () => { |
| const packageName = 'test-extension-package'; |
| const errorMessage = 'link failed'; |
| mockInstallExtension.mockRejectedValue(new Error(errorMessage)); |
| vi.mocked(stat).mockResolvedValue({ |
| size: 100, |
| } as Stats); |
|
|
| await linkAction!(mockContext, packageName); |
| expect(mockInstallExtension).toHaveBeenCalledWith( |
| { |
| source: packageName, |
| type: 'link', |
| }, |
| undefined, |
| undefined, |
| ); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: `Failed to link extension from "${packageName}": ${errorMessage}`, |
| }); |
| }); |
|
|
| it('should show error message for invalid source', async () => { |
| const packageName = 'test-extension-package'; |
| const errorMessage = 'invalid path'; |
| vi.mocked(stat).mockRejectedValue(new Error(errorMessage)); |
| await linkAction!(mockContext, packageName); |
| expect(mockInstallExtension).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('uninstall', () => { |
| let uninstallAction: SlashCommand['action']; |
|
|
| beforeEach(() => { |
| uninstallAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'uninstall', |
| )?.action; |
|
|
| expect(uninstallAction).not.toBeNull(); |
|
|
| mockContext.invocation!.name = 'uninstall'; |
| }); |
|
|
| it('should show usage if no extension name is provided', async () => { |
| await uninstallAction!(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions uninstall <extension-names...>|--all', |
| }); |
| expect(mockUninstallExtension).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should expose "delete" as an alias', () => { |
| const uninstallCmd = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'uninstall', |
| ); |
| expect(uninstallCmd?.altNames).toContain('delete'); |
| }); |
|
|
| it('should call uninstallExtension and show success message', async () => { |
| const extensionName = 'test-extension'; |
| await uninstallAction!(mockContext, extensionName); |
| expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Uninstalling extension "${extensionName}"...`, |
| }); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: `Extension "${extensionName}" uninstalled successfully.`, |
| }); |
| }); |
|
|
| it('should show error message on uninstallation failure', async () => { |
| const extensionName = 'failed-extension'; |
| const errorMessage = 'uninstall failed'; |
| mockUninstallExtension.mockRejectedValue(new Error(errorMessage)); |
|
|
| await uninstallAction!(mockContext, extensionName); |
| expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`, |
| }); |
| }); |
| }); |
|
|
| describe('enable', () => { |
| let enableAction: SlashCommand['action']; |
|
|
| beforeEach(() => { |
| enableAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'enable', |
| )?.action; |
|
|
| expect(enableAction).not.toBeNull(); |
|
|
| mockContext.invocation!.name = 'enable'; |
| }); |
|
|
| it('should show usage if no extension name is provided', async () => { |
| await enableAction!(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions enable <extension> [--scope=<user|workspace|session>]', |
| }); |
| }); |
|
|
| it('should call enableExtension with the provided scope', async () => { |
| await enableAction!(mockContext, `${inactiveExt.name} --scope=user`); |
| expect(mockEnableExtension).toHaveBeenCalledWith( |
| inactiveExt.name, |
| SettingScope.User, |
| ); |
|
|
| await enableAction!(mockContext, `${inactiveExt.name} --scope workspace`); |
| expect(mockEnableExtension).toHaveBeenCalledWith( |
| inactiveExt.name, |
| SettingScope.Workspace, |
| ); |
| }); |
|
|
| it('should support --all', async () => { |
| mockGetExtensions.mockReturnValue([ |
| inactiveExt, |
| { ...inactiveExt, name: 'another-inactive-ext' }, |
| ]); |
| await enableAction!(mockContext, '--all --scope session'); |
| expect(mockEnableExtension).toHaveBeenCalledWith( |
| inactiveExt.name, |
| SettingScope.Session, |
| ); |
| expect(mockEnableExtension).toHaveBeenCalledWith( |
| 'another-inactive-ext', |
| SettingScope.Session, |
| ); |
| }); |
| }); |
|
|
| describe('disable', () => { |
| let disableAction: SlashCommand['action']; |
|
|
| beforeEach(() => { |
| disableAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'disable', |
| )?.action; |
|
|
| expect(disableAction).not.toBeNull(); |
|
|
| mockContext.invocation!.name = 'disable'; |
| }); |
|
|
| it('should show usage if no extension name is provided', async () => { |
| await disableAction!(mockContext, ''); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions disable <extension> [--scope=<user|workspace|session>]', |
| }); |
| }); |
|
|
| it('should call disableExtension with the provided scope', async () => { |
| await disableAction!(mockContext, `${activeExt.name} --scope=user`); |
| expect(mockDisableExtension).toHaveBeenCalledWith( |
| activeExt.name, |
| SettingScope.User, |
| ); |
|
|
| await disableAction!(mockContext, `${activeExt.name} --scope workspace`); |
| expect(mockDisableExtension).toHaveBeenCalledWith( |
| activeExt.name, |
| SettingScope.Workspace, |
| ); |
| }); |
|
|
| it('should support --all', async () => { |
| mockGetExtensions.mockReturnValue([ |
| activeExt, |
| { ...activeExt, name: 'another-active-ext' }, |
| ]); |
| await disableAction!(mockContext, '--all --scope session'); |
| expect(mockDisableExtension).toHaveBeenCalledWith( |
| activeExt.name, |
| SettingScope.Session, |
| ); |
| expect(mockDisableExtension).toHaveBeenCalledWith( |
| 'another-active-ext', |
| SettingScope.Session, |
| ); |
| }); |
| }); |
|
|
| describe('reload', () => { |
| let restartAction: SlashCommand['action']; |
| let mockRestartExtension: MockedFunction< |
| typeof ExtensionLoader.prototype.restartExtension |
| >; |
|
|
| beforeEach(() => { |
| restartAction = extensionsCommand().subCommands?.find( |
| (c) => c.name === 'reload', |
| )?.action; |
| expect(restartAction).not.toBeNull(); |
|
|
| mockRestartExtension = vi.fn(); |
| mockContext.services.agentContext!.config.getExtensionLoader = vi |
| .fn() |
| .mockImplementation(() => ({ |
| getExtensions: mockGetExtensions, |
| restartExtension: mockRestartExtension, |
| })); |
| mockContext.invocation!.name = 'reload'; |
| }); |
|
|
| it('should show a message if no extensions are installed', async () => { |
| mockContext.services.agentContext!.config.getExtensionLoader = vi |
| .fn() |
| .mockImplementation(() => ({ |
| getExtensions: () => [], |
| restartExtension: mockRestartExtension, |
| })); |
|
|
| await restartAction!(mockContext, '--all'); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.INFO, |
| text: 'No extensions installed. Run `/extensions explore` to check out the gallery.', |
| }); |
| }); |
|
|
| it('reloads all active extensions when --all is provided', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| { name: 'ext2', isActive: true }, |
| { name: 'ext3', isActive: false }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
|
|
| await restartAction!(mockContext, '--all'); |
|
|
| expect(mockRestartExtension).toHaveBeenCalledTimes(2); |
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); |
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[1]); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.INFO, |
| text: 'Reloading 2 extensions...', |
| }), |
| ); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.INFO, |
| text: '2 extensions reloaded successfully', |
| }), |
| ); |
| expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ |
| type: 'RESTARTED', |
| payload: { name: 'ext1' }, |
| }); |
| expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ |
| type: 'RESTARTED', |
| payload: { name: 'ext2' }, |
| }); |
| expect(mockReloadSkills).toHaveBeenCalled(); |
| expect(mockReloadAgents).toHaveBeenCalled(); |
| }); |
|
|
| it('handles errors during skill or agent reload', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
| mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills')); |
|
|
| await restartAction!(mockContext, '--all'); |
|
|
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); |
| expect(mockReloadSkills).toHaveBeenCalled(); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.ERROR, |
| text: 'Failed to reload skills or agents: Failed to reload skills', |
| }), |
| ); |
| }); |
|
|
| it('reloads only specified active extensions', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: false }, |
| { name: 'ext2', isActive: true }, |
| { name: 'ext3', isActive: true }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
|
|
| await restartAction!(mockContext, 'ext1 ext3'); |
|
|
| expect(mockRestartExtension).toHaveBeenCalledTimes(1); |
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[2]); |
| expect(mockContext.ui.dispatchExtensionStateUpdate).toHaveBeenCalledWith({ |
| type: 'RESTARTED', |
| payload: { name: 'ext3' }, |
| }); |
| }); |
|
|
| it('shows an error if no extension loader is available', async () => { |
| mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); |
|
|
| await restartAction!(mockContext, '--all'); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.ERROR, |
| text: "Extensions are not yet loaded, can't restart yet", |
| }), |
| ); |
| expect(mockRestartExtension).not.toHaveBeenCalled(); |
| }); |
|
|
| it('shows usage error for no arguments', async () => { |
| await restartAction!(mockContext, ''); |
|
|
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.ERROR, |
| text: 'Usage: /extensions reload <extension-names>|--all', |
| }), |
| ); |
| expect(mockRestartExtension).not.toHaveBeenCalled(); |
| }); |
|
|
| it('handles errors during extension reload', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
| mockRestartExtension.mockRejectedValue(new Error('Failed to restart')); |
|
|
| await restartAction!(mockContext, '--all'); |
|
|
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.ERROR, |
| text: 'Failed to reload some extensions:\n ext1: Failed to restart', |
| }), |
| ); |
| }); |
|
|
| it('shows a warning if an extension is not found', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
|
|
| await restartAction!(mockContext, 'ext1 ext2'); |
|
|
| expect(mockRestartExtension).toHaveBeenCalledTimes(1); |
| expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.WARNING, |
| text: 'Extension(s) not found or not active: ext2', |
| }), |
| ); |
| }); |
|
|
| it('does not reload any extensions if none are found', async () => { |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
|
|
| await restartAction!(mockContext, 'ext2 ext3'); |
|
|
| expect(mockRestartExtension).not.toHaveBeenCalled(); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| type: MessageType.WARNING, |
| text: 'Extension(s) not found or not active: ext2, ext3', |
| }), |
| ); |
| }); |
|
|
| it('should suggest only enabled extension names for the reload command', async () => { |
| mockContext.invocation!.name = 'reload'; |
| const mockExtensions = [ |
| { name: 'ext1', isActive: true }, |
| { name: 'ext2', isActive: false }, |
| ] as GeminiCLIExtension[]; |
| mockGetExtensions.mockReturnValue(mockExtensions); |
|
|
| const suggestions = completeExtensions(mockContext, 'ext'); |
| expect(suggestions).toEqual(['ext1']); |
| }); |
| }); |
|
|
| describe('config', () => { |
| let configAction: SlashCommand['action']; |
|
|
| beforeEach(async () => { |
| configAction = extensionsCommand(true).subCommands?.find( |
| (cmd) => cmd.name === 'config', |
| )?.action; |
|
|
| expect(configAction).not.toBeNull(); |
| mockContext.invocation!.name = 'config'; |
|
|
| const prompts = (await import('prompts')).default; |
| vi.mocked(prompts).mockResolvedValue({ overwrite: true }); |
|
|
| const { getScopedEnvContents } = await import( |
| '../../config/extensions/extensionSettings.js' |
| ); |
| vi.mocked(getScopedEnvContents).mockResolvedValue({}); |
| }); |
|
|
| it('should return dialog to configure all extensions if no args provided', async () => { |
| const result = await configAction!(mockContext, ''); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
| const dialogResult = result; |
| const component = |
| dialogResult.component as ReactElement<ConfigExtensionDialogProps>; |
| expect(component.type).toBe(ConfigExtensionDialog); |
| expect(component.props.configureAll).toBe(true); |
| expect(component.props.extensionManager).toBeDefined(); |
| }); |
|
|
| it('should return dialog to configure specific extension', async () => { |
| const result = await configAction!(mockContext, 'ext-one'); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
| const dialogResult = result; |
| const component = |
| dialogResult.component as ReactElement<ConfigExtensionDialogProps>; |
| expect(component.type).toBe(ConfigExtensionDialog); |
| expect(component.props.extensionName).toBe('ext-one'); |
| expect(component.props.settingKey).toBeUndefined(); |
| expect(component.props.configureAll).toBe(false); |
| }); |
|
|
| it('should return dialog to configure specific setting for an extension', async () => { |
| const result = await configAction!(mockContext, 'ext-one SETTING1'); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
| const dialogResult = result; |
| const component = |
| dialogResult.component as ReactElement<ConfigExtensionDialogProps>; |
| expect(component.type).toBe(ConfigExtensionDialog); |
| expect(component.props.extensionName).toBe('ext-one'); |
| expect(component.props.settingKey).toBe('SETTING1'); |
| expect(component.props.scope).toBe('user'); |
| }); |
|
|
| it('should respect scope argument passed to dialog', async () => { |
| const result = await configAction!( |
| mockContext, |
| 'ext-one SETTING1 --scope=workspace', |
| ); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
| const dialogResult = result; |
| const component = |
| dialogResult.component as ReactElement<ConfigExtensionDialogProps>; |
| expect(component.props.scope).toBe('workspace'); |
| }); |
|
|
| it('should show error for invalid extension name', async () => { |
| await configAction!(mockContext, '../invalid'); |
| expect(mockContext.ui.addItem).toHaveBeenCalledWith({ |
| type: MessageType.ERROR, |
| text: 'Invalid extension name. Names cannot contain path separators or "..".', |
| }); |
| }); |
|
|
| |
| |
| |
| it('should return dialog even if extension has no settings (dialog handles logic)', async () => { |
| const result = await configAction!(mockContext, 'ext-one'); |
| if (result?.type !== 'custom_dialog') { |
| throw new Error('Expected custom_dialog'); |
| } |
| const dialogResult = result; |
| const component = |
| dialogResult.component as ReactElement<ConfigExtensionDialogProps>; |
| expect(component.type).toBe(ConfigExtensionDialog); |
| }); |
| }); |
| }); |
|
|