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
+21 -24
View File
@@ -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>
+50 -31
View File
@@ -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()
})
})