| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| import fetchMock from 'fetch-mock'; |
| import type { contributions, core } from '@apache-superset/core'; |
| import ExtensionsManager from './ExtensionsManager'; |
| |
| // Type-safe mock data generators |
| interface MockExtensionOptions { |
| id?: string; |
| name?: string; |
| description?: string; |
| version?: string; |
| dependencies?: string[]; |
| remoteEntry?: string; |
| exposedModules?: string[]; |
| extensionDependencies?: string[]; |
| commands?: contributions.CommandContribution[]; |
| menus?: Record<string, contributions.MenuContribution>; |
| views?: Record<string, contributions.ViewContribution[]>; |
| includeMockFunctions?: boolean; |
| } |
| |
| /** |
| * Creates a mock extension with proper typing and default values |
| */ |
| function createMockExtension( |
| options: MockExtensionOptions = {}, |
| ): core.Extension { |
| const { |
| id = 'test-extension', |
| name = 'Test Extension', |
| description = 'A test extension', |
| version = '1.0.0', |
| dependencies = [], |
| remoteEntry = '', |
| exposedModules = [], |
| extensionDependencies = [], |
| commands = [], |
| menus = {}, |
| views = {}, |
| includeMockFunctions = true, |
| } = options; |
| |
| const extension: core.Extension = { |
| id, |
| name, |
| description, |
| version, |
| dependencies, |
| remoteEntry, |
| exposedModules, |
| extensionDependencies, |
| contributions: { |
| commands, |
| menus, |
| views, |
| }, |
| activate: includeMockFunctions ? jest.fn() : undefined!, |
| deactivate: includeMockFunctions ? jest.fn() : undefined!, |
| }; |
| |
| return extension; |
| } |
| |
| /** |
| * Creates a mock command contribution with proper typing |
| */ |
| function createMockCommand( |
| command: string, |
| overrides: Partial<contributions.CommandContribution> = {}, |
| ): contributions.CommandContribution { |
| return { |
| command, |
| icon: `${command}-icon`, |
| title: `${command} Command`, |
| description: `A ${command} command`, |
| ...overrides, |
| }; |
| } |
| |
| /** |
| * Creates a mock menu contribution with proper typing |
| */ |
| function createMockMenu( |
| overrides: Partial<contributions.MenuContribution> = {}, |
| ): contributions.MenuContribution { |
| return { |
| context: [], |
| primary: [], |
| secondary: [], |
| ...overrides, |
| }; |
| } |
| |
| /** |
| * Creates a mock view contribution with proper typing |
| */ |
| function createMockView( |
| id: string, |
| overrides: Partial<contributions.ViewContribution> = {}, |
| ): contributions.ViewContribution { |
| return { |
| id, |
| name: `${id} View`, |
| ...overrides, |
| }; |
| } |
| |
| /** |
| * Creates a mock menu item with proper typing |
| */ |
| function createMockMenuItem( |
| view: string, |
| command: string, |
| overrides: Partial<contributions.MenuItem> = {}, |
| ): contributions.MenuItem { |
| return { |
| view, |
| command, |
| ...overrides, |
| }; |
| } |
| |
| /** |
| * Sets up an activated extension in the manager by manually adding context and contributions |
| * This simulates what happens when an extension is properly enabled |
| */ |
| function setupActivatedExtension( |
| manager: ExtensionsManager, |
| extension: core.Extension, |
| contextOverrides: Partial<{ disposables: { dispose: () => void }[] }> = {}, |
| ) { |
| const context = { disposables: [], ...contextOverrides }; |
| (manager as any).contextIndex.set(extension.id, context); |
| (manager as any).extensionContributions.set(extension.id, { |
| commands: extension.contributions.commands, |
| menus: extension.contributions.menus, |
| views: extension.contributions.views, |
| }); |
| } |
| |
| /** |
| * Creates a fully initialized and activated extension for testing |
| */ |
| async function createActivatedExtension( |
| manager: ExtensionsManager, |
| extensionOptions: MockExtensionOptions = {}, |
| contextOverrides: Partial<{ disposables: { dispose: () => void }[] }> = {}, |
| ): Promise<core.Extension> { |
| const mockExtension = createMockExtension({ |
| ...extensionOptions, |
| }); |
| |
| await manager.initializeExtension(mockExtension); |
| setupActivatedExtension(manager, mockExtension, contextOverrides); |
| |
| return mockExtension; |
| } |
| |
| /** |
| * Creates multiple activated extensions for testing |
| */ |
| async function createMultipleActivatedExtensions( |
| manager: ExtensionsManager, |
| extensionConfigs: MockExtensionOptions[], |
| ): Promise<core.Extension[]> { |
| const extensionPromises = extensionConfigs.map(config => |
| createActivatedExtension(manager, config), |
| ); |
| |
| return Promise.all(extensionPromises); |
| } |
| |
| /** |
| * Common assertions for deactivation success |
| */ |
| function expectSuccessfulDeactivation( |
| result: boolean, |
| mockExtension?: core.Extension, |
| expectedDeactivateCalls = 1, |
| ) { |
| expect(result).toBe(true); |
| if (mockExtension && mockExtension.deactivate) { |
| expect(mockExtension.deactivate).toHaveBeenCalledTimes( |
| expectedDeactivateCalls, |
| ); |
| } |
| } |
| |
| /** |
| * Common assertions for deactivation failure |
| */ |
| function expectFailedDeactivation(result: boolean) { |
| expect(result).toBe(false); |
| } |
| |
| beforeEach(() => { |
| // Clear any existing instance |
| (ExtensionsManager as any).instance = undefined; |
| |
| // Setup fetch mocks for API calls |
| fetchMock.restore(); |
| fetchMock.put('glob:*/api/v1/extensions/*', { ok: true }); |
| fetchMock.delete('glob:*/api/v1/extensions/*', { ok: true }); |
| fetchMock.get('glob:*/api/v1/extensions/', { |
| json: { result: [] }, |
| }); |
| fetchMock.get('glob:*/api/v1/extensions/*', { |
| json: { |
| result: createMockExtension({ includeMockFunctions: false }), |
| }, |
| }); |
| }); |
| |
| afterEach(() => { |
| // Clean up after each test |
| (ExtensionsManager as any).instance = undefined; |
| fetchMock.restore(); |
| }); |
| |
| test('creates singleton instance', () => { |
| const manager1 = ExtensionsManager.getInstance(); |
| const manager2 = ExtensionsManager.getInstance(); |
| |
| expect(manager1).toBe(manager2); |
| expect(manager1).toBeInstanceOf(ExtensionsManager); |
| }); |
| |
| test('singleton maintains state across multiple getInstance calls', async () => { |
| const manager1 = ExtensionsManager.getInstance(); |
| const mockExtension = createMockExtension(); |
| |
| await manager1.initializeExtension(mockExtension); |
| |
| const manager2 = ExtensionsManager.getInstance(); |
| const extensions = manager2.getExtensions(); |
| |
| expect(extensions).toHaveLength(1); |
| expect(extensions[0]).toEqual(mockExtension); |
| }); |
| |
| test('returns empty array for getExtensions initially', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const extensions = manager.getExtensions(); |
| |
| expect(Array.isArray(extensions)).toBe(true); |
| expect(extensions).toHaveLength(0); |
| }); |
| test('returns undefined for non-existent extension', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const extension = manager.getExtension('non-existent-extension'); |
| |
| expect(extension).toBeUndefined(); |
| }); |
| |
| test('can store and retrieve extensions using initializeExtension', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockExtension = createMockExtension(); |
| |
| await manager.initializeExtension(mockExtension); |
| |
| const extensions = manager.getExtensions(); |
| expect(extensions).toHaveLength(1); |
| expect(extensions[0]).toEqual(mockExtension); |
| |
| const retrievedExtension = manager.getExtension('test-extension'); |
| expect(retrievedExtension).toEqual(mockExtension); |
| }); |
| |
| test('handles multiple extensions', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| const extension1 = createMockExtension({ |
| id: 'extension-1', |
| name: 'Extension 1', |
| }); |
| |
| const extension2 = createMockExtension({ |
| id: 'extension-2', |
| name: 'Extension 2', |
| }); |
| |
| await manager.initializeExtension(extension1); |
| await manager.initializeExtension(extension2); |
| |
| const extensions = manager.getExtensions(); |
| expect(extensions).toHaveLength(2); |
| |
| expect(manager.getExtension('extension-1')).toEqual(extension1); |
| expect(manager.getExtension('extension-2')).toEqual(extension2); |
| |
| expect(manager.getExtension('extension-1')?.name).toBe('Extension 1'); |
| expect(manager.getExtension('extension-2')?.name).toBe('Extension 2'); |
| }); |
| |
| test('initializeExtension properly stores extension in manager', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| const mockExtension = createMockExtension({ |
| id: 'test-extension-init', |
| name: 'Test Extension', |
| description: 'A test extension for initialization', |
| }); |
| |
| expect(manager.getExtension('test-extension-init')).toBeUndefined(); |
| expect(manager.getExtensions()).toHaveLength(0); |
| |
| await manager.initializeExtension(mockExtension); |
| |
| expect(manager.getExtension('test-extension-init')).toBeDefined(); |
| expect(manager.getExtensions()).toHaveLength(1); |
| expect(manager.getExtension('test-extension-init')?.name).toBe( |
| 'Test Extension', |
| ); |
| expect(manager.getExtension('test-extension-init')?.description).toBe( |
| 'A test extension for initialization', |
| ); |
| }); |
| |
| test('initializeExtension handles extension without remoteEntry', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| const mockExtension = createMockExtension({ |
| id: 'simple-extension', |
| name: 'Simple Extension', |
| description: 'Extension without remote entry', |
| remoteEntry: '', |
| commands: [createMockCommand('simple.command')], |
| }); |
| |
| expect(manager.getExtension('simple-extension')).toBeUndefined(); |
| |
| await manager.initializeExtension(mockExtension); |
| |
| expect(manager.getExtension('simple-extension')).toBeDefined(); |
| expect(manager.getExtensions()).toHaveLength(1); |
| expect(manager.getExtension('simple-extension')?.name).toBe( |
| 'Simple Extension', |
| ); |
| |
| // Since extension has no remoteEntry, activate should not be called |
| expect(mockExtension.activate).not.toHaveBeenCalled(); |
| }); |
| |
| test('getMenuContributions returns undefined initially', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const menuContributions = manager.getMenuContributions('nonexistent'); |
| |
| expect(menuContributions).toBeUndefined(); |
| }); |
| |
| test('getViewContributions returns undefined initially', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const viewContributions = manager.getViewContributions('nonexistent'); |
| |
| expect(viewContributions).toBeUndefined(); |
| }); |
| |
| test('getCommandContributions returns empty array initially', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const commandContributions = manager.getCommandContributions(); |
| |
| expect(Array.isArray(commandContributions)).toBe(true); |
| expect(commandContributions).toHaveLength(0); |
| }); |
| |
| test('getCommandContribution returns undefined for non-existent command', () => { |
| const manager = ExtensionsManager.getInstance(); |
| const command = manager.getCommandContribution('nonexistent.command'); |
| |
| expect(command).toBeUndefined(); |
| }); |
| |
| test('deactivateExtension successfully deactivates an enabled extension', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockExtension = await createActivatedExtension(manager, { |
| commands: [createMockCommand('test.command')], |
| }); |
| |
| // Verify extension has contributions after setup |
| expect(manager.getCommandContributions()).toHaveLength(1); |
| |
| // Deactivate the extension |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectSuccessfulDeactivation(result, mockExtension); |
| }); |
| |
| test('deactivateExtension disposes of context disposables', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockDisposable = { dispose: jest.fn() }; |
| |
| await createActivatedExtension( |
| manager, |
| {}, |
| { |
| disposables: [mockDisposable], |
| }, |
| ); |
| |
| // Verify disposable is not yet disposed |
| expect(mockDisposable.dispose).not.toHaveBeenCalled(); |
| |
| // Deactivate the extension |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectSuccessfulDeactivation(result); |
| expect(mockDisposable.dispose).toHaveBeenCalledTimes(1); |
| }); |
| |
| test('deactivateExtension handles extension without deactivate function', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| await createActivatedExtension(manager, { |
| includeMockFunctions: false, // Don't create mock functions |
| }); |
| |
| // Deactivate should still return true even without deactivate function |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectSuccessfulDeactivation(result); |
| }); |
| |
| test('deactivateExtension returns false for non-existent extension', () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| const result = manager.deactivateExtension('non-existent-extension'); |
| |
| expectFailedDeactivation(result); |
| }); |
| |
| test('deactivateExtension returns false for extension without context', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockExtension = createMockExtension({ |
| // Extension without context created |
| }); |
| |
| await manager.initializeExtension(mockExtension); |
| |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectFailedDeactivation(result); |
| }); |
| |
| test('deactivateExtension handles errors during deactivation gracefully', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockExtension = await createActivatedExtension(manager); |
| |
| // Override the deactivate function to throw an error |
| mockExtension.deactivate = jest.fn(() => { |
| throw new Error('Deactivation error'); |
| }); |
| |
| // Should return false when deactivation throws an error |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectFailedDeactivation(result); |
| expect(mockExtension.deactivate).toHaveBeenCalledTimes(1); |
| }); |
| |
| test('deactivateExtension handles errors during dispose gracefully', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| const mockDisposable = { |
| dispose: jest.fn(() => { |
| throw new Error('Dispose error'); |
| }), |
| }; |
| |
| await createActivatedExtension( |
| manager, |
| {}, |
| { |
| disposables: [mockDisposable], |
| }, |
| ); |
| |
| // Should return false when disposal throws an error |
| const result = manager.deactivateExtension('test-extension'); |
| |
| expectFailedDeactivation(result); |
| expect(mockDisposable.dispose).toHaveBeenCalledTimes(1); |
| }); |
| |
| test('handles contributions with menu items', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| await createActivatedExtension(manager, { |
| commands: [ |
| createMockCommand('ext1.command1'), |
| createMockCommand('ext1.command2'), |
| ], |
| menus: { |
| testMenu: createMockMenu({ |
| primary: [ |
| createMockMenuItem('test-view', 'ext1.command1'), |
| createMockMenuItem('test-view2', 'ext1.command2'), |
| ], |
| secondary: [createMockMenuItem('test-view3', 'ext1.command1')], |
| }), |
| }, |
| views: { |
| testView: [createMockView('test-view-1'), createMockView('test-view-2')], |
| }, |
| }); |
| |
| // Test command contributions |
| const commands = manager.getCommandContributions(); |
| expect(commands).toHaveLength(2); |
| expect(commands.find(cmd => cmd.command === 'ext1.command1')).toBeDefined(); |
| expect(commands.find(cmd => cmd.command === 'ext1.command2')).toBeDefined(); |
| |
| // Test menu contributions |
| const menuContributions = manager.getMenuContributions('testMenu'); |
| expect(menuContributions).toBeDefined(); |
| expect(menuContributions?.primary).toHaveLength(2); |
| expect(menuContributions?.secondary).toHaveLength(1); |
| |
| // Test view contributions |
| const viewContributions = manager.getViewContributions('testView'); |
| expect(viewContributions).toBeDefined(); |
| expect(viewContributions).toHaveLength(2); |
| }); |
| |
| test('handles non-existent menu and view contributions', () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| expect(manager.getMenuContributions('nonexistent')).toBeUndefined(); |
| expect(manager.getViewContributions('nonexistent')).toBeUndefined(); |
| expect(manager.getCommandContribution('nonexistent.command')).toBeUndefined(); |
| }); |
| |
| test('merges contributions from multiple extensions', async () => { |
| const manager = ExtensionsManager.getInstance(); |
| |
| await createMultipleActivatedExtensions(manager, [ |
| { |
| id: 'extension-1', |
| name: 'Extension 1', |
| commands: [createMockCommand('ext1.command')], |
| }, |
| { |
| id: 'extension-2', |
| name: 'Extension 2', |
| commands: [createMockCommand('ext2.command')], |
| }, |
| ]); |
| |
| const commands = manager.getCommandContributions(); |
| expect(commands).toHaveLength(2); |
| |
| expect(manager.getCommandContribution('ext1.command')).toBeDefined(); |
| expect(manager.getCommandContribution('ext2.command')).toBeDefined(); |
| }); |