diff --git a/explore/src/components/ExploreManager/ExploreManager.test.tsx b/explore/src/components/ExploreManager/ExploreManager.test.tsx new file mode 100644 index 00000000..0f9baa0c --- /dev/null +++ b/explore/src/components/ExploreManager/ExploreManager.test.tsx @@ -0,0 +1,62 @@ +// Copyright The Perses Authors +// Licensed 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 { render, waitFor } from '@testing-library/react'; + +import { ExploreManager } from './ExploreManager'; +import { ExplorerManagerProvider } from './ExplorerManagerProvider'; + +const pluginLoaderComponent = vi.fn<(props: unknown) => null>(() => null); +const listPluginMetadata = vi.fn(); + +vi.mock('@perses-dev/plugin-system', () => ({ + PluginLoaderComponent: (props: unknown): null => pluginLoaderComponent(props), + useListPluginMetadata: (): unknown => listPluginMetadata(), +})); + +vi.mock('../ExploreToolbar', () => ({ + ExploreToolbar: (): null => null, +})); + +describe('ExploreManager', () => { + it('should load the explorer plugin with its module version and registry', async () => { + listPluginMetadata.mockReturnValue({ + data: [ + { + kind: 'Explore', + spec: { name: 'TempoExplorer', display: { name: 'Tempo' } }, + module: { name: 'Tempo', version: '0.59.0', registry: 'perses' }, + }, + ], + }); + + render( + + + , + ); + + await waitFor(() => { + expect(pluginLoaderComponent).toHaveBeenCalledWith( + expect.objectContaining({ + plugin: { + name: 'TempoExplorer', + moduleName: 'Tempo', + version: '0.59.0', + registry: 'perses', + }, + }), + ); + }); + }); +}); diff --git a/explore/src/components/ExploreManager/ExploreManager.tsx b/explore/src/components/ExploreManager/ExploreManager.tsx index 98cecb55..b724fcc7 100644 --- a/explore/src/components/ExploreManager/ExploreManager.tsx +++ b/explore/src/components/ExploreManager/ExploreManager.tsx @@ -118,6 +118,8 @@ export function ExploreManager(props: ExploreManagerProps): ReactElement { plugin={{ name: currentPlugin.spec.name, moduleName: currentPlugin.module.name, + version: currentPlugin.module.version, + registry: currentPlugin.module.registry, }} /> )} diff --git a/plugin-system/src/remote/PluginRuntime.test.ts b/plugin-system/src/remote/PluginRuntime.test.ts new file mode 100644 index 00000000..c940ac06 --- /dev/null +++ b/plugin-system/src/remote/PluginRuntime.test.ts @@ -0,0 +1,63 @@ +// Copyright The Perses Authors +// Licensed 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 { loadPlugin } from './PluginRuntime'; +import { remotePluginLoader } from './remotePluginLoader'; + +const registerRemotes = vi.fn(); +const loadRemote = vi.fn().mockResolvedValue({}); + +vi.mock('@module-federation/enhanced/runtime', () => ({ + createInstance: vi.fn(() => ({ options: { remotes: [] }, registerRemotes, loadRemote })), +})); + +describe('loadPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should use the plugin baseURL when provided', async () => { + remotePluginLoader({ baseURL: '/perses', apiPrefix: '/perses' }); + + await loadPlugin({ + moduleName: 'Tempo', + pluginName: 'TempoExplorer', + version: '0.59.0', + baseURL: 'https://cdn.example.com/plugins', + }); + + expect(registerRemotes).toHaveBeenCalledWith([ + expect.objectContaining({ entry: 'https://cdn.example.com/plugins/Tempo~0.59.0/mf-manifest.json' }), + ]); + }); + + it('should fall back to /plugins when the loader has no base URL', async () => { + remotePluginLoader(); + + await loadPlugin({ moduleName: 'Tempo', pluginName: 'TempoExplorer', version: '0.59.0' }); + + expect(registerRemotes).toHaveBeenCalledWith([ + expect.objectContaining({ entry: '/plugins/Tempo~0.59.0/mf-manifest.json' }), + ]); + }); + + it('should fall back to the base URL configured on the loader when the plugin has none', async () => { + remotePluginLoader({ baseURL: '/perses', apiPrefix: '/perses' }); + + await loadPlugin({ moduleName: 'Tempo', pluginName: 'TempoExplorer', version: '0.59.0' }); + + expect(registerRemotes).toHaveBeenCalledWith([ + expect.objectContaining({ entry: '/perses/plugins/Tempo~0.59.0/mf-manifest.json' }), + ]); + }); +}); diff --git a/plugin-system/src/remote/PluginRuntime.tsx b/plugin-system/src/remote/PluginRuntime.tsx index f3b9e2cc..9592d12e 100644 --- a/plugin-system/src/remote/PluginRuntime.tsx +++ b/plugin-system/src/remote/PluginRuntime.tsx @@ -25,6 +25,16 @@ import type { PersesPlugin, RemotePluginModule } from './PersesPlugin.types'; let instance: ModuleFederation | null = null; +let pluginsAssetsBaseURL = '/plugins'; + +/** + * Sets the base URL used to resolve plugin assets for plugins that don't carry their own `baseURL`. The plugin loader + * registers the path it was configured with, so plugins keep resolving when Perses is served behind a sub-path. + */ +export function setPluginsAssetsBaseURL(baseURL: string): void { + pluginsAssetsBaseURL = baseURL; +} + function createSharedModuleLoader(loadModule: () => Promise): () => Promise<() => TModule> { return async () => { const module = await loadModule(); @@ -263,7 +273,7 @@ const registerRemote = (name: string, registry?: string, version?: string, baseU const existingRemote = pluginRuntime.options.remotes.find((remote) => remote.name === registryName); if (!existingRemote) { const nameVersionRegistry = [name, version, registry].filter(Boolean).join('~'); - const prefix = baseURL || '/plugins'; + const prefix = baseURL || pluginsAssetsBaseURL; const remoteEntryURL = `${prefix}/${nameVersionRegistry}/mf-manifest.json`; pluginRuntime.registerRemotes([ diff --git a/plugin-system/src/remote/remotePluginLoader.test.ts b/plugin-system/src/remote/remotePluginLoader.test.ts index fff5cbb6..9e86da70 100644 --- a/plugin-system/src/remote/remotePluginLoader.test.ts +++ b/plugin-system/src/remote/remotePluginLoader.test.ts @@ -27,6 +27,7 @@ import { remotePluginLoader } from './remotePluginLoader'; // Mock the loadPlugin function vi.mock('./PluginRuntime', () => ({ loadPlugin: vi.fn(), + setPluginsAssetsBaseURL: vi.fn(), })); const mockLoadPlugin = vi.mocked(loadPlugin); diff --git a/plugin-system/src/remote/remotePluginLoader.ts b/plugin-system/src/remote/remotePluginLoader.ts index a3500fc1..92693327 100644 --- a/plugin-system/src/remote/remotePluginLoader.ts +++ b/plugin-system/src/remote/remotePluginLoader.ts @@ -17,7 +17,7 @@ import type { PluginLoader, PluginMetadata, PluginModuleResource, PluginType } f import { getPluginModuleCompoundKey } from '@perses-dev/plugin-system'; import type { RemotePluginModule } from './PersesPlugin.types'; -import { loadPlugin } from './PluginRuntime'; +import { loadPlugin, setPluginsAssetsBaseURL } from './PluginRuntime'; const isPluginMetadata = (plugin: unknown): plugin is PluginMetadata => { return ( @@ -92,6 +92,8 @@ export function remotePluginLoader(options?: RemotePluginLoaderOptions): PluginL const { pluginsApiPath, pluginsAssetsPath } = paramToOptions(options); const fetchFn = options?.fetchFn ?? defaultFetch; + setPluginsAssetsBaseURL(pluginsAssetsPath); + return { getInstalledPlugins: async (): Promise => { const pluginsResponse = await fetchFn(pluginsApiPath);