fix(plugin): align lifecycle action state handling (#637)

This commit is contained in:
InfinityPacer
2026-08-04 06:33:00 +08:00
committed by GitHub
parent 41d5f579f7
commit e3ee714732
11 changed files with 1250 additions and 141 deletions
-36
View File
@@ -141,31 +141,6 @@
"count": 1 "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": { "src/components/cards/PluginMixedSortCard.vue": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -270,17 +245,6 @@
"count": 1 "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": { "src/components/dialog/PluginMarketSettingDialog.vue": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 3 "count": 3
+18 -21
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import type { Plugin } from '@/api/types' import type { ApiResponse, Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor' import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
@@ -39,7 +39,7 @@ const createConfirm = useConfirm()
const accentRgb = ref('40, 169, 225') const accentRgb = ref('40, 169, 225')
// 图片对象 // 图片对象
const imageRef = ref<any>() const imageRef = ref<{ $el: HTMLElement } | null>(null)
// 获取当前插件的标签 // 获取当前插件的标签
const pluginLabels = computed(() => { const pluginLabels = computed(() => {
@@ -51,9 +51,6 @@ const pluginLabels = computed(() => {
.filter(tag => tag.length > 0) .filter(tag => tag.length > 0)
}) })
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
@@ -74,7 +71,6 @@ function closeInstallProgress() {
// 图片加载完成 // 图片加载完成
async function imageLoaded() { async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源 // 从图标中提取主色,作为卡片头部染色玻璃的色相来源
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1') accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
@@ -99,20 +95,16 @@ function visitPluginPage() {
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) { if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
repoUrl = props.plugin?.author_url repoUrl = props.plugin?.author_url
} }
if (repoUrl) { if (repoUrl?.includes('raw.githubusercontent.com')) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) repoUrl += '/'
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
try { try {
const [user, repo] = repoUrl.split('/').slice(-4, -2) const rawUrl = new URL(repoUrl)
repoUrl = `https://github.com/${user}/${repo}` const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
} catch (error) { if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
} catch {
return return
} }
} }
} else { if (!repoUrl) {
repoUrl = props.plugin?.author_url repoUrl = props.plugin?.author_url
} }
window.open(repoUrl, '_blank') 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: { params: {
repo_url: repoUrl || props.plugin?.repo_url, repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion, release_version: releaseVersion,
@@ -167,8 +159,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
}, },
}) })
closeInstallProgress()
if (result.success) { if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close() 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 })) $toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
} }
} catch (error) { } catch (error) {
closeInstallProgress() $toast.error(
t('plugin.installFailed', {
name: props.plugin?.plugin_name,
message: t('common.serverConnectionFailed'),
}),
)
console.error(error) console.error(error)
} finally {
closeInstallProgress()
} }
} }
@@ -323,7 +320,7 @@ onUnmounted(() => {
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon" /> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title" /> <VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
+50 -31
View File
@@ -2,13 +2,14 @@
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import api from '@/api' import api from '@/api'
import type { Plugin } from '@/api/types' import type { ApiResponse, Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor' import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import { formatDownloadCount } from '@/@core/utils/formatters' import { formatDownloadCount } from '@/@core/utils/formatters'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。 // 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue')) const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
@@ -45,11 +46,13 @@ const display = useDisplay()
const accentRgb = ref('40, 169, 225') const accentRgb = ref('40, 169, 225')
// 图片对象 // 图片对象
const imageRef = ref<any>() const imageRef = ref<{ $el: HTMLElement } | null>(null)
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 确认框 // 确认框
const createConfirm = useConfirm() const createConfirm = useConfirm()
@@ -62,9 +65,6 @@ const menuVisible = ref(false)
// 用户头像是否加载完成 // 用户头像是否加载完成
const isAvatarLoaded = ref(false) const isAvatarLoaded = ref(false)
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
@@ -98,7 +98,6 @@ watch(
// 图片加载完成 // 图片加载完成
async function imageLoaded() { async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源 // 从图标中提取主色,作为卡片头部染色玻璃的色相来源
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1') accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
@@ -124,17 +123,15 @@ async function uninstallPlugin() {
if (!isConfirmed) return if (!isConfirmed) return
try {
// 显示等待提示框
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name })) showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`) try {
// 隐藏等待提示框 const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
closePluginProgress()
if (result.success) { if (result.success) {
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('remove') emit('remove')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} else { } else {
$toast.error( $toast.error(
t('plugin.uninstallFailed', { t('plugin.uninstallFailed', {
@@ -144,8 +141,15 @@ async function uninstallPlugin() {
) )
} }
} catch (error) { } catch (error) {
closePluginProgress() $toast.error(
t('plugin.uninstallFailed', {
name: props.plugin?.plugin_name,
message: t('common.serverConnectionFailed'),
}),
)
console.error(error) console.error(error)
} finally {
closePluginProgress()
} }
} }
@@ -204,11 +208,12 @@ async function resetPlugin() {
if (!isConfirmed) return if (!isConfirmed) return
try { 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) { if (result.success) {
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save') emit('save')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} else { } else {
$toast.error( $toast.error(
t('plugin.resetFailed', { t('plugin.resetFailed', {
@@ -218,6 +223,12 @@ async function resetPlugin() {
) )
} }
} catch (error) { } catch (error) {
$toast.error(
t('plugin.resetFailed', {
name: props.plugin?.plugin_name,
message: t('common.serverConnectionFailed'),
}),
)
console.error(error) console.error(error)
} }
} }
@@ -243,14 +254,13 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
} }
try { try {
// 显示等待提示框
showPluginProgress( showPluginProgress(
releaseVersion releaseVersion
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion }) ? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
: t('plugin.updating', { name: props.plugin?.plugin_name }), : 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: { params: {
repo_url: repoUrl || props.plugin?.repo_url, repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion, release_version: releaseVersion,
@@ -258,16 +268,14 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
}, },
}) })
// 隐藏等待提示框
closePluginProgress()
if (result.success) { if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close() versionHistoryDialogController?.close()
versionHistoryDialogController = null versionHistoryDialogController = null
// 通知父组件刷新
emit('save') emit('save')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} else { } else {
$toast.error( $toast.error(
t('plugin.updateFailed', { t('plugin.updateFailed', {
@@ -277,8 +285,15 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
) )
} }
} catch (error) { } catch (error) {
closePluginProgress() $toast.error(
t('plugin.updateFailed', {
name: props.plugin?.plugin_name,
message: t('common.serverConnectionFailed'),
}),
)
console.error(error) console.error(error)
} finally {
closePluginProgress()
} }
} }
@@ -362,7 +377,11 @@ async function showPluginAbout() {
count: props.count, count: props.count,
}, },
{ {
install: () => emit('save'), install: () => {
emit('save')
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
void pluginSidebarNavStore.ensureSidebarNav(true)
},
}, },
{ closeOn: ['close', 'install', 'update:modelValue'] }, { closeOn: ['close', 'install', 'update:modelValue'] },
) )
@@ -445,7 +464,7 @@ async function executePluginClone(cloneForm: {
try { try {
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name })) 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(), suffix: cloneForm.suffix.trim(),
name: cloneForm.name.trim(), name: cloneForm.name.trim(),
description: cloneForm.description.trim(), description: cloneForm.description.trim(),
@@ -453,21 +472,21 @@ async function executePluginClone(cloneForm: {
icon: cloneForm.icon.trim(), icon: cloneForm.icon.trim(),
}) })
closePluginProgress()
if (result.success) { if (result.success) {
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name })) $toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
cloneDialogController?.close() cloneDialogController?.close()
cloneDialogController = null cloneDialogController = null
// 通知父组件刷新
emit('remove') emit('remove')
// 生命周期成功后刷新动态导航。
void pluginSidebarNavStore.ensureSidebarNav(true)
} else { } else {
$toast.error(t('plugin.cloneFailed', { message: result.message })) $toast.error(t('plugin.cloneFailed', { message: result.message }))
} }
} catch (error) { } catch (error) {
closePluginProgress()
$toast.error(t('plugin.cloneFailedGeneral')) $toast.error(t('plugin.cloneFailedGeneral'))
console.error(error) console.error(error)
} finally {
closePluginProgress()
} }
} }
@@ -581,7 +600,7 @@ const dropdownItems = ref([
// 监听插件状态变化 // 监听插件状态变化
watch( watch(
() => props.plugin?.has_update, () => props.plugin?.has_update,
(newHasUpdate, _) => { newHasUpdate => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
@@ -593,7 +612,7 @@ watch(
// 监听插件窗口状态变化 // 监听插件窗口状态变化
watch( watch(
() => props.plugin?.page_open, () => props.plugin?.page_open,
(newOpenState, _) => { newOpenState => {
if (newOpenState) openPluginDetail() if (newOpenState) openPluginDetail()
}, },
{ immediate: true }, { immediate: true },
@@ -695,7 +714,7 @@ watch(
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon" /> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title" /> <VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>
@@ -1,10 +1,38 @@
import type { Plugin } from '@/api/types' import type { Plugin } from '@/api/types'
import PluginAppCard from '@/components/cards/PluginAppCard.vue' import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import { renderWithProviders } from '@tests/support/render' 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', () => ({ vi.mock('@/composables/useCardAccentColor', () => ({
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'), getCardAccentRgbFromImage: mocks.accentFromImage,
})) }))
const plugin: Plugin = { const plugin: Plugin = {
@@ -16,7 +44,32 @@ const plugin: Plugin = {
installed: false, 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', () => { 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 () => { it('shows the top-right score only after the plugin has ratings', async () => {
const unrated = await renderWithProviders(PluginAppCard, { const unrated = await renderWithProviders(PluginAppCard, {
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } }, props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
@@ -32,4 +85,175 @@ describe('PluginAppCard rating badge', () => {
expect(badge).toHaveTextContent('4.3') expect(badge).toHaveTextContent('4.3')
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull() 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 type { Plugin } from '@/api/types'
import PluginCard from '@/components/cards/PluginCard.vue' import PluginCard from '@/components/cards/PluginCard.vue'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue' import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 () => { 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 }, props: { plugin, count: 24 },
}) })
const sidebarStore = usePluginSidebarNavStore(pinia)
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn') const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
expect(menuButton).not.toBeNull() expect(menuButton).not.toBeNull()
@@ -76,5 +79,47 @@ describe('PluginCard about menu', () => {
repo_url: 'https://github.com/example/plugins', repo_url: 'https://github.com/example/plugins',
}) })
expect(dialogProps.count).toBe(24) 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 ratingLoading = ref(false)
const ratingSubmitting = ref(false) const ratingSubmitting = ref(false)
// 图片对象
const imageRef = ref<any>()
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
@@ -97,20 +94,16 @@ function visitPluginPage() {
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) { if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
repoUrl = props.plugin?.author_url repoUrl = props.plugin?.author_url
} }
if (repoUrl) { if (repoUrl?.includes('raw.githubusercontent.com')) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) repoUrl += '/'
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
try { try {
const [user, repo] = repoUrl.split('/').slice(-4, -2) const rawUrl = new URL(repoUrl)
repoUrl = `https://github.com/${user}/${repo}` const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
} catch (error) { if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
} catch {
return return
} }
} }
} else { if (!repoUrl) {
repoUrl = props.plugin?.author_url repoUrl = props.plugin?.author_url
} }
window.open(repoUrl, '_blank') window.open(repoUrl, '_blank')
@@ -136,6 +129,8 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!isConfirmed) return if (!isConfirmed) return
} }
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
try { try {
showInstallProgress( showInstallProgress(
isInstalled.value && !releaseVersion 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: { params: {
repo_url: repoUrl || props.plugin?.repo_url, repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion, release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion), force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
}, },
}) })
closeInstallProgress()
if (result.success) { if (result.success) {
$toast.success( $toast.success(
isInstalled.value isInstalled.value
@@ -167,11 +160,18 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
visible.value = false visible.value = false
emit('install') emit('install')
} else { } 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) { } catch (error) {
closeInstallProgress() $toast.error(
t(failureMessageKey, {
name: props.plugin?.plugin_name,
message: t('common.serverConnectionFailed'),
}),
)
console.error(error) 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="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5"> <div class="mx-auto mt-5">
<VAvatar size="64"> <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> </VAvatar>
</div> </div>
<div class="flex-grow"> <div class="flex-grow">
@@ -63,7 +63,9 @@ const resolvedHistory = computed(() => {
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0) 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 || []) const releaseItems = computed(() => releaseDetail.value?.items || [])
@@ -187,29 +189,48 @@ watch(
<div v-if="loading" class="plugin-version-history-dialog__loading"> <div v-if="loading" class="plugin-version-history-dialog__loading">
<VProgressCircular indeterminate color="primary" /> <VProgressCircular indeterminate color="primary" />
</div> </div>
<VCardText v-else-if="loadError && !hasHistory"> <template v-else>
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" /> <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>
<VCardText v-else-if="!hasHistory && !releaseLoading"> <VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" /> <VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText> </VCardText>
<template v-else>
<VCardText v-if="releaseError" class="pb-0">
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
</VCardText>
<VersionHistory <VersionHistory
v-if="hasHistory"
:history="resolvedHistory" :history="resolvedHistory"
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))" :has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
> >
<template #meta="{ version }"> <template #meta="{ version }">
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta"> <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) }} {{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
</span> </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') }} {{ t('plugin.latestVersion') }}
</VChip> </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') }} {{ t('plugin.currentVersion') }}
</VChip> </VChip>
</div> </div>
@@ -229,9 +250,7 @@ watch(
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))" @click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
> >
{{ {{
releaseItemByHistoryVersion(version)?.is_latest releaseItemByHistoryVersion(version)?.is_latest ? latestActionText : t('plugin.installReleaseVersion')
? latestActionText
: t('plugin.installReleaseVersion')
}} }}
</VBtn> </VBtn>
</template> </template>
@@ -248,11 +267,7 @@ watch(
class="mb-3" class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')" :text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/> />
<VBtn <VBtn @click="handleUpdate()" block :disabled="resolvedPlugin?.system_version_compatible === false">
@click="handleUpdate()"
block
:disabled="resolvedPlugin?.system_version_compatible === false"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" /> <VIcon icon="mdi-arrow-up-circle-outline" />
</template> </template>
@@ -289,5 +304,4 @@ watch(
font-size: 0.875rem; font-size: 0.875rem;
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
@@ -3,11 +3,16 @@ import type { Plugin, PluginRating } from '@/api/types'
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue' import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
import { renderWithProviders } from '@tests/support/render' import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue' import { fireEvent, screen, waitFor } from '@testing-library/vue'
import type { Stubs } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
apiGet: vi.fn(), apiGet: vi.fn(),
apiPost: vi.fn(), apiPost: vi.fn(),
confirm: vi.fn(),
dialogClose: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
toastSuccess: vi.fn(), toastSuccess: vi.fn(),
})) }))
@@ -23,6 +28,14 @@ vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }), 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 = { const basePlugin: Plugin = {
id: 'DemoPlugin', id: 'DemoPlugin',
plugin_name: '演示插件', plugin_name: '演示插件',
@@ -39,25 +52,45 @@ const ratingResult: PluginRating = {
user_rating: 4.0, 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, { return renderWithProviders(PluginMarketDetailDialog, {
props: { props: {
modelValue: true, modelValue: true,
plugin, plugin,
}, },
global: { components: { VDialogCloseBtn: DialogCloseBtn } }, global: {
components: { VDialogCloseBtn: DialogCloseBtn },
stubs,
},
}) })
} }
describe('PluginMarketDetailDialog', () => { describe('PluginMarketDetailDialog', () => {
beforeEach(() => { 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({ mocks.apiPost.mockReset().mockResolvedValue({
success: true, success: true,
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 }, 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.toastError.mockReset()
mocks.toastSuccess.mockReset() mocks.toastSuccess.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
}) })
it('shows install action and readonly rating for a market plugin', async () => { it('shows install action and readonly rating for a market plugin', async () => {
@@ -102,4 +135,214 @@ describe('PluginMarketDetailDialog', () => {
expect(screen.getByText('我的评分')).toBeInTheDocument() expect(screen.getByText('我的评分')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交评分' })).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)
})
})
+28
View File
@@ -370,6 +370,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/cards/SiteCard.vue', 'src/components/cards/SiteCard.vue',
'src/components/cards/DownloadingCard.vue', 'src/components/cards/DownloadingCard.vue',
'src/components/cards/PluginFolderCard.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/components/slide/VirtualSlideView.vue',
'src/views/discover/PersonCardSlideView.vue', 'src/views/discover/PersonCardSlideView.vue',
'src/views/reorganize/TransferHistoryView.vue', 'src/views/reorganize/TransferHistoryView.vue',
@@ -418,6 +422,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80, lines: 80,
statements: 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': { 'src/views/reorganize/FileBrowserView.vue': {
branches: 80, branches: 80,
functions: 85, functions: 85,