diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 1576a7ba73b..872cf120e0d 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -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 diff --git a/shell/core/__test__/extension-manager-impl.test.js b/shell/core/__test__/extension-manager-impl.test.js new file mode 100644 index 00000000000..152a15bdd8e --- /dev/null +++ b/shell/core/__test__/extension-manager-impl.test.js @@ -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'); + }); + }); +}); diff --git a/shell/core/extension-manager-impl.js b/shell/core/extension-manager-impl.js index cb8aba09d79..da270fe8098 100644 --- a/shell/core/extension-manager-impl.js +++ b/shell/core/extension-manager-impl.js @@ -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) => { @@ -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 { diff --git a/shell/dialog/DeveloperLoadExtensionDialog.vue b/shell/dialog/DeveloperLoadExtensionDialog.vue index 638a89e089a..13c4c146f1c 100644 --- a/shell/dialog/DeveloperLoadExtensionDialog.vue +++ b/shell/dialog/DeveloperLoadExtensionDialog.vue @@ -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'], @@ -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 = /^(?.+?)-(?\d+\.\d+.*)$/; + const match = name.match(regex); + + if (match && match.groups) { + version = match.groups.version; + crdName = match.groups.name; + } } if (this.persist) { @@ -114,7 +123,7 @@ export default { }, spec: { plugin: { - name: crdName, + name: `${ crdName }${ DEVELOPER_LOAD_NAME_SUFFIX }`, version, endpoint: url, noCache: true, diff --git a/shell/pages/c/_cluster/uiplugins/__tests__/index.test.ts b/shell/pages/c/_cluster/uiplugins/__tests__/index.test.ts index 219bdd2af6b..09ea714f6de 100644 --- a/shell/pages/c/_cluster/uiplugins/__tests__/index.test.ts +++ b/shell/pages/c/_cluster/uiplugins/__tests__/index.test.ts @@ -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); diff --git a/shell/pages/c/_cluster/uiplugins/index.vue b/shell/pages/c/_cluster/uiplugins/index.vue index 2cfa8580795..954822f984b 100644 --- a/shell/pages/c/_cluster/uiplugins/index.vue +++ b/shell/pages/c/_cluster/uiplugins/index.vue @@ -393,6 +393,7 @@ export default { versions: [], displayVersion: p.version, displayVersionLabel: p.version || '-', + isDeveloper: p.isDeveloper, installed: true, installing: false, builtin: false, @@ -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')); } @@ -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')); }