mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
fix(plugin): align lifecycle action state handling (#637)
This commit is contained in:
@@ -141,31 +141,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginAppCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginMixedSortCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -270,17 +245,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/PluginMarketDetailDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/PluginMarketSettingDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -39,7 +39,7 @@ const createConfirm = useConfirm()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
@@ -51,9 +51,6 @@ const pluginLabels = computed(() => {
|
||||
.filter(tag => tag.length > 0)
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -74,7 +71,6 @@ function closeInstallProgress() {
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -99,20 +95,16 @@ function visitPluginPage() {
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!repoUrl) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
@@ -159,7 +151,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -167,8 +159,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
@@ -178,8 +168,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
$toast.error(
|
||||
t('plugin.installFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closeInstallProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +320,7 @@ onUnmounted(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
@@ -45,11 +46,13 @@ const display = useDisplay()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
@@ -62,9 +65,6 @@ const menuVisible = ref(false)
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -98,7 +98,6 @@ watch(
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -124,17 +123,15 @@ async function uninstallPlugin() {
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
@@ -144,8 +141,15 @@ async function uninstallPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +208,12 @@ async function resetPlugin() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
@@ -218,6 +223,12 @@ async function resetPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -243,14 +254,13 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -258,16 +268,14 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
@@ -277,8 +285,15 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +377,11 @@ async function showPluginAbout() {
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('save'),
|
||||
install: () => {
|
||||
emit('save')
|
||||
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
@@ -445,7 +464,7 @@ async function executePluginClone(cloneForm: {
|
||||
try {
|
||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
@@ -453,21 +472,21 @@ async function executePluginClone(cloneForm: {
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +600,7 @@ const dropdownItems = ref([
|
||||
// 监听插件状态变化
|
||||
watch(
|
||||
() => props.plugin?.has_update,
|
||||
(newHasUpdate, _) => {
|
||||
newHasUpdate => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
|
||||
@@ -593,7 +612,7 @@ watch(
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.plugin?.page_open,
|
||||
(newOpenState, _) => {
|
||||
newOpenState => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -695,7 +714,7 @@ watch(
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
@@ -16,7 +44,32 @@ const plugin: Plugin = {
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||
const unrated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||
@@ -32,4 +85,175 @@ describe('PluginAppCard rating badge', () => {
|
||||
expect(badge).toHaveTextContent('4.3')
|
||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports an HTTP failure without emitting install or hiding the card', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('network unavailable'))
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(container.querySelector('.plugin-app-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens market details and forwards only the detail completion event', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin, count: 12 },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ plugin, count: 12 })
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const detailEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||
detailEvents.install()
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs a selected release with exact parameters and emits completion', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the version dialog open and emits nothing on a business failure', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: false, message: '下载失败' })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:下载失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
})
|
||||
|
||||
it('blocks incompatible latest installs and honors a cancelled release confirmation', async () => {
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders normalized labels, handles image events, and opens a raw GitHub repository', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
plugin_icon: 'https://example.com/plugin.png',
|
||||
plugin_label: ' 工具, 自动化, ,工具 ',
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('工具')).toHaveLength(2)
|
||||
expect(screen.getByText('自动化')).toBeInTheDocument()
|
||||
const image = screen.getByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||
})
|
||||
|
||||
it('uses the author page for a local plugin project link', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
is_local: true,
|
||||
repo_url: 'local://DemoPlugin',
|
||||
author_url: 'https://github.com/example-author',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example-author', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
delete: mocks.apiDelete,
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试插件生命周期',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
installed: true,
|
||||
state: true,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginCard lifecycle actions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('refreshes plugin sidebar navigation after uninstall succeeds', async () => {
|
||||
mocks.apiDelete.mockResolvedValue({ success: true })
|
||||
const { container, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('plugin/DemoPlugin'))
|
||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 卸载成功!')
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors uninstall cancellation and preserves the card on business failure', async () => {
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
const cancelled = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(cancelled.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||
cancelled.unmount()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(true)
|
||||
mocks.apiDelete.mockResolvedValueOnce({ success: false, message: '仍有任务运行' })
|
||||
const failed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(failed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 卸载失败:仍有任务运行'))
|
||||
expect(failed.emitted()).not.toHaveProperty('remove')
|
||||
expect(failed.container.querySelector('.plugin-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports uninstall HTTP failures and always closes progress', async () => {
|
||||
mocks.apiDelete.mockRejectedValue(new Error('network unavailable'))
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('卸载失败')))
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('resets plugin data only after confirmation and refreshes navigation on success', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('plugin/reset/DemoPlugin'))
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: expect.stringContaining('演示插件') }),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 数据已重置')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports reset business and HTTP failures without emitting save', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '无法清理数据' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:无法清理数据'))
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:服务器连接失败'))
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('updates from a confirmed Release with exact query parameters', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: updatablePlugin },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks an incompatible latest update without sending a request', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps version history open after update business and HTTP failures', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '校验失败' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
let versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:校验失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('creates a clone with trimmed form values and refreshes navigation', async () => {
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({
|
||||
suffix: ' Test ',
|
||||
name: '演示分身',
|
||||
description: ' 独立配置 ',
|
||||
version: ' 1.0.1 ',
|
||||
icon: ' https://example.com/icon.png ',
|
||||
})
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/clone/DemoPlugin', {
|
||||
suffix: 'Test',
|
||||
name: '演示分身',
|
||||
description: '独立配置',
|
||||
version: '1.0.1',
|
||||
icon: 'https://example.com/icon.png',
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty clone suffix before calling the API', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps clone dialog open after business and HTTP failures', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false, message: '后缀已存在' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (value: typeof form) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('opens data and config surfaces with reciprocal switch contracts', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: { ...plugin, has_page: true } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
plugin: expect.objectContaining({ id: 'DemoPlugin' }),
|
||||
})
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'switch'] })
|
||||
|
||||
const dataEvents = mocks.openSharedDialog.mock.calls[0][2] as { switch: () => void }
|
||||
dataEvents.switch()
|
||||
expect(mocks.openSharedDialog.mock.calls[1][3]).toEqual({ closeOn: ['close', 'save', 'switch'] })
|
||||
|
||||
const configEvents = mocks.openSharedDialog.mock.calls[1][2] as { save: () => void; switch: () => void }
|
||||
configEvents.save()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
configEvents.switch()
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||
sortable: true,
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
await fireEvent.click(authorImage)
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the shared log dialog from the menu', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('查看日志'))
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ plugin },
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
})
|
||||
|
||||
it('opens plugin detail from an external action exactly once', async () => {
|
||||
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, action: false },
|
||||
})
|
||||
|
||||
await rerender({ plugin, action: true })
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(emitted().actionDone).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -54,9 +55,11 @@ describe('PluginCard about menu', () => {
|
||||
})
|
||||
|
||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, count: 24 },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
@@ -76,5 +79,47 @@ describe('PluginCard about menu', () => {
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
expect(dialogProps.count).toBe(24)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||
dialogEvents.install()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
||||
if (url === 'plugin/') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
...plugin,
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
])
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
const replace = vi.fn()
|
||||
const popup = {
|
||||
close: vi.fn(),
|
||||
location: { replace },
|
||||
opener: window,
|
||||
} as unknown as Window
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(popup)
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/', {
|
||||
params: { force: false, state: 'market' },
|
||||
})
|
||||
})
|
||||
expect(open).toHaveBeenCalledWith('about:blank', '_blank')
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith('https://github.com/example/plugins'))
|
||||
expect(popup.opener).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,9 +59,6 @@ const selectedRating = ref(props.plugin?.user_rating || 0)
|
||||
const ratingLoading = ref(false)
|
||||
const ratingSubmitting = ref(false)
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -97,20 +94,16 @@ function visitPluginPage() {
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!repoUrl) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
@@ -136,6 +129,8 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
isInstalled.value && !releaseVersion
|
||||
@@ -146,16 +141,14 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(
|
||||
isInstalled.value
|
||||
@@ -167,11 +160,18 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
$toast.error(t(failureMessageKey, { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
$toast.error(
|
||||
t(failureMessageKey, {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closeInstallProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ onUnmounted(() => {
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||
<VImg :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
|
||||
@@ -63,7 +63,9 @@ const resolvedHistory = computed(() => {
|
||||
|
||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||
|
||||
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
||||
const latestActionText = computed(() =>
|
||||
props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'),
|
||||
)
|
||||
|
||||
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||
|
||||
@@ -187,29 +189,48 @@ watch(
|
||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</div>
|
||||
<VCardText v-else-if="loadError && !hasHistory">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<template v-else>
|
||||
<VCardText v-if="releaseError" class="pb-0">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
||||
<VCardText v-if="loadError || releaseError" class="pb-0">
|
||||
<VAlert v-if="loadError" type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
<VAlert
|
||||
v-if="releaseError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
:class="{ 'mt-2': loadError }"
|
||||
:text="releaseError"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<VersionHistory
|
||||
v-if="hasHistory"
|
||||
:history="resolvedHistory"
|
||||
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
<template #meta="{ version }">
|
||||
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
||||
<span
|
||||
v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)"
|
||||
class="plugin-release-meta__date"
|
||||
>
|
||||
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||
</span>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
||||
<VChip
|
||||
v-if="releaseItemByHistoryVersion(version)?.is_latest"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('plugin.latestVersion') }}
|
||||
</VChip>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
||||
<VChip
|
||||
v-if="releaseItemByHistoryVersion(version)?.is_current"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('plugin.currentVersion') }}
|
||||
</VChip>
|
||||
</div>
|
||||
@@ -229,9 +250,7 @@ watch(
|
||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
{{
|
||||
releaseItemByHistoryVersion(version)?.is_latest
|
||||
? latestActionText
|
||||
: t('plugin.installReleaseVersion')
|
||||
releaseItemByHistoryVersion(version)?.is_latest ? latestActionText : t('plugin.installReleaseVersion')
|
||||
}}
|
||||
</VBtn>
|
||||
</template>
|
||||
@@ -248,11 +267,7 @@ watch(
|
||||
class="mb-3"
|
||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<VBtn
|
||||
@click="handleUpdate()"
|
||||
block
|
||||
:disabled="resolvedPlugin?.system_version_compatible === false"
|
||||
>
|
||||
<VBtn @click="handleUpdate()" block :disabled="resolvedPlugin?.system_version_compatible === false">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
@@ -289,5 +304,4 @@ watch(
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -3,11 +3,16 @@ import type { Plugin, PluginRating } from '@/api/types'
|
||||
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import type { Stubs } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogClose: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
@@ -23,6 +28,14 @@ vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
const basePlugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
@@ -39,25 +52,45 @@ const ratingResult: PluginRating = {
|
||||
user_rating: 4.0,
|
||||
}
|
||||
|
||||
async function renderDialog(plugin: Plugin) {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error'],
|
||||
template: '<button data-testid="plugin-image" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
async function renderDialog(plugin: Plugin, stubs: Stubs = {}) {
|
||||
return renderWithProviders(PluginMarketDetailDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
plugin,
|
||||
},
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
global: {
|
||||
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||
stubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginMarketDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue(ratingResult)
|
||||
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: true })
|
||||
})
|
||||
mocks.apiPost.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 },
|
||||
})
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogClose.mockReset()
|
||||
mocks.openSharedDialog.mockReset().mockReturnValue({
|
||||
close: mocks.dialogClose,
|
||||
id: 1,
|
||||
updateProps: vi.fn(),
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('shows install action and readonly rating for a market plugin', async () => {
|
||||
@@ -102,4 +135,214 @@ describe('PluginMarketDetailDialog', () => {
|
||||
expect(screen.getByText('我的评分')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits installation completion only after installation succeeds', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: false,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the detail open and emits nothing after a business failure', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: false, message: '安装包损坏' })
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:安装包损坏')
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
})
|
||||
|
||||
it('reports an HTTP install failure without closing or emitting completion', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.reject(new Error('network unavailable'))
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks an incompatible latest install before sending a request', async () => {
|
||||
await renderDialog({
|
||||
...basePlugin,
|
||||
installed: false,
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
})
|
||||
|
||||
const installButton = await screen.findByRole('button', { name: '安装到本地' })
|
||||
expect(installButton).toBeDisabled()
|
||||
expect(screen.getByText('需要更高版本')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||
})
|
||||
|
||||
it('installs a confirmed historical Release with its repo URL', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false, release: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows update semantics for an installed plugin with a newer version', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
})
|
||||
|
||||
it('reports an HTTP update failure with update semantics', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.reject(new Error('network unavailable'))
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a business update failure with the backend message', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: false, message: '更新包损坏' })
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:更新包损坏')
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
})
|
||||
|
||||
it('forces a latest Release update when the installed plugin snapshot has no update flag', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false, release: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update(undefined, 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
})
|
||||
|
||||
it('handles image fallback and opens a raw GitHub repository from the author row', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
await renderDialog(
|
||||
{
|
||||
...basePlugin,
|
||||
installed: false,
|
||||
plugin_icon: 'https://example.com/plugin.png',
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
{ VImg: ImageStub },
|
||||
)
|
||||
|
||||
await fireEvent.contextMenu(screen.getByTestId('plugin-image'))
|
||||
await fireEvent.click(await screen.findByText('MoviePilot'))
|
||||
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||
})
|
||||
|
||||
it('reports rating business and HTTP failures with stable feedback', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
||||
const businessFailed = await renderDialog({ ...basePlugin, installed: true })
|
||||
const businessButton = await screen.findByRole('button', { name: '提交评分' })
|
||||
await waitFor(() => expect(businessButton).toBeEnabled())
|
||||
await fireEvent.click(businessButton)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:未知')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
const httpButton = await screen.findByRole('button', { name: '提交评分' })
|
||||
await waitFor(() => expect(httpButton).toBeEnabled())
|
||||
await fireEvent.click(httpButton)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败')
|
||||
})
|
||||
|
||||
it('keeps plugin actions usable when rating loading fails', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('rating unavailable'))
|
||||
await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('opens installed version history without an update action and closes through the model contract', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
actionMode: 'update',
|
||||
showUpdateAction: false,
|
||||
})
|
||||
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||
expect(closeButton).not.toBeNull()
|
||||
await fireEvent.click(closeButton!)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(emitted().close).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import type { Plugin, PluginReleaseVersionsResponse } from '@/api/types'
|
||||
import PluginVersionHistoryDialog from '@/components/dialog/PluginVersionHistoryDialog.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
const installedPlugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_version: '1.0.0',
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
installed: true,
|
||||
release: true,
|
||||
}
|
||||
|
||||
const releases: PluginReleaseVersionsResponse = {
|
||||
release_supported: true,
|
||||
latest_version: '2.0.0',
|
||||
current_version: '1.0.0',
|
||||
items: [
|
||||
{
|
||||
version: '2.0.0',
|
||||
tag_name: 'v2.0.0',
|
||||
body: '最新说明',
|
||||
published_at: '2026-08-01T00:00:00Z',
|
||||
is_latest: true,
|
||||
is_current: false,
|
||||
},
|
||||
{
|
||||
version: '1.0.0',
|
||||
tag_name: 'v1.0.0',
|
||||
body: '当前说明',
|
||||
is_latest: false,
|
||||
is_current: true,
|
||||
},
|
||||
{
|
||||
version: '0.9.0',
|
||||
tag_name: 'v0.9.0',
|
||||
body: '旧版说明',
|
||||
published_at: 'invalid-date',
|
||||
is_latest: false,
|
||||
is_current: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function renderDialog(props: Record<string, unknown>) {
|
||||
return renderWithProviders(PluginVersionHistoryDialog, {
|
||||
props,
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginVersionHistoryDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === 'plugin/history/DemoPlugin') {
|
||||
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
|
||||
}
|
||||
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('loads installed history and releases, marks versions, and emits exact update arguments', async () => {
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
showUpdateAction: true,
|
||||
actionMode: 'update',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('最新')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前')).toBeInTheDocument()
|
||||
expect(screen.getByText('invalid-date')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
|
||||
params: { force: true },
|
||||
})
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'plugin/releases/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '安装' }))
|
||||
expect(emitted().update).toEqual([['0.9.0', 'https://github.com/example/plugins']])
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '更新到最新版本' }))
|
||||
expect(emitted().update).toEqual([
|
||||
['0.9.0', 'https://github.com/example/plugins'],
|
||||
[undefined, 'https://github.com/example/plugins'],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses market metadata directly and emits latest installation without a release version', async () => {
|
||||
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: marketPlugin,
|
||||
actionMode: 'install',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/history/DemoPlugin', expect.anything())
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/releases/DemoPlugin', {
|
||||
params: {
|
||||
force: false,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
|
||||
const installButtons = screen.getAllByRole('button', { name: '安装' })
|
||||
await fireEvent.click(installButtons[0])
|
||||
expect(emitted().update).toEqual([[undefined, 'https://github.com/example/plugins']])
|
||||
})
|
||||
|
||||
it('distinguishes a Release request failure from an empty history', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
|
||||
await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: { ...installedPlugin, installed: false, history: {} },
|
||||
actionMode: 'install',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Release 版本加载失败')).toBeInTheDocument()
|
||||
expect(screen.queryByText('暂未获取到更新说明')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows history request failures and true empty history as different states', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('history unavailable'))
|
||||
const failed = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
|
||||
failed.unmount()
|
||||
|
||||
mocks.apiGet.mockReset().mockResolvedValue({ ...installedPlugin, release: false, history: {} })
|
||||
await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
expect(await screen.findByText('暂未获取到更新说明')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes through the model contract', async () => {
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||
expect(closeButton).not.toBeNull()
|
||||
await fireEvent.click(closeButton!)
|
||||
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(emitted().close).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -370,6 +370,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/cards/SiteCard.vue',
|
||||
'src/components/cards/DownloadingCard.vue',
|
||||
'src/components/cards/PluginFolderCard.vue',
|
||||
'src/components/cards/PluginCard.vue',
|
||||
'src/components/cards/PluginAppCard.vue',
|
||||
'src/components/dialog/PluginMarketDetailDialog.vue',
|
||||
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
||||
'src/components/slide/VirtualSlideView.vue',
|
||||
'src/views/discover/PersonCardSlideView.vue',
|
||||
'src/views/reorganize/TransferHistoryView.vue',
|
||||
@@ -418,6 +422,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/PluginCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/PluginAppCard.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/PluginMarketDetailDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/PluginVersionHistoryDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/views/reorganize/FileBrowserView.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
|
||||
Reference in New Issue
Block a user