From b2c2cacc4c79c387453c159a4d920f237c41cb76 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:45:33 +0800 Subject: [PATCH] test(plugin): cover federation host lifecycle (#628) --- eslint-suppressions.json | 17 -- src/pages/__tests__/plugin-app.spec.ts | 213 ++++++++++++++ src/pages/plugin-app.vue | 10 +- src/utils/__tests__/federationLoader.spec.ts | 291 +++++++++++++++++++ src/utils/federationLoader.ts | 62 ++-- src/utils/federationRuntime.ts | 28 ++ vite.config.ts | 43 +++ 7 files changed, 622 insertions(+), 42 deletions(-) create mode 100644 src/pages/__tests__/plugin-app.spec.ts create mode 100644 src/utils/__tests__/federationLoader.spec.ts create mode 100644 src/utils/federationRuntime.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 41205765..1bef2924 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -864,23 +864,6 @@ "count": 2 } }, - "src/utils/federationLoader.ts": { - "@typescript-eslint/ban-ts-comment": { - "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "preserve-caught-error": { - "count": 1 - }, - "sonarjs/no-ignored-exceptions": { - "count": 1 - } - }, "src/utils/globalSetting.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/pages/__tests__/plugin-app.spec.ts b/src/pages/__tests__/plugin-app.spec.ts new file mode 100644 index 00000000..ebb7dee2 --- /dev/null +++ b/src/pages/__tests__/plugin-app.spec.ts @@ -0,0 +1,213 @@ +import PluginAppPage from '@/pages/plugin-app.vue' +import { renderWithProviders } from '@tests/support/render' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { defineComponent, h, inject, nextTick, reactive } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + api: { get: vi.fn(), post: vi.fn() }, + loadRemoteAppPageComponent: vi.fn(), + nativeSubscribe: vi.fn(), + route: undefined as unknown as { params: { navKey?: string; pluginId?: string } }, + toast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@/api', () => ({ + default: mocks.api, +})) + +vi.mock('@/utils/federationLoader', () => ({ + loadRemoteAppPageComponent: (...args: unknown[]) => mocks.loadRemoteAppPageComponent(...args), +})) + +vi.mock('@/composables/usePluginNativeSubscribe', () => ({ + usePluginNativeSubscribe: () => mocks.nativeSubscribe, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => mocks.toast, +})) + +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRoute: () => mocks.route, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +function remotePage(label: string) { + return defineComponent({ + name: `${label}RemotePage`, + props: { + api: Object, + nativeSubscribe: Function, + navKey: String, + pluginId: String, + }, + setup: () => () => h('div', label), + }) +} + +function capabilityPage() { + return defineComponent({ + name: 'CapabilityRemotePage', + props: { + api: Object, + nativeSubscribe: Function, + navKey: String, + pluginId: String, + }, + emits: ['action'], + setup(props, { emit }) { + const injectedToast = inject('moviepilot:toast') + const injectedNativeSubscribe = inject('moviepilot:nativeSubscribe') + return () => + h( + 'button', + { onClick: () => emit('action') }, + [ + props.pluginId, + props.navKey, + props.api === mocks.api, + props.nativeSubscribe === mocks.nativeSubscribe, + injectedToast === mocks.toast, + injectedNativeSubscribe === mocks.nativeSubscribe, + ].join(':'), + ) + }, + }) +} + +describe('plugin-app page', () => { + beforeEach(() => { + mocks.loadRemoteAppPageComponent.mockReset() + mocks.api.get.mockReset() + mocks.api.post.mockReset() + mocks.nativeSubscribe.mockReset() + mocks.route = reactive({ params: { navKey: 'main', pluginId: 'alpha' } }) + mocks.toast.error.mockReset() + mocks.toast.success.mockReset() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('shows loading, forwards host props and provides the same host capabilities', async () => { + const pageLoad = deferred>() + mocks.route.params.navKey = 'settings' + mocks.loadRemoteAppPageComponent.mockReturnValue(pageLoad.promise) + + const { container } = await renderWithProviders(PluginAppPage) + expect(container.querySelector('.v-skeleton-loader')).toBeInTheDocument() + + pageLoad.resolve(capabilityPage()) + const remoteAction = await screen.findByRole('button', { + name: 'alpha:settings:true:true:true:true', + }) + await fireEvent.click(remoteAction) + + expect(remoteAction).toBeInTheDocument() + expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('alpha', 'settings') + }) + + it('uses main when the optional nav key is absent', async () => { + mocks.route.params.navKey = undefined + mocks.loadRemoteAppPageComponent.mockResolvedValue(remotePage('main page')) + + await renderWithProviders(PluginAppPage) + + expect(await screen.findByText('main page')).toBeInTheDocument() + expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('alpha', 'main') + }) + + it('does not start a remote load without a plugin id', async () => { + mocks.route.params.pluginId = undefined + + const { container } = await renderWithProviders(PluginAppPage) + await nextTick() + + expect(mocks.loadRemoteAppPageComponent).not.toHaveBeenCalled() + expect(container.querySelector('.v-skeleton-loader')).toBeInTheDocument() + }) + + it('clears the previous page while a new route is loading', async () => { + const beta = deferred>() + mocks.loadRemoteAppPageComponent.mockImplementation((pluginId: string) => { + return pluginId === 'alpha' ? Promise.resolve(remotePage('alpha page')) : beta.promise + }) + + const { container } = await renderWithProviders(PluginAppPage) + expect(await screen.findByText('alpha page')).toBeInTheDocument() + + mocks.route.params.pluginId = 'beta' + await waitFor(() => expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('beta', 'main')) + + expect(screen.queryByText('alpha page')).not.toBeInTheDocument() + expect(container.querySelector('.v-skeleton-loader')).toBeInTheDocument() + + beta.resolve(remotePage('beta page')) + expect(await screen.findByText('beta page')).toBeInTheDocument() + }) + + it('keeps the latest route component when an earlier load resolves late', async () => { + const alpha = deferred>() + const beta = deferred>() + mocks.loadRemoteAppPageComponent.mockImplementation((pluginId: string) => { + return pluginId === 'alpha' ? alpha.promise : beta.promise + }) + + await renderWithProviders(PluginAppPage) + await waitFor(() => expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('alpha', 'main')) + + mocks.route.params.pluginId = 'beta' + await waitFor(() => expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('beta', 'main')) + + beta.resolve(remotePage('beta page')) + expect(await screen.findByText('beta page')).toBeInTheDocument() + + alpha.resolve(remotePage('alpha page')) + await alpha.promise + await nextTick() + + expect(screen.queryByText('alpha page')).not.toBeInTheDocument() + expect(screen.getByText('beta page')).toBeInTheDocument() + }) + + it('ignores an earlier route error after the latest page has loaded', async () => { + const alpha = deferred>() + mocks.loadRemoteAppPageComponent.mockImplementation((pluginId: string) => { + return pluginId === 'alpha' ? alpha.promise : Promise.resolve(remotePage('beta page')) + }) + + await renderWithProviders(PluginAppPage) + await waitFor(() => expect(mocks.loadRemoteAppPageComponent).toHaveBeenCalledWith('alpha', 'main')) + + mocks.route.params.pluginId = 'beta' + expect(await screen.findByText('beta page')).toBeInTheDocument() + + alpha.reject(new Error('alpha failed')) + await alpha.promise.catch(() => undefined) + await nextTick() + + expect(screen.queryByText('组件加载错误')).not.toBeInTheDocument() + expect(screen.getByText('beta page')).toBeInTheDocument() + expect(console.error).not.toHaveBeenCalled() + }) + + it('shows an observable error state when the current remote load fails', async () => { + mocks.loadRemoteAppPageComponent.mockRejectedValue(new Error('current route failed')) + + const { container } = await renderWithProviders(PluginAppPage) + + expect(await screen.findByText('组件加载错误')).toBeInTheDocument() + expect(screen.getByText(/无法加载插件全页组件/)).toBeInTheDocument() + expect(container.querySelector('.v-skeleton-loader')).not.toBeInTheDocument() + expect(console.error).toHaveBeenCalledOnce() + }) +}) diff --git a/src/pages/plugin-app.vue b/src/pages/plugin-app.vue index f3e297b0..fbdb9d51 100644 --- a/src/pages/plugin-app.vue +++ b/src/pages/plugin-app.vue @@ -12,6 +12,7 @@ const navKey = computed(() => (route.params.navKey as string) || 'main') const RemoteView = shallowRef(null) const loadError = ref(false) +let loadGeneration = 0 // 侧栏联邦页面复用主应用 Toast 实例。 const $toast = useToast() @@ -24,16 +25,19 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe) watch( [pluginId, navKey], async ([pid, nk]) => { + const generation = ++loadGeneration loadError.value = false + RemoteView.value = null if (!pid) { - RemoteView.value = null return } try { - RemoteView.value = (await loadRemoteAppPageComponent(pid, nk)) as Component + const remoteView = (await loadRemoteAppPageComponent(pid, nk)) as Component + if (generation !== loadGeneration) return + RemoteView.value = remoteView } catch (e) { + if (generation !== loadGeneration) return console.error(e) - RemoteView.value = null loadError.value = true } }, diff --git a/src/utils/__tests__/federationLoader.spec.ts b/src/utils/__tests__/federationLoader.spec.ts new file mode 100644 index 00000000..a36a0ff9 --- /dev/null +++ b/src/utils/__tests__/federationLoader.spec.ts @@ -0,0 +1,291 @@ +import { + injectRemoteModule, + loadRemoteAppPageComponent, + loadRemoteComponent, + loadRemoteComponentFromModule, + loadRemoteComponents, + type RemoteModule, +} from '@/utils/federationLoader' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), + getRemote: vi.fn(), + setRemote: vi.fn(), + unwrapDefault: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: { + get: (...args: unknown[]) => mocks.apiGet(...args), + }, +})) + +vi.mock('virtual:__federation__', () => ({ + __federation_method_getRemote: (...args: unknown[]) => mocks.getRemote(...args), + __federation_method_setRemote: (...args: unknown[]) => mocks.setRemote(...args), + __federation_method_unwrapDefault: (...args: unknown[]) => mocks.unwrapDefault(...args), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +function configureRuntimeRegistry() { + const registered = new Set() + mocks.getRemote.mockImplementation(async (id: string) => { + if (!registered.has(id)) throw new Error(`${id} is not registered`) + return { default: `${id} page` } + }) + mocks.setRemote.mockImplementation((id: string) => { + registered.add(id) + }) +} + +describe('federationLoader', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + mocks.getRemote.mockReset() + mocks.setRemote.mockReset() + mocks.unwrapDefault.mockReset() + mocks.unwrapDefault.mockImplementation(module => (module as { default: unknown }).default) + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('loads an already registered remote and unwraps its default export without discovery', async () => { + const remoteModule = { default: { name: 'RemotePage' } } + mocks.getRemote.mockResolvedValue(remoteModule) + + await expect(loadRemoteComponent('demo', 'Config')).resolves.toBe(remoteModule.default) + + expect(mocks.getRemote).toHaveBeenCalledWith('demo', './Config') + expect(mocks.unwrapDefault).toHaveBeenCalledWith(remoteModule) + expect(mocks.apiGet).not.toHaveBeenCalled() + expect(mocks.setRemote).not.toHaveBeenCalled() + }) + + it('single-flights discovery and registration for concurrent loads of the same unknown remote', async () => { + const remoteComponent = { name: 'RemotePage' } + let registered = false + + mocks.apiGet.mockResolvedValue([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async () => { + if (!registered) throw new Error('remote is not registered') + return { default: remoteComponent } + }) + mocks.setRemote.mockImplementation(() => { + registered = true + }) + + await expect(Promise.all([loadRemoteComponent('demo'), loadRemoteComponent('demo')])).resolves.toEqual([ + remoteComponent, + remoteComponent, + ]) + + expect(mocks.apiGet).toHaveBeenCalledTimes(1) + expect(mocks.setRemote).toHaveBeenCalledTimes(1) + }) + + it('starts a new discovery after a successful registration flight has settled', async () => { + let registered = false + mocks.apiGet.mockResolvedValue([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async () => { + if (!registered) throw new Error('remote is not registered') + return { default: 'demo page' } + }) + mocks.setRemote.mockImplementation(() => { + registered = true + }) + + await expect(loadRemoteComponent('demo')).resolves.toBe('demo page') + registered = false + await expect(loadRemoteComponent('demo')).resolves.toBe('demo page') + + expect(mocks.apiGet).toHaveBeenCalledTimes(2) + expect(mocks.setRemote).toHaveBeenCalledTimes(2) + }) + + it('shares a failed discovery flight and allows a later call to recover', async () => { + const failedDiscovery = deferred() + let registered = false + mocks.apiGet + .mockImplementationOnce(() => failedDiscovery.promise) + .mockResolvedValueOnce([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async () => { + if (!registered) throw new Error('remote is not registered') + return { default: 'demo page' } + }) + mocks.setRemote.mockImplementation(() => { + registered = true + }) + + const firstLoad = loadRemoteComponent('demo') + const secondLoad = loadRemoteComponent('demo') + await vi.waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(1)) + failedDiscovery.reject(new Error('discovery failed')) + + const failedLoads = await Promise.allSettled([firstLoad, secondLoad]) + expect(failedLoads.map(result => result.status)).toEqual(['rejected', 'rejected']) + expect(mocks.apiGet).toHaveBeenCalledTimes(1) + + await expect(loadRemoteComponent('demo')).resolves.toBe('demo page') + expect(mocks.apiGet).toHaveBeenCalledTimes(2) + expect(mocks.setRemote).toHaveBeenCalledTimes(1) + }) + + it('lets different unknown remotes complete independently', async () => { + const alphaDiscovery = deferred() + const betaDiscovery = deferred() + configureRuntimeRegistry() + mocks.apiGet + .mockImplementationOnce(() => alphaDiscovery.promise) + .mockImplementationOnce(() => betaDiscovery.promise) + + let alphaSettled = false + const alphaLoad = loadRemoteComponent('alpha').then(value => { + alphaSettled = true + return value + }) + const betaLoad = loadRemoteComponent('beta') + + await vi.waitFor(() => expect(mocks.apiGet).toHaveBeenCalledTimes(2)) + betaDiscovery.resolve([{ id: 'beta', url: '/plugins/beta/remoteEntry.js' }]) + + await expect(betaLoad).resolves.toBe('beta page') + expect(alphaSettled).toBe(false) + + alphaDiscovery.resolve([{ id: 'alpha', url: '/plugins/alpha/remoteEntry.js' }]) + await expect(alphaLoad).resolves.toBe('alpha page') + expect(mocks.setRemote).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['an empty discovery response', () => Promise.resolve([])], + ['a failed discovery request', () => Promise.reject(new Error('discovery failed'))], + ])('rejects when an unknown remote cannot be found after %s', async (_name, discover) => { + mocks.getRemote.mockRejectedValue(new Error('remote is not registered')) + mocks.apiGet.mockImplementation(discover) + + await expect(loadRemoteComponent('missing')).rejects.toThrow('无法找到远程模块信息: missing') + expect(mocks.setRemote).not.toHaveBeenCalled() + }) + + it('loads a component from explicit remote metadata after registering it', async () => { + const component = { name: 'ExplicitPage' } + mocks.getRemote.mockResolvedValue({ default: component }) + + await expect( + loadRemoteComponentFromModule({ id: 'explicit', url: 'https://cdn.example/remoteEntry.js' }, 'Dashboard'), + ).resolves.toBe(component) + + expect(mocks.setRemote).toHaveBeenCalledOnce() + expect(mocks.getRemote).toHaveBeenCalledWith('explicit', './Dashboard') + }) + + it('registers remote metadata with the resolved ESM runtime contract', async () => { + injectRemoteModule({ id: 'explicit', url: 'https://cdn.example/remoteEntry.js' }) + + expect(mocks.setRemote).toHaveBeenCalledWith( + 'explicit', + expect.objectContaining({ format: 'esm', from: 'vite', url: expect.any(Function) }), + ) + const config = mocks.setRemote.mock.calls[0]?.[1] as { url: () => Promise } + await expect(config.url()).resolves.toBe('https://cdn.example/remoteEntry.js') + }) + + it('initializes every discovered remote and tolerates empty or failed discovery', async () => { + mocks.apiGet.mockResolvedValueOnce([ + { id: 'alpha', url: '/plugins/alpha/remoteEntry.js' }, + { id: 'beta', url: '/plugins/beta/remoteEntry.js' }, + ]) + await loadRemoteComponents() + expect(mocks.setRemote).toHaveBeenCalledTimes(2) + + mocks.setRemote.mockClear() + mocks.apiGet.mockResolvedValueOnce([]) + await expect(loadRemoteComponents()).resolves.toBeUndefined() + expect(mocks.setRemote).not.toHaveBeenCalled() + + mocks.apiGet.mockResolvedValueOnce(null) + await expect(loadRemoteComponents()).resolves.toBeUndefined() + expect(mocks.setRemote).not.toHaveBeenCalled() + + mocks.apiGet.mockRejectedValueOnce(new Error('discovery failed')) + await expect(loadRemoteComponents()).resolves.toBeUndefined() + expect(mocks.setRemote).not.toHaveBeenCalled() + }) + + it('contains a registration failure during remote initialization', async () => { + mocks.apiGet.mockResolvedValue([{ id: 'broken', url: '/plugins/broken/remoteEntry.js' }]) + mocks.setRemote.mockImplementation(() => { + throw new Error('registration failed') + }) + + await expect(loadRemoteComponents()).resolves.toBeUndefined() + expect(console.error).toHaveBeenCalledWith('加载远程组件失败:', expect.any(Error)) + }) + + it('uses AppPage then Page for the main navigation entry', async () => { + const appPageError = new Error('AppPage failed') + mocks.apiGet.mockResolvedValue([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async (_id: string, componentName: string) => { + if (componentName === './AppPage') throw appPageError + return { default: 'legacy page' } + }) + + await expect(loadRemoteAppPageComponent('demo')).resolves.toBe('legacy page') + expect(mocks.getRemote.mock.calls.map(call => call[1])).toEqual(['./AppPage', './AppPage', './Page']) + }) + + it('tries the PascalCase nav expose before the generic fallbacks', async () => { + mocks.getRemote.mockResolvedValue({ default: 'tool page' }) + + await expect(loadRemoteAppPageComponent('demo', ' my_tool-name ')).resolves.toBe('tool page') + expect(mocks.getRemote).toHaveBeenCalledWith('demo', './AppPageMyToolName') + expect(mocks.apiGet).not.toHaveBeenCalled() + }) + + it.each([ + ['blank', ' '], + ['separator-only', '_- '], + ])('uses the generic AppPage candidate for a %s nav key', async (_name, navKey) => { + mocks.getRemote.mockResolvedValue({ default: 'generic app page' }) + + await expect(loadRemoteAppPageComponent('demo', navKey)).resolves.toBe('generic app page') + expect(mocks.getRemote).toHaveBeenCalledWith('demo', './AppPage') + }) + + it('preserves candidate fallback when a specialized expose rejects', async () => { + mocks.apiGet.mockResolvedValue([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async (_id: string, componentName: string) => { + if (componentName === './AppPage') return { default: 'generic app page' } + throw new Error('specialized expose failed') + }) + + await expect(loadRemoteAppPageComponent('demo', 'settings')).resolves.toBe('generic app page') + expect(mocks.getRemote.mock.calls.map(call => call[1])).toEqual([ + './AppPageSettings', + './AppPageSettings', + './AppPage', + ]) + }) + + it('rejects with the final candidate error when no AppPage expose can load', async () => { + mocks.apiGet.mockResolvedValue([{ id: 'demo', url: '/plugins/demo/remoteEntry.js' }]) + mocks.getRemote.mockImplementation(async (_id: string, componentName: string) => { + throw new Error(`${componentName} failed`) + }) + + await expect(loadRemoteAppPageComponent('demo', 'main')).rejects.toThrow('./Page failed') + expect(mocks.getRemote.mock.calls.map(call => call[1])).toEqual(['./AppPage', './AppPage', './Page', './Page']) + }) +}) diff --git a/src/utils/federationLoader.ts b/src/utils/federationLoader.ts index 05e03443..876087f4 100644 --- a/src/utils/federationLoader.ts +++ b/src/utils/federationLoader.ts @@ -1,15 +1,13 @@ import api from '@/api' +import { getFederationRemote, setFederationRemote, unwrapFederationDefault } from '@/utils/federationRuntime' import { resolveFederationRemoteUrl } from '@/utils/federationUrl' -import { - __federation_method_setRemote, - __federation_method_getRemote, - __federation_method_unwrapDefault, - // @ts-ignore -} from 'virtual:__federation__' // 创建一个专用的AbortController,用于federationLoader请求 const federationController = new AbortController() +// 同一 remote 的首次加载共享发现与注册,避免并发写入同一个运行时槽位。 +const remoteRegistrationFlights = new Map>() + // 定义远程模块接口 export interface RemoteModule { id: string @@ -31,6 +29,30 @@ async function fetchSingleRemoteModule(id: string): Promise } } +/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */ +async function discoverAndRegisterRemote(id: string): Promise { + const activeFlight = remoteRegistrationFlights.get(id) + if (activeFlight) return activeFlight + + const flight = (async () => { + const moduleInfo = await fetchSingleRemoteModule(id) + if (!moduleInfo) return false + + console.log(`组件未注册,正在重新注册: ${id}`) + injectRemoteModule(moduleInfo) + return true + })() + + remoteRegistrationFlights.set(id, flight) + try { + return await flight + } finally { + if (remoteRegistrationFlights.get(id) === flight) { + remoteRegistrationFlights.delete(id) + } + } +} + /** * 将 nav_key 转为联邦暴露名的 Pascal 片段(如 settings -> Settings,my-tool -> MyTool) */ @@ -90,19 +112,15 @@ export async function loadRemoteAppPageComponent(id: string, navKey: string = 'm */ export async function loadRemoteComponent(id: string, componentName: string = 'Page') { try { - const module = await __federation_method_getRemote(id, `./${componentName}`) - return __federation_method_unwrapDefault(module) - } catch (error) { + const module = await getFederationRemote(id, `./${componentName}`) + return unwrapFederationDefault(module) + } catch { // 组件未注册,尝试重新注册 try { - const moduleInfo = await fetchSingleRemoteModule(id) - if (moduleInfo) { - console.log(`组件未注册,正在重新注册: ${id}`) - injectRemoteModule(moduleInfo) - + if (await discoverAndRegisterRemote(id)) { // 重新尝试加载组件 - const module = await __federation_method_getRemote(id, `./${componentName}`) - return __federation_method_unwrapDefault(module) + const module = await getFederationRemote(id, `./${componentName}`) + return unwrapFederationDefault(module) } else { console.error(`无法找到远程模块信息: ${id}`) throw new Error(`无法找到远程模块信息: ${id}`) @@ -121,8 +139,8 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P */ export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, componentName: string = 'Page') { injectRemoteModule(remoteModule) - const module = await __federation_method_getRemote(remoteModule.id, `./${componentName}`) - return __federation_method_unwrapDefault(module) + const module = await getFederationRemote(remoteModule.id, `./${componentName}`) + return unwrapFederationDefault(module) } /** @@ -130,10 +148,10 @@ export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, */ async function fetchRemoteModules(): Promise { try { - const response = await api.get('plugin/remotes?token=moviepilot', { + const response = (await api.get('plugin/remotes?token=moviepilot', { signal: federationController.signal, - }) - return (response as any) || [] + })) as unknown as RemoteModule[] | null + return response ?? [] } catch (error) { console.error('获取远程模块列表失败:', error) return [] @@ -146,7 +164,7 @@ async function fetchRemoteModules(): Promise { */ export function injectRemoteModule(module: RemoteModule): void { const remoteEntryUrl = resolveFederationRemoteUrl(module.url, import.meta.env.VITE_API_BASE_URL, document.baseURI) - __federation_method_setRemote(module.id, { + setFederationRemote(module.id, { url: () => Promise.resolve(remoteEntryUrl), format: 'esm', from: 'vite', diff --git a/src/utils/federationRuntime.ts b/src/utils/federationRuntime.ts new file mode 100644 index 00000000..4427f73e --- /dev/null +++ b/src/utils/federationRuntime.ts @@ -0,0 +1,28 @@ +import { + __federation_method_getRemote, + __federation_method_setRemote, + __federation_method_unwrapDefault, + // @ts-expect-error -- virtual 模块由 federation 插件在开发与生产构建时提供。 +} from 'virtual:__federation__' + +/** 模块联邦运行时接受的远程入口配置。 */ +export interface FederationRemoteConfig { + format: 'esm' + from: 'vite' + url: () => Promise +} + +/** 注册模块联邦远程入口。 */ +export function setFederationRemote(id: string, config: FederationRemoteConfig): void { + __federation_method_setRemote(id, config) +} + +/** 从已注册远程入口加载指定 expose。 */ +export async function getFederationRemote(id: string, componentName: string): Promise { + return __federation_method_getRemote(id, componentName) +} + +/** 解包默认导出;具体类型由各 expose 的宿主契约决定。 */ +export function unwrapFederationDefault(module: unknown) { + return __federation_method_unwrapDefault(module) +} diff --git a/vite.config.ts b/vite.config.ts index 9f1abf13..93bc4087 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,10 +25,32 @@ const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')) const buildTime = new Date().getTime().toString() const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true' +/** 仅为单测提供可 mock 的 virtual federation 模块,不启用生产 federation 插件。 */ +function federationRuntimeTestModule() { + const moduleId = 'virtual:__federation__' + const resolvedModuleId = `\0${moduleId}` + + return { + name: 'moviepilot-federation-runtime-test-module', + resolveId(source: string) { + if (source === moduleId) return resolvedModuleId + }, + load(id: string) { + if (id !== resolvedModuleId) return + return ` + export function __federation_method_getRemote() { throw new Error('Federation runtime must be mocked in tests') } + export function __federation_method_setRemote() { throw new Error('Federation runtime must be mocked in tests') } + export function __federation_method_unwrapDefault() { throw new Error('Federation runtime must be mocked in tests') } + ` + }, + } +} + // https://vitejs.dev/config/ export default defineConfig(({ command, mode, isPreview }) => ({ base: './', plugins: [ + isTestMode(mode) && federationRuntimeTestModule(), shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) && createDevServiceWorkerCleanupPlugin(), vue(), @@ -286,6 +308,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/utils/permission.ts', 'src/utils/requestOptimizer.ts', 'src/utils/sseManager.ts', + 'src/utils/federationLoader.ts', + 'src/utils/federationRuntime.ts', 'src/stores/auth.ts', 'src/pages/recommend.vue', 'src/pages/discover.vue', @@ -294,6 +318,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({ 'src/pages/resource.vue', 'src/pages/site.vue', 'src/pages/subscribe.vue', + 'src/pages/plugin-app.vue', 'src/views/dashboard/MediaRecommend.vue', 'src/views/discover/MediaCardSlideView.vue', 'src/views/subscribe/FullCalendarView.vue', @@ -566,6 +591,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({ lines: 90, statements: 90, }, + 'src/utils/federationLoader.ts': { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + 'src/utils/federationRuntime.ts': { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + 'src/pages/plugin-app.vue': { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, 'src/utils/searchStream.ts': { branches: 85, functions: 90,