mirror of
https://github.com/apache/superset.git
synced 2026-05-11 10:55:43 +00:00
Co-authored-by: Ville Brofeldt <ville.brofeldt@apple.com> Co-authored-by: Ville Brofeldt <ville@Villes-MacBook-Pro-2024.local> Co-authored-by: Ville Brofeldt <v_brofeldt@apple.com>
569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|