Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5260,7 +5260,8 @@ plugins:
closePluginPanel: Close plugin description panel
viewVersionDetails: View extension {name} version {version} details/Readme
labels:
primeOnly: Prime-only
isDeveloper: Developer Load
primeOnly: Prime only
builtin: Built-In
experimental: Experimental
third-party: Third-Party
Expand Down
236 changes: 236 additions & 0 deletions shell/core/__test__/extension-manager-impl.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { DEVELOPER_LOAD_NAME_SUFFIX } from '@shell/core/extension-manager-impl';

// Mock external dependencies
jest.mock('@shell/store/type-map', () => ({ productsLoaded: jest.fn().mockReturnValue(true) }));

jest.mock('@shell/plugins/dashboard-store/model-loader', () => ({ clearModelCache: jest.fn() }));

jest.mock('@shell/config/uiplugins', () => ({ UI_PLUGIN_BASE_URL: '/api/v1/uiplugins' }));

jest.mock('@shell/plugins/clean-html', () => ({
addLinkInterceptor: jest.fn(),
removeLinkInterceptor: jest.fn(),
}));

// Mock the Plugin class
jest.mock('@shell/core/plugin', () => {
return {
Plugin: jest.fn().mockImplementation((id) => ({
id,
name: id,
types: {},
uiConfig: {},
l10n: {},
modelExtensions: {},
stores: [],
locales: [],
routes: [],
validators: {},
uninstallHooks: [],
productNames: [],
})),
EXT_IDS: {
MODELS: 'models',
MODEL_EXTENSION: 'model-extension'
},
ExtensionPoint: { EDIT_YAML: 'edit-yaml' }
};
});

// Mock PluginRoutes
jest.mock('@shell/core/plugin-routes', () => {
return { PluginRoutes: jest.fn().mockImplementation(() => ({ addRoutes: jest.fn() })) };
});

describe('extension Manager', () => {
let mockStore;
let mockApp;
let context;

// These variables will be assigned the fresh functions inside beforeEach
let initExtensionManager;
let getExtensionManager;

beforeEach(() => {
// singleton instance for every test run, preventing mock store leaks.
jest.resetModules();

// Re-require the System Under Test (SUT)
const extensionManagerModule = require('../extension-manager-impl');

initExtensionManager = extensionManagerModule.initExtensionManager;
getExtensionManager = extensionManagerModule.getExtensionManager;

jest.clearAllMocks();

// Setup Mock Context
mockStore = {
getters: { 'i18n/t': jest.fn() },
dispatch: jest.fn(),
commit: jest.fn(),
};

mockApp = { router: {} };

context = {
app: mockApp,
store: mockStore,
$axios: {},
redirect: jest.fn(),
};

// Clean up DOM from previous tests
document.head.innerHTML = '';
});

describe('singleton Pattern', () => {
it('initializes and returns the same instance', () => {
const instance1 = initExtensionManager(context);
const instance2 = getExtensionManager();
const instance3 = initExtensionManager(context);

expect(instance1).toBeDefined();
expect(instance1).toBe(instance2);
expect(instance1).toBe(instance3);
});
});

describe('registration (Dynamic)', () => {
it('registers and retrieves a dynamic component', () => {
const manager = initExtensionManager(context);
const mockFn = jest.fn();

manager.register('component', 'my-component', mockFn);

const retrieved = manager.getDynamic('component', 'my-component');

expect(retrieved).toBe(mockFn);
});

it('unregisters a dynamic component', () => {
const manager = initExtensionManager(context);
const mockFn = jest.fn();

manager.register('component', 'my-component', mockFn);
manager.unregister('component', 'my-component');

const retrieved = manager.getDynamic('component', 'my-component');

expect(retrieved).toBeUndefined();
});
});

describe('loadPluginAsync (URL Generation)', () => {
let manager;

beforeEach(() => {
manager = initExtensionManager(context);
// Mock the internal loadAsync so we only test URL generation here
jest.spyOn(manager, 'loadAsync').mockImplementation().mockResolvedValue();
});

it('generates correct URL for standard plugin', async() => {
const pluginData = { name: 'elemental', version: '1.0.0' };
const expectedId = 'elemental-1.0.0';
const expectedUrl = `/api/v1/uiplugins/elemental/1.0.0/plugin/elemental-1.0.0.umd.min.js`;

await manager.loadPluginAsync(pluginData);

expect(manager.loadAsync).toHaveBeenCalledWith(expectedId, expectedUrl);
});

it('handles "direct" metadata plugins', async() => {
const pluginData = {
name: 'direct-plugin',
version: '1.0.0',
endpoint: 'http://localhost:8000/plugin.js',
metadata: { direct: 'true' }
};

await manager.loadPluginAsync(pluginData);

expect(manager.loadAsync).toHaveBeenCalledWith('direct-plugin-1.0.0', 'http://localhost:8000/plugin.js');
});

it('removes developer suffix from ID but keeps it for internal logic', async() => {
const pluginData = {
name: `my-plugin${ DEVELOPER_LOAD_NAME_SUFFIX }`,
version: `1.0.0`
};

await manager.loadPluginAsync(pluginData);

// Expected ID passed to loadAsync should NOT have the suffix
const expectedIdWithoutSuffix = 'my-plugin-1.0.0';

expect(manager.loadAsync).toHaveBeenCalledWith(
expectedIdWithoutSuffix,
expect.any(String)
);
});
});

describe('loadAsync (Script Injection)', () => {
let manager;

beforeEach(() => {
manager = initExtensionManager(context);
});

it('resolves immediately if element already exists', async() => {
const id = 'existing-plugin';
const script = document.createElement('script');

script.id = id;
document.body.appendChild(script);

await expect(manager.loadAsync(id, 'url.js')).resolves.toBeUndefined();

document.body.removeChild(script);
});

it('injects script tag and initializes plugin on load', async() => {
const pluginId = 'test-plugin';
const pluginUrl = 'http://test.com/plugin.js';

// Mock the window object to simulate the plugin loading into global scope
const mockPluginInit = jest.fn();

window[pluginId] = { default: mockPluginInit };

// Start the load
const loadPromise = manager.loadAsync(pluginId, pluginUrl);

// Find the injected script tag in the DOM
const script = document.head.querySelector(`script[id="${ pluginId }"]`);

expect(script).toBeTruthy();
expect(script.src).toBe(pluginUrl);

// Manually trigger the onload event
script.onload();

// Await the promise
await loadPromise;

// Assertions
expect(mockPluginInit).toHaveBeenCalledWith(expect.objectContaining({ id: pluginId }), expect.objectContaining({ ...context }));
expect(mockStore.dispatch).toHaveBeenCalledWith('uiplugins/addPlugin', expect.objectContaining({ id: pluginId }));

// Cleanup
delete window[pluginId];
});

it('rejects if script load fails', async() => {
const pluginId = 'fail-plugin';
const loadPromise = manager.loadAsync(pluginId, 'bad-url.js');

const script = document.head.querySelector(`script[id="${ pluginId }"]`);

// Trigger error
script.onerror({ target: { src: 'bad-url.js' } });

await expect(loadPromise).rejects.toThrow('Failed to load script');
});
});
});
13 changes: 12 additions & 1 deletion shell/core/extension-manager-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { UI_PLUGIN_BASE_URL } from '@shell/config/uiplugins';
import { ExtensionPoint } from './types';
import { addLinkInterceptor, removeLinkInterceptor } from '@shell/plugins/clean-html';

