test(plugin): cover plugin host surfaces (#630)

This commit is contained in:
InfinityPacer
2026-08-03 15:21:36 +08:00
committed by GitHub
parent 1298a5f72c
commit 9a533cd103
10 changed files with 941 additions and 103 deletions

View File

@@ -270,25 +270,6 @@
"count": 1
}
},
"src/components/dialog/PluginConfigDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
},
"vue/valid-v-else": {
"count": 1
}
},
"src/components/dialog/PluginDataDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/components/dialog/PluginMarketDetailDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import type { Plugin, RenderProps } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toastification'
@@ -9,6 +9,9 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import RemoteComponentError from '@/components/misc/RemoteComponentError.vue'
import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue'
// 国际化
const { t } = useI18n()
@@ -27,10 +30,10 @@ const emit = defineEmits(['close', 'save', 'switch'])
const display = useDisplay()
// 插件配置表单数据
const pluginConfigForm = ref({})
const pluginConfigForm = ref<Record<string, unknown>>({})
// 插件表单配置项
let pluginFormItems = reactive([])
const pluginFormItems = ref<RenderProps[]>([])
// 进度框
const progressDialog = ref(false)
@@ -48,11 +51,17 @@ provide('moviepilot:toast', $toast)
const nativeSubscribe = usePluginNativeSubscribe()
provide('moviepilot:nativeSubscribe', nativeSubscribe)
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 是否刷新
const isRefreshed = ref(false)
// 只有成功取得合法表单响应时才允许展示空配置状态。
const loadError = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
type PluginRenderMode = 'vue' | 'vuetify'
const renderMode = ref<PluginRenderMode>('vuetify')
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
const dialogMaxWidth = ref('60rem')
@@ -62,6 +71,10 @@ interface PluginConfigLayout {
maxWidth?: string
}
function isPluginRenderMode(value: unknown): value is PluginRenderMode {
return value === 'vue' || value === 'vuetify'
}
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
@@ -78,22 +91,13 @@ const dynamicComponent = defineAsyncComponent({
return module
} catch (error) {
console.error('加载远程组件失败:', error)
throw error
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
loadingComponent: RemoteComponentLoading,
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
errorComponent: RemoteComponentError,
// 添加超时设置
timeout: 20000,
})
@@ -102,40 +106,47 @@ const dynamicComponent = defineAsyncComponent({
async function loadPluginUIData() {
// 重置
isRefreshed.value = false
pluginFormItems = []
loadError.value = false
pluginFormItems.value = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
dialogMaxWidth.value = '60rem'
try {
// 获取UI定义
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (!result) {
const result = (await api.get(`plugin/form/${props.plugin?.id}`)) as {
conf?: RenderProps[]
model?: Record<string, unknown>
render_mode?: string
}
if (!result || !isPluginRenderMode(result.render_mode)) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
loadError.value = true
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
// Vue模式下初始配置在同一个API返回
if (!isNullOrEmptyObject(result.model)) {
if (result.model && !isNullOrEmptyObject(result.model)) {
pluginConfigForm.value = result.model
}
} else {
// Vuetify模式
pluginFormItems = result.conf || []
pluginFormItems.value = result.conf || []
if (result.model) {
pluginConfigForm.value = result.model
}
}
} catch (error: any) {
} catch (error) {
console.error(error)
loadError.value = true
} finally {
isRefreshed.value = true
}
}
// 处理 Vue 组件触发的保存事件
function handleVueComponentSave(newConfig: Record<string, any>) {
function handleVueComponentSave(newConfig: Record<string, unknown>) {
pluginConfigForm.value = newConfig
savePluginConf()
}
@@ -152,18 +163,26 @@ async function savePluginConf() {
progressDialog.value = true
progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
const result = (await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)) as {
message?: string
success: boolean
}
if (result.success) {
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
// 导航声明可能由插件配置控制;刷新失败不改变已经成功的配置保存结果。
void pluginSidebarNavStore.ensureSidebarNav(true).catch(error => console.error(error))
} else {
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
const message = error instanceof Error ? error.message : String(error)
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message }))
} finally {
progressDialog.value = false
}
progressDialog.value = false
}
onBeforeMount(async () => {
@@ -177,7 +196,13 @@ onBeforeMount(async () => {
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else="isRefreshed">
<VCardText v-else-if="loadError">
<VAlert type="error" title="配置加载失败">
<div>插件配置加载失败请稍后重试</div>
<VBtn class="mt-3" color="error" variant="tonal" @click="loadPluginUIData">重试</VBtn>
</VAlert>
</VCardText>
<VCardText v-else>
<div>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
@@ -196,7 +221,7 @@ onBeforeMount(async () => {
<VSpacer />
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn
v-if="renderMode === 'vuetify'"
v-if="isRefreshed && !loadError && renderMode === 'vuetify'"
color="primary"
variant="flat"
@click="savePluginConf"

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import type { Plugin, RenderProps } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
import RemoteComponentError from '@/components/misc/RemoteComponentError.vue'
import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue'
// 输入参数
const props = defineProps({
@@ -38,16 +40,23 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
// 是否刷新
const isRefreshed = ref(false)
// 只有成功取得合法页面响应时才允许展示空页面状态。
const loadError = ref(false)
// 组件是否已加载成功
const componentLoaded = ref(false)
// 是否正在加载数据
const isLoading = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
type PluginRenderMode = 'vue' | 'vuetify'
const renderMode = ref<PluginRenderMode>('vuetify')
// 插件数据页面配置项
let pluginPageItems = ref([])
const pluginPageItems = ref<RenderProps[]>([])
function isPluginRenderMode(value: unknown): value is PluginRenderMode {
return value === 'vue' || value === 'vuetify'
}
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
@@ -65,22 +74,13 @@ const dynamicComponent = defineAsyncComponent({
} catch (error) {
console.error('加载远程组件失败:', error)
componentLoaded.value = false
throw error
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
loadingComponent: RemoteComponentLoading,
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
errorComponent: RemoteComponentError,
// 添加超时设置
timeout: 20000,
})
@@ -92,6 +92,7 @@ async function loadPluginUIData() {
isLoading.value = true
isRefreshed.value = false
loadError.value = false
pluginPageItems.value = []
try {
@@ -102,9 +103,13 @@ async function loadPluginUIData() {
return
}
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
if (!result || !result.render_mode) {
const result = (await api.get(`plugin/page/${props.plugin?.id}`)) as {
page?: RenderProps[]
render_mode?: string
}
if (!result || !isPluginRenderMode(result.render_mode)) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
loadError.value = true
return
}
renderMode.value = result.render_mode
@@ -112,8 +117,9 @@ async function loadPluginUIData() {
// Vuetify模式
pluginPageItems.value = result.page || []
}
} catch (error: any) {
} catch (error) {
console.error(error)
loadError.value = true
} finally {
isRefreshed.value = true
isLoading.value = false
@@ -121,16 +127,16 @@ async function loadPluginUIData() {
}
// 重新加载数据(可由 PageRender 或 Vue component 触发)
function handleAction(event: any) {
function handleAction() {
// 避免在组件已加载的情况下重复调用loadPluginUIData
if (renderMode.value === 'vue' && componentLoaded.value) {
return
}
loadPluginUIData()
void loadPluginUIData()
}
onMounted(() => {
loadPluginUIData()
void loadPluginUIData()
})
</script>
<template>
@@ -139,6 +145,12 @@ onMounted(() => {
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else-if="loadError" class="min-h-40">
<VAlert type="error" title="数据加载失败">
<div>插件数据加载失败请稍后重试</div>
<VBtn class="mt-3" color="error" variant="tonal" @click="loadPluginUIData">重试</VBtn>
</VAlert>
</VCardText>
<VCardText v-else class="min-h-40">
<div>
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />

View File

@@ -0,0 +1,292 @@
import type { Plugin } from '@/api/types'
import PluginConfigDialog from '@/components/dialog/PluginConfigDialog.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPut: vi.fn(),
ensureSidebarNav: vi.fn(),
loadRemoteComponent: vi.fn(),
nativeSubscribe: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
put: mocks.apiPut,
},
}))
vi.mock('@/utils/federationLoader', () => ({
loadRemoteComponent: mocks.loadRemoteComponent,
}))
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
}))
vi.mock('@/stores/pluginSidebarNav', () => ({
usePluginSidebarNavStore: () => ({ ensureSidebarNav: mocks.ensureSidebarNav }),
}))
vi.mock('vue-toastification', () => ({
useToast: () => mocks.toast,
}))
const plugin: Plugin = {
has_page: true,
id: 'DemoPlugin',
plugin_name: '演示插件',
}
const DialogStub = defineComponent({
name: 'VDialog',
props: { maxWidth: String },
setup:
(props, { slots }) =>
() =>
h('section', { 'data-max-width': props.maxWidth, role: 'dialog' }, slots.default?.()),
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
setup: () => () => h('div', '正在加载'),
})
const ProgressDialogStub = defineComponent({
name: 'ProgressDialog',
props: { text: String },
setup: props => () => h('div', { 'data-testid': 'save-progress' }, props.text),
})
const CloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
setup:
(_, { attrs }) =>
() =>
h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
})
const FormRenderStub = defineComponent({
name: 'FormRender',
props: {
config: { type: Object as PropType<Record<string, unknown>>, required: true },
model: { type: Object as PropType<Record<string, unknown>>, required: true },
},
setup: props => () => h('div', { 'data-testid': 'form-render' }, `${props.config.component}:${props.model.enabled}`),
})
type RemoteCapture = {
api: unknown
initialConfig: Record<string, unknown>
injectedNativeSubscribe: unknown
injectedToast: unknown
nativeSubscribe: unknown
}
function createDeferred<T>() {
let reject!: (reason?: unknown) => void
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
function createRemoteConfig(captures: RemoteCapture[]): Component {
return defineComponent({
name: 'RemoteConfigFixture',
props: {
api: Object,
initialConfig: { type: Object as PropType<Record<string, unknown>>, required: true },
nativeSubscribe: Function,
},
emits: ['close', 'layout', 'save', 'switch'],
setup(props, { emit }) {
captures.push({
api: props.api,
initialConfig: props.initialConfig,
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
injectedToast: inject('moviepilot:toast'),
nativeSubscribe: props.nativeSubscribe,
})
return () =>
h('section', { 'data-testid': 'remote-config' }, [
h('button', { onClick: () => emit('layout', { maxWidth: '72rem' }), type: 'button' }, '调整布局'),
h('button', { onClick: () => emit('save', { enabled: false }), type: 'button' }, '远程保存'),
h('button', { onClick: () => emit('switch'), type: 'button' }, '切换数据'),
h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程配置'),
])
},
})
}
async function renderDialog() {
return renderWithProviders(PluginConfigDialog, {
props: { modelValue: true, plugin },
global: {
stubs: {
FormRender: FormRenderStub,
LoadingBanner: LoadingBannerStub,
ProgressDialog: ProgressDialogStub,
VDialog: DialogStub,
VDialogCloseBtn: CloseButtonStub,
},
},
})
}
describe('PluginConfigDialog', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPut.mockReset()
mocks.ensureSidebarNav.mockReset().mockResolvedValue(undefined)
mocks.loadRemoteComponent.mockReset()
mocks.nativeSubscribe.mockReset()
mocks.toast.error.mockReset()
mocks.toast.success.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('distinguishes an empty Vuetify form from a failed load and retries in place', async () => {
mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
conf: [],
model: {},
render_mode: 'vuetify',
})
mocks.apiPut.mockResolvedValue({ success: true })
await renderDialog()
expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
expect(screen.queryByText('此插件没有可配置项')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('此插件没有可配置项')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', {}))
})
it('does not expose configuration saving while the form is loading or failed', async () => {
const load = createDeferred<never>()
mocks.apiGet.mockReturnValue(load.promise)
await renderDialog()
expect(screen.getByText('正在加载')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
load.reject(new Error('request failed'))
expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
expect(mocks.apiPut).not.toHaveBeenCalled()
})
it('renders the Vuetify form with the merged model returned by the backend', async () => {
mocks.apiGet.mockResolvedValue({
conf: [{ component: 'VSwitch', props: { label: '启用' } }],
model: { enabled: true },
render_mode: 'vuetify',
})
const result = await renderDialog()
expect(await screen.findByTestId('form-render')).toHaveTextContent('VSwitch:true')
await fireEvent.click(screen.getByRole('button', { name: '查看数据' }))
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(result.emitted().switch).toHaveLength(1)
expect(result.emitted().close).toHaveLength(1)
})
it('passes the same host capabilities through props and provide and forwards remote events', async () => {
const captures: RemoteCapture[] = []
mocks.apiGet.mockResolvedValue({ model: { enabled: true }, render_mode: 'vue' })
mocks.apiPut.mockResolvedValue({ success: true })
mocks.loadRemoteComponent.mockResolvedValue(createRemoteConfig(captures))
const result = await renderDialog()
expect(await screen.findByTestId('remote-config')).toBeInTheDocument()
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Config')
expect(captures).toHaveLength(1)
expect(captures[0]).toMatchObject({ initialConfig: { enabled: true } })
expect(captures[0].api).toMatchObject({ get: mocks.apiGet, put: mocks.apiPut })
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedToast).toBe(mocks.toast)
await fireEvent.click(screen.getByRole('button', { name: '调整布局' }))
expect(screen.getByRole('dialog')).toHaveAttribute('data-max-width', '72rem')
await fireEvent.click(screen.getByRole('button', { name: '远程保存' }))
await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: false }))
await fireEvent.click(screen.getByRole('button', { name: '切换数据' }))
await fireEvent.click(screen.getByRole('button', { name: '关闭远程配置' }))
expect(result.emitted().switch).toHaveLength(1)
expect(result.emitted().close).toHaveLength(1)
})
it('renders the async error component when the remote Config rejects', async () => {
mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
await renderDialog()
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
})
it('renders the precompiled loading state while the remote Config is pending', async () => {
const remote = createDeferred<Component>()
mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
mocks.loadRemoteComponent.mockReturnValue(remote.promise)
await renderDialog()
expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
})
it('keeps configuration save successful when the sidebar refresh fails', async () => {
mocks.apiGet.mockResolvedValue({ conf: [], model: { enabled: true }, render_mode: 'vuetify' })
mocks.apiPut.mockResolvedValue({ success: true })
mocks.ensureSidebarNav.mockRejectedValue(new Error('sidebar unavailable'))
const result = await renderDialog()
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
await waitFor(() => {
expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: true })
expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
expect(result.emitted().save).toHaveLength(1)
})
expect(mocks.toast.success).toHaveBeenCalledOnce()
expect(mocks.toast.error).not.toHaveBeenCalled()
expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
})
it.each([
['business failure', () => Promise.resolve({ message: '配置被拒绝', success: false })],
['HTTP failure', () => Promise.reject(new Error('request failed'))],
])('keeps the dialog open and reports a %s', async (_case, saveResult) => {
mocks.apiGet.mockResolvedValue({ conf: [], model: {}, render_mode: 'vuetify' })
mocks.apiPut.mockImplementation(saveResult)
const result = await renderDialog()
await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toast.error).toHaveBeenCalledOnce())
expect(result.emitted().save).toBeUndefined()
expect(mocks.ensureSidebarNav).not.toHaveBeenCalled()
expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,236 @@
import type { Plugin } from '@/api/types'
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
loadRemoteComponent: vi.fn(),
nativeSubscribe: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/api', () => ({
default: { get: mocks.apiGet },
}))
vi.mock('@/utils/federationLoader', () => ({
loadRemoteComponent: mocks.loadRemoteComponent,
}))
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
}))
vi.mock('@/composables/usePWA', () => ({
usePWA: () => ({ appMode: false }),
}))
vi.mock('vue-toastification', () => ({
useToast: () => mocks.toast,
}))
const plugin: Plugin = {
id: 'DemoPlugin',
plugin_name: '演示插件',
}
const DialogStub = defineComponent({
name: 'VDialog',
setup:
(_, { slots }) =>
() =>
h('section', { role: 'dialog' }, slots.default?.()),
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
setup: () => () => h('div', '正在加载'),
})
const CloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
setup:
(_, { attrs }) =>
() =>
h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
})
const FabStub = defineComponent({
name: 'VFab',
setup:
(_, { attrs }) =>
() =>
h('button', { ...attrs, 'aria-label': '切换配置', type: 'button' }),
})
const PageRenderStub = defineComponent({
name: 'PageRender',
props: {
config: { type: Object as PropType<Record<string, unknown>>, required: true },
},
emits: ['action'],
setup:
(props, { emit }) =>
() =>
h('button', { onClick: () => emit('action'), type: 'button' }, String(props.config.component)),
})
type RemoteCapture = {
api: unknown
injectedNativeSubscribe: unknown
injectedToast: unknown
nativeSubscribe: unknown
showSwitch: boolean
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(promiseResolve => {
resolve = promiseResolve
})
return { promise, resolve }
}
function createRemotePage(captures: RemoteCapture[]): Component {
return defineComponent({
name: 'RemotePageFixture',
props: {
api: Object,
nativeSubscribe: Function,
show_switch: Boolean,
},
emits: ['action', 'close', 'switch'],
setup(props, { emit }) {
captures.push({
api: props.api,
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
injectedToast: inject('moviepilot:toast'),
nativeSubscribe: props.nativeSubscribe,
showSwitch: props.show_switch,
})
return () =>
h('section', { 'data-testid': 'remote-page' }, [
h('button', { onClick: () => emit('action'), type: 'button' }, '刷新远程页面'),
h('button', { onClick: () => emit('switch'), type: 'button' }, '切换配置'),
h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程页面'),
])
},
})
}
async function renderDialog(showSwitch = true) {
return renderWithProviders(PluginDataDialog, {
props: { modelValue: true, plugin, show_switch: showSwitch },
global: {
stubs: {
LoadingBanner: LoadingBannerStub,
PageRender: PageRenderStub,
VDialog: DialogStub,
VDialogCloseBtn: CloseButtonStub,
VFab: FabStub,
},
},
})
}
describe('PluginDataDialog', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.loadRemoteComponent.mockReset()
mocks.nativeSubscribe.mockReset()
mocks.toast.error.mockReset()
mocks.toast.success.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('distinguishes an empty Vuetify page from a failed load and retries in place', async () => {
mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
page: [],
render_mode: 'vuetify',
})
await renderDialog()
expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('此插件没有详情页面')).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
})
it('renders Vuetify page definitions and reloads them after an action', async () => {
mocks.apiGet
.mockResolvedValueOnce({ page: [{ component: 'VBtn' }], render_mode: 'vuetify' })
.mockResolvedValueOnce({ page: [{ component: 'VChip' }], render_mode: 'vuetify' })
const result = await renderDialog()
await fireEvent.click(await screen.findByRole('button', { name: 'VBtn' }))
expect(await screen.findByRole('button', { name: 'VChip' })).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
expect(result.emitted().switch).toHaveLength(1)
expect(result.emitted().close).toHaveLength(1)
})
it('passes the same host capabilities through props and provide and forwards remote events', async () => {
const captures: RemoteCapture[] = []
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
mocks.loadRemoteComponent.mockResolvedValue(createRemotePage(captures))
const result = await renderDialog(false)
expect(await screen.findByTestId('remote-page')).toBeInTheDocument()
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Page')
expect(captures).toHaveLength(1)
expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
expect(captures[0].showSwitch).toBe(false)
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedToast).toBe(mocks.toast)
await fireEvent.click(screen.getByRole('button', { name: '刷新远程页面' }))
await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
await fireEvent.click(screen.getByRole('button', { name: '关闭远程页面' }))
expect(mocks.apiGet).toHaveBeenCalledOnce()
expect(result.emitted().switch).toHaveLength(1)
expect(result.emitted().close).toHaveLength(1)
})
it('renders the async error component when the remote Page rejects', async () => {
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
await renderDialog()
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
})
it('renders the precompiled loading state while the remote Page is pending', async () => {
const remote = createDeferred<Component>()
mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
mocks.loadRemoteComponent.mockReturnValue(remote.promise)
await renderDialog()
expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
})
it('treats an unsupported render mode as a load error instead of a blank dialog', async () => {
mocks.apiGet.mockResolvedValue({ render_mode: 'legacy' })
await renderDialog()
expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
await waitFor(() => expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument())
})
})

