mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 00:23:37 +08:00
test(plugin): cover plugin host surfaces (#630)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
292
src/components/dialog/__tests__/PluginConfigDialog.spec.ts
Normal file
292
src/components/dialog/__tests__/PluginConfigDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
236
src/components/dialog/__tests__/PluginDataDialog.spec.ts
Normal file
236
src/components/dialog/__tests__/PluginDataDialog.spec.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
6
src/components/misc/RemoteComponentError.vue
Normal file
6
src/components/misc/RemoteComponentError.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- 联邦远程组件加载失败时使用预编译错误态,兼容生产环境的 Vue runtime-only 构建。 -->
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<VAlert type="error" title="组件加载错误"> 无法加载组件,请稍后再试 </VAlert>
|
||||
</div>
|
||||
</template>
|
||||
6
src/components/misc/RemoteComponentLoading.vue
Normal file
6
src/components/misc/RemoteComponentLoading.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- 联邦远程组件加载期间使用预编译骨架,兼容生产环境的 Vue runtime-only 构建。 -->
|
||||
<template>
|
||||
<div class="pa-4" data-testid="remote-component-loading">
|
||||
<VSkeletonLoader type="card" />
|
||||
</div>
|
||||
</template>
|
||||
254
src/components/misc/__tests__/DashboardElement.spec.ts
Normal file
254
src/components/misc/__tests__/DashboardElement.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user