mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
test(plugin): cover federation host lifecycle (#628)
This commit is contained in:
@@ -864,23 +864,6 @@
|
|||||||
"count": 2
|
"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": {
|
"src/utils/globalSetting.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
@@ -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 RemoteView = shallowRef<Component | null>(null)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
|
let loadGeneration = 0
|
||||||
|
|
||||||
// 侧栏联邦页面复用主应用 Toast 实例。
|
// 侧栏联邦页面复用主应用 Toast 实例。
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -24,16 +25,19 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
|||||||
watch(
|
watch(
|
||||||
[pluginId, navKey],
|
[pluginId, navKey],
|
||||||
async ([pid, nk]) => {
|
async ([pid, nk]) => {
|
||||||
|
const generation = ++loadGeneration
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
|
RemoteView.value = null
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
RemoteView.value = null
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
|
if (generation !== loadGeneration) return
|
||||||
console.error(e)
|
console.error(e)
|
||||||
RemoteView.value = null
|
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 api from '@/api'
|
||||||
|
import { getFederationRemote, setFederationRemote, unwrapFederationDefault } from '@/utils/federationRuntime'
|
||||||
import { resolveFederationRemoteUrl } from '@/utils/federationUrl'
|
import { resolveFederationRemoteUrl } from '@/utils/federationUrl'
|
||||||
import {
|
|
||||||
__federation_method_setRemote,
|
|
||||||
__federation_method_getRemote,
|
|
||||||
__federation_method_unwrapDefault,
|
|
||||||
// @ts-ignore
|
|
||||||
} from 'virtual:__federation__'
|
|
||||||
|
|
||||||
// 创建一个专用的AbortController,用于federationLoader请求
|
// 创建一个专用的AbortController,用于federationLoader请求
|
||||||
const federationController = new AbortController()
|
const federationController = new AbortController()
|
||||||
|
|
||||||
|
// 同一 remote 的首次加载共享发现与注册,避免并发写入同一个运行时槽位。
|
||||||
|
const remoteRegistrationFlights = new Map<string, Promise<boolean>>()
|
||||||
|
|
||||||
// 定义远程模块接口
|
// 定义远程模块接口
|
||||||
export interface RemoteModule {
|
export interface RemoteModule {
|
||||||
id: string
|
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)
|
* 将 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') {
|
export async function loadRemoteComponent(id: string, componentName: string = 'Page') {
|
||||||
try {
|
try {
|
||||||
const module = await __federation_method_getRemote(id, `./${componentName}`)
|
const module = await getFederationRemote(id, `./${componentName}`)
|
||||||
return __federation_method_unwrapDefault(module)
|
return unwrapFederationDefault(module)
|
||||||
} catch (error) {
|
} catch {
|
||||||
// 组件未注册,尝试重新注册
|
// 组件未注册,尝试重新注册
|
||||||
try {
|
try {
|
||||||
const moduleInfo = await fetchSingleRemoteModule(id)
|
if (await discoverAndRegisterRemote(id)) {
|
||||||
if (moduleInfo) {
|
|
||||||
console.log(`组件未注册,正在重新注册: ${id}`)
|
|
||||||
injectRemoteModule(moduleInfo)
|
|
||||||
|
|
||||||
// 重新尝试加载组件
|
// 重新尝试加载组件
|
||||||
const module = await __federation_method_getRemote(id, `./${componentName}`)
|
const module = await getFederationRemote(id, `./${componentName}`)
|
||||||
return __federation_method_unwrapDefault(module)
|
return unwrapFederationDefault(module)
|
||||||
} else {
|
} else {
|
||||||
console.error(`无法找到远程模块信息: ${id}`)
|
console.error(`无法找到远程模块信息: ${id}`)
|
||||||
throw new 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') {
|
export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, componentName: string = 'Page') {
|
||||||
injectRemoteModule(remoteModule)
|
injectRemoteModule(remoteModule)
|
||||||
const module = await __federation_method_getRemote(remoteModule.id, `./${componentName}`)
|
const module = await getFederationRemote(remoteModule.id, `./${componentName}`)
|
||||||
return __federation_method_unwrapDefault(module)
|
return unwrapFederationDefault(module)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,10 +148,10 @@ export async function loadRemoteComponentFromModule(remoteModule: RemoteModule,
|
|||||||
*/
|
*/
|
||||||
async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('plugin/remotes?token=moviepilot', {
|
const response = (await api.get('plugin/remotes?token=moviepilot', {
|
||||||
signal: federationController.signal,
|
signal: federationController.signal,
|
||||||
})
|
})) as unknown as RemoteModule[] | null
|
||||||
return (response as any) || []
|
return response ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取远程模块列表失败:', error)
|
console.error('获取远程模块列表失败:', error)
|
||||||
return []
|
return []
|
||||||
@@ -146,7 +164,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
|||||||
*/
|
*/
|
||||||
export function injectRemoteModule(module: RemoteModule): void {
|
export function injectRemoteModule(module: RemoteModule): void {
|
||||||
const remoteEntryUrl = resolveFederationRemoteUrl(module.url, import.meta.env.VITE_API_BASE_URL, document.baseURI)
|
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),
|
url: () => Promise.resolve(remoteEntryUrl),
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
from: 'vite',
|
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)
|
||||||
|
}
|
||||||
@@ -25,10 +25,32 @@ const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
|||||||
const buildTime = new Date().getTime().toString()
|
const buildTime = new Date().getTime().toString()
|
||||||
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ command, mode, isPreview }) => ({
|
export default defineConfig(({ command, mode, isPreview }) => ({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
isTestMode(mode) && federationRuntimeTestModule(),
|
||||||
shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) &&
|
shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) &&
|
||||||
createDevServiceWorkerCleanupPlugin(),
|
createDevServiceWorkerCleanupPlugin(),
|
||||||
vue(),
|
vue(),
|
||||||
@@ -286,6 +308,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/utils/permission.ts',
|
'src/utils/permission.ts',
|
||||||
'src/utils/requestOptimizer.ts',
|
'src/utils/requestOptimizer.ts',
|
||||||
'src/utils/sseManager.ts',
|
'src/utils/sseManager.ts',
|
||||||
|
'src/utils/federationLoader.ts',
|
||||||
|
'src/utils/federationRuntime.ts',
|
||||||
'src/stores/auth.ts',
|
'src/stores/auth.ts',
|
||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
'src/pages/discover.vue',
|
'src/pages/discover.vue',
|
||||||
@@ -294,6 +318,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'src/pages/resource.vue',
|
'src/pages/resource.vue',
|
||||||
'src/pages/site.vue',
|
'src/pages/site.vue',
|
||||||
'src/pages/subscribe.vue',
|
'src/pages/subscribe.vue',
|
||||||
|
'src/pages/plugin-app.vue',
|
||||||
'src/views/dashboard/MediaRecommend.vue',
|
'src/views/dashboard/MediaRecommend.vue',
|
||||||
'src/views/discover/MediaCardSlideView.vue',
|
'src/views/discover/MediaCardSlideView.vue',
|
||||||
'src/views/subscribe/FullCalendarView.vue',
|
'src/views/subscribe/FullCalendarView.vue',
|
||||||
@@ -566,6 +591,24 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
lines: 90,
|
lines: 90,
|
||||||
statements: 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': {
|
'src/utils/searchStream.ts': {
|
||||||
branches: 85,
|
branches: 85,
|
||||||
functions: 90,
|
functions: 90,
|
||||||
|
|||||||
Reference in New Issue
Block a user