test(plugin): cover federation host lifecycle (#628)

This commit is contained in:
InfinityPacer
2026-08-03 12:45:33 +08:00
committed by GitHub
parent d27b402b74
commit b2c2cacc4c
7 changed files with 622 additions and 42 deletions

View File

@@ -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

View File

@@ -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<typeof import('vue-router')>()),
useRoute: () => mocks.route,
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<ReturnType<typeof capabilityPage>>()
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<ReturnType<typeof remotePage>>()
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<ReturnType<typeof remotePage>>()
const beta = deferred<ReturnType<typeof remotePage>>()
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<ReturnType<typeof remotePage>>()
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()
})
})

View File

@@ -12,6 +12,7 @@ const navKey = computed(() => (route.params.navKey as string) || 'main')
const RemoteView = shallowRef<Component | null>(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
}
},

View File

@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
function configureRuntimeRegistry() {
const registered = new Set<string>()
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<RemoteModule[]>()
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<RemoteModule[]>()
const betaDiscovery = deferred<RemoteModule[]>()
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<string> }
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'])
})
})

View File

@@ -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<string, Promise<boolean>>()
// 定义远程模块接口
export interface RemoteModule {
id: string
@@ -31,6 +29,30 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
}
}
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
async function discoverAndRegisterRemote(id: string): Promise<boolean> {
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 -> Settingsmy-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<RemoteModule[]> {
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<RemoteModule[]> {
*/
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',

View File

@@ -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<string>
}
/** 注册模块联邦远程入口。 */
export function setFederationRemote(id: string, config: FederationRemoteConfig): void {
__federation_method_setRemote(id, config)
}
/** 从已注册远程入口加载指定 expose。 */
export async function getFederationRemote(id: string, componentName: string): Promise<unknown> {
return __federation_method_getRemote(id, componentName)
}
/** 解包默认导出;具体类型由各 expose 的宿主契约决定。 */
export function unwrapFederationDefault(module: unknown) {
return __federation_method_unwrapDefault(module)
}

View File

@@ -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,