export const DEVELOPER_LOAD_NAME_SUFFIX = '-developer-load';

let extensionManagerInstance;

const createExtensionManager = (context) => {
Expand Down Expand Up @@ -63,9 +65,18 @@ const createExtensionManager = (context) => {
// Load a plugin from a UI package
loadPluginAsync(plugin) {
const { name, version } = plugin;
const id = `${ name }-${ version }`;
let id = `${ name }-${ version }`;
let url;

// for a developer load, we need to remove the suffix applied
// otherwise the extension won't load correctly
// but with this at least we won't hit developer loaded cards find charts
// when they aren't supposed to
if (id.includes(DEVELOPER_LOAD_NAME_SUFFIX)) {
id = id.replace(DEVELOPER_LOAD_NAME_SUFFIX, '');
}

// this is where a developer load hits (direct=true, developer=true)
if (plugin?.metadata?.direct === 'true') {
url = plugin.endpoint;
} else {
Expand Down
15 changes: 12 additions & 3 deletions shell/dialog/DeveloperLoadExtensionDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LabeledInput } from '@components/Form/LabeledInput';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
import { UI_PLUGIN } from '@shell/config/types';
import { UI_PLUGIN_CHART_ANNOTATIONS, UI_PLUGIN_NAMESPACE } from '@shell/config/uiplugins';
import { DEVELOPER_LOAD_NAME_SUFFIX } from '@shell/core/extension-manager-impl';

export default {
emits: ['close'],
Expand Down Expand Up @@ -101,8 +102,16 @@ export default {
const parts = name.split('-');

if (parts.length >= 2) {
version = parts.pop();
crdName = parts.join('-');
// fixing the name-version separation, especially in RC versions
// like: elemental-3.0.1-rc.1
// on capturing version it must be "digit + dot + digit" + rest of string
const regex = /^(?<name>.+?)-(?<version>\d+\.\d+.*)$/;
const match = name.match(regex);

if (match && match.groups) {
version = match.groups.version;
crdName = match.groups.name;
}
}

if (this.persist) {
Expand All @@ -114,7 +123,7 @@ export default {
},
spec: {
plugin: {
name: crdName,
name: `${ crdName }${ DEVELOPER_LOAD_NAME_SUFFIX }`,
version,
endpoint: url,
noCache: true,
Expand Down
7 changes: 7 additions & 0 deletions shell/pages/c/_cluster/uiplugins/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ describe('page: UI plugins/Extensions', () => {
});

describe('getFooterItems', () => {
it('should return "developer" label for isDeveloper plugins', () => {
const plugin = { isDeveloper: true };
const items = wrapper.vm.getFooterItems(plugin);

expect(items[0].labels).toContain('plugins.labels.isDeveloper');
});

it('should return "builtin" label for builtin plugins', () => {
const plugin = { builtin: true };
const items = wrapper.vm.getFooterItems(plugin);
Expand Down
8 changes: 7 additions & 1 deletion shell/pages/c/_cluster/uiplugins/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export default {
versions: [],
displayVersion: p.version,
displayVersionLabel: p.version || '-',
isDeveloper: p.isDeveloper,
installed: true,
installing: false,
builtin: false,
Expand Down Expand Up @@ -812,6 +813,11 @@ export default {
getFooterItems(plugin) {
const labels = [];

// "developer load" tag
if (plugin.isDeveloper) {
labels.push(this.t('plugins.labels.isDeveloper'));
}

if (plugin.primeOnly) {
labels.push(this.t('plugins.labels.primeOnly'));
}
Expand All @@ -820,7 +826,7 @@ export default {
labels.push(this.t('plugins.labels.builtin'));
}

if (!plugin.builtin && !plugin.certified) {
if (!plugin.builtin && !plugin.certified && !plugin.isDeveloper) {
labels.push(this.t('plugins.labels.third-party'));
}

Expand Down