View File

@@ -7,6 +7,7 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
import RemoteComponentError from './RemoteComponentError.vue'
type DashboardComponentLoader = () => Promise<any>
@@ -114,50 +115,50 @@ const isDashboardElementLoaded = ref(false)
let isDashboardElementUnmounted = false
let pluginDashboardComponentLoadPromise: Promise<any> | null = null
let dashboardLoadGeneration = 0
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise
function loadPluginDashboardComponent() {
if (!props.config?.id) return Promise.reject(new Error('插件ID不存在'))
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载
const pluginDashboardIdentity = computed(
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
)
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
function loadPluginDashboardComponent(pluginId: string) {
if (!pluginDashboardComponentLoadPromise) {
pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => {
pluginDashboardComponentLoadPromise = null
const loadPromise = loadRemoteComponent(pluginId, 'Dashboard')
const guardedPromise = loadPromise.catch(error => {
if (pluginDashboardComponentLoadPromise === guardedPromise) {
pluginDashboardComponentLoadPromise = null
}
throw error
})
pluginDashboardComponentLoadPromise = guardedPromise
}
return pluginDashboardComponentLoadPromise
}
// Vue 模式:动态加载的组件
const dynamicPluginComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
const module = await loadPluginDashboardComponent()
// 每个插件节点身份使用独立异步组件,避免 Vue 复用前一个 remote 的成功解析结果。
const dynamicPluginComponent = computed(() => {
const pluginId = props.config?.id
const identity = pluginDashboardIdentity.value
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
throw error
}
},
// 加载中显示的组件
loadingComponent: DashboardSkeleton,
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
return defineAsyncComponent({
loader: async () => {
try {
if (!pluginId) throw new Error(`插件ID不存在: ${identity}`)
return await loadPluginDashboardComponent(pluginId)
} catch (error) {
console.error('加载远程组件失败:', error)
throw error
}
},
loadingComponent: DashboardSkeleton,
errorComponent: RemoteComponentError,
})
})
// 判断当前配置是否对应内置异步仪表盘组件。
@@ -179,28 +180,38 @@ function emitDashboardElementLoaded() {
}
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
async function waitForDashboardElementLoaded() {
async function waitForDashboardElementLoaded(generation: number) {
if (isDashboardElementLoaded.value) return
try {
if (isBuiltInDashboardElement() && props.config?.id) {
await loadBuiltInDashboardComponent(props.config.id)
} else if (isVuePluginDashboardElement()) {
await loadPluginDashboardComponent()
} else if (isVuePluginDashboardElement() && props.config?.id) {
await loadPluginDashboardComponent(props.config.id)
}
await nextTick()
} catch (error) {
console.error(error)
} finally {
emitDashboardElementLoaded()
if (generation === dashboardLoadGeneration) emitDashboardElementLoaded()
}
}
watch(
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
() => {
void waitForDashboardElementLoaded()
(_identity, previousIdentity) => {
const pluginIdentityChanged =
previousIdentity &&
(pluginRenderMode.value === 'vue' || previousIdentity[2] === 'vue') &&
previousIdentity.some((value, index) => value !== _identity[index])
if (pluginIdentityChanged) {
isDashboardElementLoaded.value = false
pluginDashboardComponentLoadPromise = null
}
const generation = ++dashboardLoadGeneration
void waitForDashboardElementLoaded(generation)
},
{ immediate: true },
)
@@ -233,6 +244,7 @@ onUnmounted(() => {
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
<component
:key="pluginDashboardIdentity"
:is="dynamicPluginComponent"
:config="props.config"
:allow-refresh="props.allowRefresh"

View File

@@ -0,0 +1,6 @@
<!-- 联邦远程组件加载失败时使用预编译错误态兼容生产环境的 Vue runtime-only 构建 -->
<template>
<div class="pa-4">
<VAlert type="error" title="组件加载错误"> 无法加载组件请稍后再试 </VAlert>
</div>
</template>

View File

@@ -0,0 +1,6 @@
<!-- 联邦远程组件加载期间使用预编译骨架兼容生产环境的 Vue runtime-only 构建 -->
<template>
<div class="pa-4" data-testid="remote-component-loading">
<VSkeletonLoader type="card" />
</div>
</template>

View File

@@ -0,0 +1,254 @@
import type { DashboardItem } from '@/api/types'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { renderWithProviders } from '@tests/support/render'
import { screen, waitFor } from '@testing-library/vue'
import { flushPromises } from '@vue/test-utils'
import { defineComponent, h, inject, type Component, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
loadRemoteComponent: vi.fn(),
nativeSubscribe: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/api', () => ({
default: { get: mocks.apiGet },
}))
vi.mock('@/utils/federationLoader', () => ({
loadRemoteComponent: mocks.loadRemoteComponent,
}))
vi.mock('@/composables/usePluginNativeSubscribe', () => ({
usePluginNativeSubscribe: () => mocks.nativeSubscribe,
}))
vi.mock('vue-toastification', () => ({
useToast: () => mocks.toast,
}))
type RemoteCapture = {
allowRefresh: boolean
api: unknown
config: DashboardItem
injectedNativeSubscribe: unknown
injectedToast: unknown
nativeSubscribe: unknown
}
function createDeferred<T>() {
let reject!: (reason?: unknown) => void
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
function createRemoteDashboard(captures: RemoteCapture[], label = 'remote'): Component {
return defineComponent({
name: 'RemoteDashboardFixture',
props: {
allowRefresh: Boolean,
api: Object,
config: { type: Object as PropType<DashboardItem>, required: true },
nativeSubscribe: Function,
},
setup(props) {
captures.push({
allowRefresh: props.allowRefresh,
api: props.api,
config: props.config,
injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
injectedToast: inject('moviepilot:toast'),
nativeSubscribe: props.nativeSubscribe,
})
return () => h('div', { 'data-testid': 'remote-dashboard' }, `${label}:${props.config.name}`)
},
})
}
function createPluginDashboard(overrides: Partial<DashboardItem> = {}): DashboardItem {
return {
attrs: { border: true, title: '插件仪表盘' },
cols: { lg: 6 },
elements: [],
id: 'DemoPlugin',
key: 'main',
name: '演示仪表盘',
render_mode: 'vue',
...overrides,
}
}
describe('DashboardElement plugin host', () => {
beforeEach(() => {
mocks.loadRemoteComponent.mockReset()
mocks.apiGet.mockReset()
mocks.nativeSubscribe.mockReset()
mocks.toast.error.mockReset()
mocks.toast.success.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('reuses one remote promise and passes identical prop/provide capabilities', async () => {
const captures: RemoteCapture[] = []
const config = createPluginDashboard()
mocks.loadRemoteComponent.mockResolvedValue(createRemoteDashboard(captures))
const result = await renderWithProviders(DashboardElement, {
props: { allowRefresh: false, config, refreshStatus: true },
})
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:演示仪表盘')
expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard')
expect(captures).toHaveLength(1)
expect(captures[0].config).toStrictEqual(config)
expect(captures[0].allowRefresh).toBe(false)
expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
expect(captures[0].injectedToast).toBe(mocks.toast)
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
result.unmount()
expect(result.emitted()['update:refreshStatus']).toEqual([[false]])
})
it('shows the remote error and retries after the plugin dashboard identity changes', async () => {
const captures: RemoteCapture[] = []
mocks.loadRemoteComponent
.mockRejectedValueOnce(new Error('remote unavailable'))
.mockResolvedValueOnce(createRemoteDashboard(captures))
const result = await renderWithProviders(DashboardElement, {
props: { config: createPluginDashboard(), refreshStatus: false },
})
expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
await result.rerender({
config: createPluginDashboard({ key: 'secondary', name: '恢复后的仪表盘' }),
refreshStatus: false,
})
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:恢复后的仪表盘')
expect(mocks.loadRemoteComponent).toHaveBeenCalledTimes(2)
})
it('replaces a successfully resolved remote component when the plugin identity changes', async () => {
const captures: RemoteCapture[] = []
mocks.loadRemoteComponent
.mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-a'))
.mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-b'))
const result = await renderWithProviders(DashboardElement, {
props: { config: createPluginDashboard(), refreshStatus: false },
})
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-a:演示仪表盘')
await result.rerender({
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '另一个仪表盘' }),
refreshStatus: false,
})
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:另一个仪表盘')
expect(mocks.loadRemoteComponent).toHaveBeenNthCalledWith(2, 'OtherPlugin', 'Dashboard')
})
it('ignores an older remote that resolves after the replacement has loaded', async () => {
const captures: RemoteCapture[] = []
const remoteA = createDeferred<Component>()
const remoteB = createDeferred<Component>()
mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
const result = await renderWithProviders(DashboardElement, {
props: { config: createPluginDashboard(), refreshStatus: false },
})
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
await result.rerender({
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '替换后的仪表盘' }),
refreshStatus: false,
})
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
await flushPromises()
expect(screen.getByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
expect(captures).toHaveLength(1)
expect(captures[0].config.id).toBe('OtherPlugin')
expect(result.emitted().loaded).toHaveLength(1)
})
it('reports loaded only after the current remote resolves', async () => {
const captures: RemoteCapture[] = []
const remoteA = createDeferred<Component>()
const remoteB = createDeferred<Component>()
mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
const result = await renderWithProviders(DashboardElement, {
props: { config: createPluginDashboard(), refreshStatus: false },
})
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
await result.rerender({
config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '当前仪表盘' }),
refreshStatus: false,
})
await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
await flushPromises()
expect(result.emitted().loaded).toBeUndefined()
expect(captures).toHaveLength(0)
remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:当前仪表盘')
await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
})
it('renders the Vuetify plugin branch without loading a remote component', async () => {
const DashboardRenderStub = defineComponent({
name: 'DashboardRender',
props: { config: { type: Object as PropType<Record<string, unknown>>, required: true } },
setup: props => () => h('div', { 'data-testid': 'dashboard-render' }, String(props.config.component)),
})
await renderWithProviders(DashboardElement, {
props: {
config: createPluginDashboard({
elements: [{ component: 'VChip' }],
render_mode: 'vuetify',
}),
},
global: { stubs: { DashboardRender: DashboardRenderStub } },
})
expect(await screen.findByTestId('dashboard-render')).toHaveTextContent('VChip')
expect(screen.getByText('插件仪表盘')).toBeInTheDocument()
expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
})
it('shows an explicit error for an unsupported plugin render mode', async () => {
await renderWithProviders(DashboardElement, {
props: { config: createPluginDashboard({ render_mode: 'legacy' }) },
})
expect(await screen.findByText('无法渲染插件仪表盘部件: 未知渲染模式或配置错误')).toBeInTheDocument()
expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
})
})

View File

@@ -354,6 +354,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/AddSubtitleDownloadDialog.vue',
'src/components/dialog/ReorganizeDialog.vue',
'src/components/dialog/TransferQueueDialog.vue',
'src/components/dialog/PluginConfigDialog.vue',
'src/components/dialog/PluginDataDialog.vue',
'src/views/reorganize/FileBrowserView.vue',
'src/components/filebrowser/FileBrowser.vue',
'src/components/filebrowser/FileToolbar.vue',
@@ -516,6 +518,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
'src/components/dialog/PluginConfigDialog.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/components/dialog/PluginDataDialog.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,