mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
test(plugin): cover federation host lifecycle (#628)
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 -> 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<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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user