mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 08:03:45 +08:00
test(plugin): cover federation host lifecycle (#628)
This commit is contained in:
@@ -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
|
||||
|
||||
213
src/pages/__tests__/plugin-app.spec.ts
Normal file
213
src/pages/__tests__/plugin-app.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
291
src/utils/__tests__/federationLoader.spec.ts
Normal file
291
src/utils/__tests__/federationLoader.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
28
src/utils/federationRuntime.ts
Normal file
28
src/utils/federationRuntime.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user