import type { Plugin, PluginRating, PluginRuntimeSummary, PluginSourceOptions } from '@/api/types' import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton' import PluginCardListView from '@/views/plugin/PluginCardListView.vue' import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav' import { usePluginRuntimeStore } from '@/stores/pluginRuntime' import { DEFAULT_PERMISSIONS } from '@/utils/permission' import { getActiveRequestsCount } from '@/utils/requestOptimizer' import { fireEvent, screen, waitFor, within } from '@testing-library/vue' import { server } from '@tests/support/msw/server' import { renderWithProviders } from '@tests/support/render' import { HttpResponse, http, type JsonBodyType } from 'msw' import { apiFailureJson, apiJson } from '@tests/support/msw/response' import { computed, defineComponent, h, nextTick, unref, type ComputedRef, type PropType, type Ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' const API_BASE_URL = 'http://localhost/api/v1/' const apiUrls = { folders: new URL('plugin/folders', API_BASE_URL).href, install: (pluginId: string) => new URL(`plugin/install/${pluginId}`, API_BASE_URL).href, list: new URL('plugin/', API_BASE_URL).href, order: new URL('user/config/PluginOrder', API_BASE_URL).href, rating: new URL('plugin/rating', API_BASE_URL).href, runtime: new URL('plugin/runtime', API_BASE_URL).href, sidebar: new URL('plugin/sidebar_nav', API_BASE_URL).href, sourceOptions: new URL('plugin/source/:pluginId/options', API_BASE_URL).href, statistic: new URL('plugin/statistic', API_BASE_URL).href, } const mocks = vi.hoisted(() => ({ appMode: false, keepAliveHandler: undefined as undefined | ((context?: Record) => Promise), infiniteDone: vi.fn(), openSharedDialog: vi.fn(), registerHeaderTab: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn(), toastWarning: vi.fn(), useDynamicButton: vi.fn(), })) vi.mock('vue-toastification', () => ({ useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess, warning: mocks.toastWarning }), })) vi.mock('@/composables/useDynamicButton', () => ({ useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), })) vi.mock('@/composables/useDynamicHeaderTab', () => ({ useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }), })) vi.mock('@/composables/useKeepAliveRefresh', () => ({ useKeepAliveRefresh: (handler: (context?: Record) => Promise) => { mocks.keepAliveHandler = handler return { refresh: handler } }, })) vi.mock('@/@core/utils/image', () => ({ extractDominantColor: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/composables/usePWA', () => ({ usePWA: () => ({ appMode: computed(() => mocks.appMode) }), })) vi.mock('@/composables/useSharedDialog', () => ({ openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), })) type MaybeRef = T | Ref | ComputedRef interface HeaderButton { action?: () => void | Promise dataAttr?: string icon: string } interface HeaderConfig { appendButtons: HeaderButton[] modelValue: Ref } interface DynamicButtonConfig { menuItems?: MaybeRef onClick: () => void } interface Deferred { promise: Promise resolve: (value: T) => void } function createDeferred(): Deferred { let resolve!: (value: T) => void const promise = new Promise(nextResolve => { resolve = nextResolve }) return { promise, resolve } } function createPlugin(overrides: Partial = {}): Plugin { return { id: 'DemoPlugin', plugin_name: '演示插件', plugin_author: 'MoviePilot', plugin_desc: '插件市场测试数据', plugin_label: '工具,整理', plugin_version: '1.0.0', repo_url: 'https://github.com/example/plugins', ...overrides, } } const PassthroughStub = defineComponent({ name: 'PassthroughStub', setup(_props, { slots }) { return () => h('div', slots.default?.()) }, }) const ProgressiveCardGridStub = defineComponent({ name: 'ProgressiveCardGrid', props: { getItemKey: { type: Function as PropType<(item: unknown) => string>, default: undefined }, items: { type: Array as PropType, required: true }, scrollToIndex: { type: Number, default: undefined }, }, setup(props, { slots }) { return () => h( 'section', { 'data-scroll-to-index': props.scrollToIndex ?? '' }, props.items.flatMap(item => { props.getItemKey?.(item) return slots.default?.({ item }) ?? [] }), ) }, }) const PluginMixedSortCardStub = defineComponent({ name: 'PluginMixedSortCard', props: { item: { type: Object as PropType>, required: true }, pluginStatistics: { type: Object as PropType>, default: () => ({}) }, runtimeSettling: Boolean, installing: Boolean, sortable: Boolean, }, emits: [ 'delete-folder', 'drop-to-folder', 'open-folder', 'rating', 'refresh-data', 'rename-folder', 'remove-from-folder', 'update-folder-config', ], setup(props, { emit }) { return () => { const item = props.item const type = String(item.type) const id = String(item.id) const data = item.data as | { average_rating?: number config?: { color?: string } has_update?: boolean plugin_name?: string repo_url?: string runtime_status?: Plugin['runtime_status'] } | undefined const name = type === 'folder' ? id : data?.plugin_name || id const dropTarget = document.createElement('div') dropTarget.classList.add('drag-over') return h('article', { 'data-testid': `${type}-${id}` }, [ h('span', `${type}:${name}`), h('output', { 'aria-label': `sortable-${id}` }, String(props.sortable)), type === 'plugin' ? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false)) : h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''), type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null, type === 'plugin' ? h('output', { 'aria-label': `runtime-${id}` }, data?.runtime_status || '') : null, type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : null, type === 'plugin' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null, type === 'plugin' ? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? '')) : null, type === 'plugin' ? h('output', { 'aria-label': `installed-rating-${id}` }, String(data?.average_rating ?? '')) : null, type === 'folder' ? h('button', { onClick: () => emit('open-folder', id), type: 'button' }, `open-folder-${id}`) : h('button', { onClick: () => emit('refresh-data'), type: 'button' }, `refresh-plugin-${id}`), type === 'folder' ? h( 'button', { onClick: () => emit('update-folder-config', id, { color: '#ff0000' }), type: 'button' }, `configure-folder-${id}`, ) : null, type === 'folder' ? h( 'button', { onClick: () => emit('rename-folder', id, `${id}-renamed`), type: 'button' }, `rename-folder-${id}`, ) : null, type === 'folder' ? h('button', { onClick: () => emit('delete-folder', id), type: 'button' }, `delete-folder-${id}`) : null, type === 'folder' ? h( 'button', { onClick: () => emit( 'drop-to-folder', { currentTarget: dropTarget, preventDefault: vi.fn(), stopPropagation: vi.fn() }, id, ), type: 'button', }, `drop-to-folder-${id}`, ) : null, type === 'plugin' ? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`) : null, type === 'plugin' ? h( 'button', { onClick: () => emit('rating', { average_rating: 4.7, plugin_id: id, rating_count: 10, user_rating: 5, } satisfies PluginRating), type: 'button', }, `rate-plugin-${id}`, ) : null, ]) } }, }) const PluginAppCardStub = defineComponent({ name: 'PluginAppCard', props: { plugin: { type: Object as PropType, required: true }, installHandler: Function as PropType<() => unknown>, }, emits: ['install'], setup(props, { emit }) { return () => h('article', { 'data-testid': `market-${props.plugin.id}` }, [ h('span', `market:${props.plugin.plugin_name}`), h('output', { 'aria-label': `rating-${props.plugin.id}` }, String(props.plugin.average_rating ?? '')), h( 'button', { onClick: () => (props.installHandler ? props.installHandler() : emit('install')), type: 'button', }, `installed-${props.plugin.id}`, ), ]) }, }) const DraggableStub = defineComponent({ name: 'Draggable', props: { modelValue: { type: Array as PropType, required: true } }, emits: ['end', 'start', 'update:modelValue'], setup(props, { emit, slots }) { async function reverse() { emit('update:modelValue', [...props.modelValue].reverse()) await nextTick() emit('end') } return () => h('section', [ ...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []), h('button', { onClick: reverse, type: 'button' }, 'reverse-plugin-order'), ...props.modelValue.map((_item, index) => h( 'button', { onClick: () => emit('start', { oldIndex: index }), type: 'button' }, `start-plugin-drag-${index}`, ), ), h('button', { onClick: () => emit('end'), type: 'button' }, 'finish-plugin-drag'), ]) }, }) const LoadingBannerStub = defineComponent({ name: 'LoadingBanner', template: '
正在加载插件
', }) const NoDataFoundStub = defineComponent({ name: 'NoDataFound', props: { errorDescription: String, errorTitle: String }, template: '
{{ errorTitle }} {{ errorDescription }}
', }) const InfiniteScrollStub = defineComponent({ name: 'VInfiniteScroll', emits: ['load'], setup(_props, { emit, slots }) { return () => h('section', [ slots.default?.(), h('button', { onClick: () => emit('load', { done: mocks.infiniteDone }), type: 'button' }, 'load-more-market'), ]) }, }) const FieldStub = defineComponent({ name: 'FieldStub', inheritAttrs: false, props: { label: String, modelValue: [String, Number], placeholder: String }, emits: ['keyup', 'update:modelValue'], setup(props, { emit }) { return () => h('input', { 'aria-label': props.label || props.placeholder, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), onKeyup: (event: KeyboardEvent) => emit('keyup', event), }) }, }) const SelectStub = defineComponent({ name: 'VSelect', props: { items: { type: Array as PropType, default: () => [] }, label: String, modelValue: { type: Array as PropType, default: () => [] }, }, emits: ['update:modelValue'], setup(props, { emit }) { return () => h('fieldset', { 'aria-label': props.label }, [ ...props.items.map(item => h( 'button', { onClick: () => emit( 'update:modelValue', props.modelValue.includes(item) ? props.modelValue.filter(value => value !== item) : [...props.modelValue, item], ), type: 'button', }, item, ), ), ]) }, }) interface ListResponses { folders?: () => JsonBodyType | Promise installed?: () => Plugin[] | Promise installedStatus?: number market?: () => Plugin[] | Promise marketStatus?: number order?: unknown[] rating?: (ids: string[]) => Record | Promise> runtime?: () => PluginRuntimeSummary | Promise sourceOptions?: (pluginId: string) => PluginSourceOptions | Promise statistic?: () => Record | Promise> } function registerListHandlers(responses: ListResponses = {}) { server.use( http.get(apiUrls.order, () => apiJson({ value: responses.order ?? [] })), http.get(apiUrls.folders, async () => apiJson((await responses.folders?.()) ?? {})), http.get(apiUrls.list, async ({ request }) => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.() return apiJson((plugins ?? []) as unknown as JsonBodyType, { status: state === 'installed' ? (responses.installedStatus ?? 200) : (responses.marketStatus ?? 200), }) }), http.get(apiUrls.statistic, async () => apiJson((await responses.statistic?.()) ?? {})), http.get(apiUrls.runtime, async () => apiJson( (await responses.runtime?.()) ?? { failed_count: 0, generation: 0, pending_count: 0, ready: true, }, ), ), http.get(apiUrls.sidebar, () => apiJson([])), http.get(apiUrls.sourceOptions, async ({ params }) => { const pluginId = String(params.pluginId) return apiJson( ((await responses.sourceOptions?.(pluginId)) ?? { plugin_id: pluginId, inventory_complete: true, selection_status: 'selected', selection_reason: '唯一在线来源', identity: null, candidates: [ { source_type: 'third_party', source_key: 'github:example/plugins', repo_url: 'https://github.com/example/plugins', package_generation: 'v3', plugin_version: '1.0.0', }, ], }) as unknown as JsonBodyType, ) }), http.get(apiUrls.rating, async ({ request }) => { const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? [] return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType) }), ) } async function renderList(responses: ListResponses = {}, options: { superUser?: boolean } = {}) { registerListHandlers(responses) return renderWithProviders(PluginCardListView, { initialRoute: '/plugins', initialState: { user: { permissions: DEFAULT_PERMISSIONS, superUser: options.superUser ?? true, }, }, global: { stubs: { LoadingBanner: LoadingBannerStub, NoDataFound: NoDataFoundStub, PluginAppCard: PluginAppCardStub, PluginMixedSortCard: PluginMixedSortCardStub, ProgressiveCardGrid: ProgressiveCardGridStub, VCombobox: FieldStub, VInfiniteScroll: InfiniteScrollStub, VMenu: PassthroughStub, VSelect: SelectStub, VTextField: FieldStub, VPageContentTitle: true, VWindow: PassthroughStub, VWindowItem: PassthroughStub, draggable: DraggableStub, }, }, }) } function getHeaderConfig() { return mocks.registerHeaderTab.mock.calls.at(-1)?.[0] as HeaderConfig } function getDynamicButtonConfig() { return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig } function getDynamicMenuItem(titleKey: string) { const item = unref(getDynamicButtonConfig().menuItems)?.find(candidate => candidate.titleKey === titleKey) if (!item) throw new Error(`未注册动态操作 ${titleKey}`) return item } function getHeaderButton(icon: string) { const button = getHeaderConfig().appendButtons.find(candidate => candidate.icon === icon) if (!button?.action) throw new Error(`未注册 header 操作 ${icon}`) return button } function getInstalledLabels() { return [...document.querySelectorAll('[data-testid^="folder-"], [data-testid^="plugin-"]')].map( node => node.querySelector('span')?.textContent, ) } function getDialogEvents(index = -1) { const call = mocks.openSharedDialog.mock.calls.at(index) if (!call) throw new Error('未打开共享弹窗') return call[2] as Record unknown> } function getDialogProps(index = -1) { const call = mocks.openSharedDialog.mock.calls.at(index) if (!call) throw new Error('未打开共享弹窗') return call[1] as { plugin?: Plugin; installHandler?: (...args: unknown[]) => unknown } } async function waitForRequestsToFinish() { await new Promise(resolve => setTimeout(resolve, 0)) await waitFor(() => expect(getActiveRequestsCount()).toBe(0)) await new Promise(resolve => setTimeout(resolve, 0)) expect(getActiveRequestsCount()).toBe(0) } describe('PluginCardListView loading and request ownership', () => { beforeEach(() => { mocks.appMode = false mocks.keepAliveHandler = undefined vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('keeps the newest forced market response when the older ordinary request finishes last', async () => { const older = createDeferred() const newer = createDeferred() let marketRequest = 0 await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], market: () => (marketRequest++ === 0 ? older.promise : newer.promise), }) await waitFor(() => expect(marketRequest).toBe(1)) await getHeaderConfig() .appendButtons.find(button => button.icon === 'mdi-refresh') ?.action?.() await waitFor(() => expect(marketRequest).toBe(2)) newer.resolve([createPlugin({ id: 'Newest', plugin_name: '最新市场插件' })]) expect(await screen.findByText('market:最新市场插件')).toBeInTheDocument() older.resolve([createPlugin({ id: 'Stale', plugin_name: '过期市场插件' })]) await waitForRequestsToFinish() expect(screen.queryByText('market:过期市场插件')).not.toBeInTheDocument() expect(screen.getByText('market:最新市场插件')).toBeInTheDocument() }) it('keeps the newest installed response when an older refresh finishes last', async () => { const older = createDeferred() const newer = createDeferred() let installedRequest = 0 await renderList({ installed: () => (installedRequest++ === 0 ? older.promise : newer.promise), }) await waitFor(() => expect(installedRequest).toBe(1)) if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') const newestRefresh = mocks.keepAliveHandler({ source: 'resume' }) await waitFor(() => expect(installedRequest).toBe(2)) newer.resolve([createPlugin({ id: 'NewestInstalled', installed: true, plugin_name: '最新已安装快照' })]) expect(await screen.findByText('plugin:最新已安装快照')).toBeInTheDocument() await newestRefresh older.resolve([createPlugin({ id: 'StaleInstalled', installed: true, plugin_name: '过期已安装快照' })]) await waitForRequestsToFinish() expect(screen.queryByText('plugin:过期已安装快照')).not.toBeInTheDocument() expect(screen.getByText('plugin:最新已安装快照')).toBeInTheDocument() }) it('keeps rating and statistic results from the newest refresh generation', async () => { const olderRatings = createDeferred>() const newerRatings = createDeferred>() const olderStatistics = createDeferred>() const newerStatistics = createDeferred>() let ratingRequest = 0 let statisticRequest = 0 await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], rating: () => { ratingRequest += 1 if (ratingRequest === 1) { return { Installed: { average_rating: 3, plugin_id: 'Installed', rating_count: 1 } } } return ratingRequest === 2 ? olderRatings.promise : newerRatings.promise }, statistic: () => { statisticRequest += 1 if (statisticRequest === 1) return { Installed: 1 } return statisticRequest === 2 ? olderStatistics.promise : newerStatistics.promise }, }) await waitForRequestsToFinish() if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') const olderRefresh = mocks.keepAliveHandler({ source: 'resume' }) await waitFor(() => { expect(ratingRequest).toBe(2) expect(statisticRequest).toBe(2) }) const newerRefresh = mocks.keepAliveHandler({ source: 'resume' }) await waitFor(() => { expect(ratingRequest).toBe(3) expect(statisticRequest).toBe(3) }) newerRatings.resolve({ Installed: { average_rating: 4.9, plugin_id: 'Installed', rating_count: 9 } }) newerStatistics.resolve({ Installed: 99 }) await newerRefresh expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.9') expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99') olderRatings.resolve({ Installed: { average_rating: 1, plugin_id: 'Installed', rating_count: 1 } }) olderStatistics.resolve({ Installed: 2 }) await olderRefresh await waitForRequestsToFinish() expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.9') expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99') }) it('updates only the submitted plugin from the POST result without reloading market metrics', async () => { let ratingRequest = 0 let statisticRequest = 0 await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], rating: () => { ratingRequest += 1 return { Installed: { average_rating: 3, plugin_id: 'Installed', rating_count: 1 } } }, statistic: () => { statisticRequest += 1 return { Installed: 24 } }, }) await waitForRequestsToFinish() expect(ratingRequest).toBe(1) expect(statisticRequest).toBe(1) await fireEvent.click(screen.getByRole('button', { name: 'rate-plugin-Installed' })) expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.7') expect(ratingRequest).toBe(1) expect(statisticRequest).toBe(1) }) it('keeps the previous rating visible while a tab refresh loads the next rating snapshot', async () => { const refreshedRatings = createDeferred>() let ratingRequest = 0 await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], rating: () => { ratingRequest += 1 if (ratingRequest === 1) { return { Installed: { average_rating: 4.2, plugin_id: 'Installed', rating_count: 3 } } } return refreshedRatings.promise }, }) await waitForRequestsToFinish() if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') const refresh = mocks.keepAliveHandler({ silent: true, source: 'tab' }) await waitFor(() => expect(ratingRequest).toBe(2)) expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.2') refreshedRatings.resolve({ Installed: { average_rating: 4.8, plugin_id: 'Installed', rating_count: 4 }, }) await refresh expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.8') }) it('rejects a rating snapshot when the current plugin ID set changes before the next rating request', async () => { const staleRatings = createDeferred>() const currentMarket = createDeferred() let installedRequest = 0 let marketRequest = 0 let ratingRequest = 0 await renderList({ installed: () => { installedRequest += 1 if (installedRequest === 1) { return [createPlugin({ average_rating: 4, id: 'Shared', installed: true, plugin_name: '共享插件' })] } if (installedRequest === 2) { return [ createPlugin({ average_rating: 4.2, id: 'Shared', installed: true, plugin_name: '共享插件' }), createPlugin({ id: 'Removed', installed: true, plugin_name: '即将移除' }), ] } return [ createPlugin({ average_rating: 4.6, id: 'Shared', installed: true, plugin_name: '共享插件' }), createPlugin({ id: 'Added', installed: true, plugin_name: '当前新增' }), ] }, market: () => { marketRequest += 1 return marketRequest === 3 ? currentMarket.promise : [] }, rating: () => { ratingRequest += 1 if (ratingRequest === 1) { return { Shared: { average_rating: 4, plugin_id: 'Shared', rating_count: 1 } } } if (ratingRequest === 2) return staleRatings.promise return { Shared: { average_rating: 4.9, plugin_id: 'Shared', rating_count: 9 } } }, }) await waitForRequestsToFinish() if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调') const staleRefresh = mocks.keepAliveHandler({ source: 'resume' }) await waitFor(() => expect(ratingRequest).toBe(2)) const currentRefresh = mocks.keepAliveHandler({ source: 'resume' }) expect(await screen.findByText('plugin:当前新增')).toBeInTheDocument() await waitFor(() => expect(marketRequest).toBe(3)) staleRatings.resolve({ Removed: { average_rating: 1, plugin_id: 'Removed', rating_count: 1 }, Shared: { average_rating: 1, plugin_id: 'Shared', rating_count: 1 }, }) await staleRefresh expect(screen.getByLabelText('installed-rating-Shared')).toHaveTextContent('4.6') currentMarket.resolve([]) await currentRefresh expect(screen.getByLabelText('installed-rating-Shared')).toHaveTextContent('4.9') await waitForRequestsToFinish() }) it('merges update metadata into installed cards and excludes update entries from the market list', async () => { await renderList({ installed: () => [ createPlugin({ id: 'Shared', installed: true, plugin_name: '已安装共享插件', repo_url: 'old/repo' }), ], market: () => [ createPlugin({ has_update: true, id: 'Shared', installed: true, plugin_name: '市场更新项', repo_url: 'new/repo', }), createPlugin({ id: 'Available', plugin_name: '可安装插件' }), ], }) await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true')) expect(screen.getByLabelText('repo-Shared')).toHaveTextContent('new/repo') expect(screen.queryByText('market:市场更新项')).not.toBeInTheDocument() expect(screen.getByText('market:可安装插件')).toBeInTheDocument() await waitForRequestsToFinish() }) it('clears the installed update marker after a successful update refresh removes the market entry', async () => { let marketRequest = 0 await renderList({ installed: () => [createPlugin({ id: 'Shared', installed: true, plugin_name: '已更新插件' })], market: () => { marketRequest += 1 return marketRequest === 1 ? [createPlugin({ has_update: true, id: 'Shared', installed: true, plugin_name: '待更新插件' })] : [] }, }) await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true')) await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' })) await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false')) expect(marketRequest).toBe(2) await waitForRequestsToFinish() }) it('keeps an initial installed failure retryable instead of presenting a successful empty list', async () => { await renderList({ installedStatus: 500 }) expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() await waitFor(() => expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument()) server.use( http.get(apiUrls.list, ({ request }) => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'installed' ? [createPlugin({ id: 'Recovered', installed: true, plugin_name: '重试恢复插件' })] : [] return apiJson(plugins as unknown as JsonBodyType) }), ) await fireEvent.click(screen.getByRole('button', { name: '重试' })) expect(await screen.findByText('plugin:重试恢复插件')).toBeInTheDocument() await waitFor(() => expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument()) await waitForRequestsToFinish() }) it('refreshes only the installed snapshot when the shared runtime generation changes', async () => { let installedRequests = 0 let marketRequests = 0 const { pinia } = await renderList({ installed: () => { installedRequests += 1 return [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })] }, market: () => { marketRequests += 1 return [createPlugin({ id: 'Market', plugin_name: '市场插件' })] }, }) await waitForRequestsToFinish() const initialInstalledRequests = installedRequests const initialMarketRequests = marketRequests const runtimeStore = usePluginRuntimeStore(pinia) runtimeStore.reconciliation = 1 await waitFor(() => expect(installedRequests).toBe(initialInstalledRequests + 1)) expect(marketRequests).toBe(initialMarketRequests) await waitForRequestsToFinish() }) it('applies a runtime generation received while the market tab is open', async () => { let installedRequests = 0 const { pinia } = await renderList({ installed: () => { installedRequests += 1 return [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })] }, market: () => [createPlugin({ id: 'Market', plugin_name: '市场插件' })], }) await waitForRequestsToFinish() const initialInstalledRequests = installedRequests const runtimeStore = usePluginRuntimeStore(pinia) getHeaderConfig().modelValue.value = 'market' await nextTick() runtimeStore.reconciliation = 1 await nextTick() expect(installedRequests).toBe(initialInstalledRequests) getHeaderConfig().modelValue.value = 'installed' await waitFor(() => expect(installedRequests).toBe(initialInstalledRequests + 1)) await waitForRequestsToFinish() }) it('does not request the superuser runtime summary for an ordinary administrator', async () => { server.use( http.get(apiUrls.runtime, () => { throw new Error('ordinary administrator must not request plugin runtime') }), ) await renderList({}, { superUser: false }) await waitForRequestsToFinish() }) it('leaves an initial market failure in a retryable error state instead of permanent loading', async () => { await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], marketStatus: 500, }) expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() await waitForRequestsToFinish() expect(screen.queryByText('正在加载插件')).not.toBeInTheDocument() server.use( http.get(apiUrls.list, ({ request }) => { const state = new URL(request.url).searchParams.get('state') const plugins = state === 'market' ? [createPlugin({ id: 'MarketRecovered', plugin_name: '市场重试恢复插件' })] : [] return apiJson(plugins as unknown as JsonBodyType) }), ) await fireEvent.click(screen.getByRole('button', { name: '重试' })) expect(await screen.findByText('market:市场重试恢复插件')).toBeInTheDocument() await waitForRequestsToFinish() }) it('batches ratings by 100 plugin IDs and merges the returned values into current cards', async () => { const requestedChunks: string[][] = [] const installed = Array.from({ length: 101 }, (_, index) => createPlugin({ id: `Installed-${index}`, installed: true, plugin_name: `已安装-${index}` }), ) const market = Array.from({ length: 101 }, (_, index) => createPlugin({ id: `Market-${index}`, plugin_name: `市场-${index}` }), ) await renderList({ installed: () => installed, market: () => market, rating: ids => { requestedChunks.push(ids) return Object.fromEntries( ids.map(id => [id, { average_rating: id === 'Market-0' ? 4.8 : 4, plugin_id: id, rating_count: 2 }]), ) }, }) await waitFor(() => expect(requestedChunks).toHaveLength(3)) expect(requestedChunks.map(chunk => chunk.length)).toEqual([100, 100, 2]) expect(await screen.findByLabelText('rating-Market-0')).toHaveTextContent('4.8') await waitForRequestsToFinish() }) }) describe('PluginCardListView market filtering and pagination', () => { beforeEach(() => { mocks.appMode = false mocks.keepAliveHandler = undefined vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('sorts market entries by rating descending when asynchronous ratings arrive', async () => { const ratings = createDeferred>() await renderList({ market: () => [ createPlugin({ id: 'Low', plugin_name: '低评分插件' }), createPlugin({ id: 'Unrated', plugin_name: '未评分插件' }), createPlugin({ id: 'High', plugin_name: '高评分插件' }), createPlugin({ id: 'Medium', plugin_name: '中评分插件' }), ], rating: () => ratings.promise, }) expect(await screen.findByText('market:低评分插件')).toBeInTheDocument() getHeaderConfig().modelValue.value = 'market' await nextTick() const marketFilterButton = getHeaderConfig().appendButtons.find(button => button.dataAttr === 'market-filter-btn') if (!marketFilterButton?.action) throw new Error('未注册市场过滤操作') marketFilterButton.action() await nextTick() await fireEvent.click(screen.getByText('评分')) ratings.resolve({ High: { average_rating: 4.8, plugin_id: 'High', rating_count: 12 }, Low: { average_rating: 2.1, plugin_id: 'Low', rating_count: 3 }, Medium: { average_rating: 3.6, plugin_id: 'Medium', rating_count: 6 }, }) await waitFor(() => { const labels = [...document.querySelectorAll('[data-testid^="market-"]')].map(node => node.textContent) expect(labels).toEqual([ expect.stringContaining('market:高评分插件'), expect.stringContaining('market:中评分插件'), expect.stringContaining('market:低评分插件'), expect.stringContaining('market:未评分插件'), ]) }) await waitForRequestsToFinish() }) it('filters and sorts market entries, labels local repos, and appends pages of 20', async () => { const market = Array.from({ length: 25 }, (_, index) => createPlugin({ add_time: index, id: `Market-${index}`, plugin_author: index % 2 === 0 ? 'Alice' : 'Bob', plugin_desc: index === 5 ? 'Contains Needle' : `描述 ${index}`, plugin_label: index % 3 === 0 ? '工具,整理' : '通知', plugin_name: index === 0 ? 'Zulu' : index === 24 ? 'Alpha' : `插件 ${String(index).padStart(2, '0')}`, repo_url: index === 0 ? 'local://repo?path=%2Ftmp%2Fplugins' : index === 1 ? 'local://repo' : 'https://github.com/example/repo', }), ) await renderList({ market: () => market, statistic: () => Object.fromEntries(market.map((plugin, index) => [plugin.id, 100 - index])), }) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20)) expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Zulu') await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' })) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(25)) expect(mocks.infiniteDone).toHaveBeenLastCalledWith('ok') await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' })) expect(mocks.infiniteDone).toHaveBeenLastCalledWith('empty') getHeaderConfig().modelValue.value = 'market' await nextTick() await waitForRequestsToFinish() const marketFilterButton = getHeaderConfig().appendButtons.find(button => button.dataAttr === 'market-filter-btn') if (!marketFilterButton?.action) throw new Error('未注册市场过滤操作') marketFilterButton.action() await nextTick() const initialRepository = screen.getByRole('group', { name: '仓库' }) expect(within(initialRepository).getByRole('button', { name: '本地' })).toBeInTheDocument() expect(within(initialRepository).queryByRole('button', { name: '/tmp/plugins' })).not.toBeInTheDocument() await fireEvent.click(screen.getByText('插件名称')) await waitFor(() => expect(document.querySelector('[data-testid^="market-"]')).toHaveTextContent('market:Alpha')) marketFilterButton.action() await nextTick() const nameInput = screen.getByRole('textbox', { name: '名称' }) await fireEvent.update(nameInput, 'needle') await fireEvent.keyUp(nameInput, { key: 'Enter' }) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(1)) expect(screen.getByText('market:插件 05')).toBeInTheDocument() marketFilterButton.action() await nextTick() const reopenedNameInput = screen.getByRole('textbox', { name: '名称' }) const repository = screen.getByRole('group', { name: '仓库' }) await fireEvent.update(reopenedNameInput, '') const author = screen.getByRole('group', { name: '作者' }) await fireEvent.click(within(author).getByRole('button', { name: 'Alice' })) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(13)) expect(screen.queryByText('market:插件 05')).not.toBeInTheDocument() await fireEvent.click(within(author).getByRole('button', { name: 'Alice' })) const label = screen.getByRole('group', { name: '标签' }) await fireEvent.click(within(label).getByRole('button', { name: '工具' })) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(9)) await fireEvent.click(within(label).getByRole('button', { name: '工具' })) await fireEvent.click(within(repository).getByRole('button', { name: '本地' })) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(2)) expect(screen.getByText('market:Zulu')).toBeInTheDocument() expect(screen.getByText('market:插件 01')).toBeInTheDocument() await waitForRequestsToFinish() }) it('preserves loaded market pages when switching tabs without refetching', async () => { const market = Array.from({ length: 45 }, (_, index) => createPlugin({ id: `Market-${index}`, plugin_name: `市场插件 ${index}`, repo_url: 'https://github.com/example/repo', }), ) let marketRequests = 0 await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], market: () => { marketRequests += 1 return market }, }) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20)) await fireEvent.click(screen.getByRole('button', { name: 'load-more-market' })) await waitFor(() => expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(40)) expect(marketRequests).toBe(1) getHeaderConfig().modelValue.value = 'installed' await nextTick() getHeaderConfig().modelValue.value = 'market' await nextTick() expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(40) expect(screen.getByText('market:市场插件 39')).toBeInTheDocument() expect(marketRequests).toBe(1) getHeaderButton('mdi-refresh').action?.() await waitFor(() => expect(marketRequests).toBe(2)) await waitForRequestsToFinish() expect(document.querySelectorAll('[data-testid^="market-"]')).toHaveLength(20) expect(screen.queryByText('market:市场插件 39')).not.toBeInTheDocument() }) it('restores each tab window scroll position after switching tabs', async () => { const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) let currentScrollTop = 0 vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => currentScrollTop) await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], market: () => Array.from({ length: 25 }, (_, index) => createPlugin({ id: `Market-${index}` })), }) await waitForRequestsToFinish() getHeaderConfig().modelValue.value = 'market' await nextTick() currentScrollTop = 1800 getHeaderConfig().modelValue.value = 'installed' await nextTick() currentScrollTop = 0 getHeaderConfig().modelValue.value = 'market' await nextTick() await waitFor(() => expect(scrollTo).toHaveBeenLastCalledWith({ behavior: 'auto', top: 1800 })) scrollTo.mockRestore() }) it('keeps the previous market snapshot until a manual refresh is complete', async () => { const refreshedMarket = createDeferred() const refreshedStatistics = createDeferred>() let marketRequests = 0 let statisticRequests = 0 await renderList({ market: () => { marketRequests += 1 return marketRequests === 1 ? [createPlugin({ id: 'BeforeRefresh', plugin_name: '刷新前插件' })] : refreshedMarket.promise }, statistic: () => { statisticRequests += 1 return statisticRequests === 1 ? {} : refreshedStatistics.promise }, }) expect(await screen.findByText('market:刷新前插件')).toBeInTheDocument() const refresh = getHeaderButton('mdi-refresh').action if (!refresh) throw new Error('未注册市场刷新操作') void refresh() await waitFor(() => expect(marketRequests).toBe(2)) refreshedMarket.resolve([createPlugin({ id: 'AfterRefresh', plugin_name: '刷新后插件' })]) await waitFor(() => expect(statisticRequests).toBe(2)) await nextTick() expect(screen.getByText('market:刷新前插件')).toBeInTheDocument() expect(screen.queryByText('market:刷新后插件')).not.toBeInTheDocument() refreshedStatistics.resolve({}) expect(await screen.findByText('market:刷新后插件')).toBeInTheDocument() expect(screen.queryByText('market:刷新前插件')).not.toBeInTheDocument() await waitForRequestsToFinish() }) it('keeps the previous market snapshot when a manual metric request fails', async () => { let marketRequests = 0 await renderList({ market: () => { marketRequests += 1 return marketRequests === 1 ? [createPlugin({ id: 'BeforeRefresh', plugin_name: '刷新前插件' })] : [createPlugin({ id: 'AfterRefresh', plugin_name: '刷新后插件' })] }, statistic: () => ({ BeforeRefresh: 10 }), }) expect(await screen.findByText('market:刷新前插件')).toBeInTheDocument() server.use(http.get(apiUrls.statistic, () => HttpResponse.json({ message: 'metrics failed' }, { status: 503 }))) const refresh = getHeaderButton('mdi-refresh').action if (!refresh) throw new Error('未注册市场刷新操作') await refresh() await waitForRequestsToFinish() expect(screen.getByText('market:刷新前插件')).toBeInTheDocument() expect(screen.queryByText('market:刷新后插件')).not.toBeInTheDocument() }) }) describe('PluginCardListView installed filtering and host callbacks', () => { beforeEach(() => { mocks.appMode = true mocks.keepAliveHandler = undefined mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('applies running, update, and name filters through the installed menu', async () => { await renderList({ installed: () => [ createPlugin({ has_update: true, id: 'Alpha', installed: true, plugin_name: 'Alpha', state: true }), createPlugin({ has_update: false, id: 'Beta', installed: true, plugin_name: 'Beta', state: true }), createPlugin({ has_update: true, id: 'Gamma', installed: true, plugin_name: 'Gamma', state: false }), ], market: () => [ createPlugin({ has_update: true, id: 'Alpha', installed: true, plugin_name: 'Alpha' }), createPlugin({ has_update: true, id: 'Gamma', installed: true, plugin_name: 'Gamma' }), ], }) await screen.findByText('plugin:Alpha') await waitForRequestsToFinish() const installedFilterButton = getHeaderConfig().appendButtons.find( button => button.dataAttr === 'installed-filter-btn', ) if (!installedFilterButton?.action) throw new Error('未注册已安装过滤操作') installedFilterButton.action() await nextTick() await fireEvent.click(screen.getByText('运行中')) await waitFor(() => expect(screen.queryByText('plugin:Gamma')).not.toBeInTheDocument()) expect(screen.getByText('plugin:Alpha')).toBeInTheDocument() expect(screen.getByText('plugin:Beta')).toBeInTheDocument() installedFilterButton.action() await nextTick() await fireEvent.click(screen.getByText('有新版本')) await waitFor(() => expect(screen.queryByText('plugin:Beta')).not.toBeInTheDocument()) expect(screen.getByText('plugin:Alpha')).toBeInTheDocument() installedFilterButton.action() await nextTick() await fireEvent.click(screen.getByText('运行中')) installedFilterButton.action() await nextTick() await fireEvent.click(screen.getByText('有新版本')) installedFilterButton.action() await nextTick() const nameInput = screen.getByRole('textbox', { name: '名称' }) await fireEvent.update(nameInput, 'bet') await fireEvent.keyUp(nameInput, { key: 'Enter' }) expect(screen.getByText('plugin:Beta')).toBeInTheDocument() expect(screen.queryByText('plugin:Alpha')).not.toBeInTheDocument() expect(screen.queryByText('plugin:Gamma')).not.toBeInTheDocument() }) it('updates search results and refreshes market and sidebar through public callbacks', async () => { let marketRequests = 0 const controller = { close: vi.fn(), id: 1, updateProps: vi.fn() } mocks.openSharedDialog.mockReturnValue(controller) const { pinia } = await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], market: () => { marketRequests += 1 return [createPlugin({ id: 'Available', plugin_desc: 'Needle', plugin_name: '可安装插件' })] }, }) await waitForRequestsToFinish() const sidebarStore = usePluginSidebarNavStore(pinia) getDynamicButtonConfig().onClick() const searchEvents = getDialogEvents() searchEvents['update:keyword']('needle') expect(controller.updateProps).toHaveBeenCalledWith( expect.objectContaining({ keyword: 'needle', plugins: [expect.objectContaining({ id: 'Available' })] }), ) getHeaderConfig().modelValue.value = 'market' await nextTick() await waitForRequestsToFinish() expect(marketRequests).toBe(1) getDynamicMenuItem('dialog.pluginMarketSetting.title').action() getDialogEvents().save() await waitFor(() => expect(marketRequests).toBe(2)) const requestsAfterSave = marketRequests getDynamicMenuItem('dialog.pluginMarketSetting.title').action() expect(mocks.openSharedDialog.mock.calls.at(-1)?.[3]).toEqual({ closeOn: ['close', 'save'] }) getDialogEvents().changed() await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave)) server.use(http.get(apiUrls.install('Available'), () => apiJson(null))) await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' })) await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)) await waitForRequestsToFinish() }) it('moves a market installation to the installed tab and targets its card', async () => { const installGate = createDeferred() let installed = false const target = createPlugin({ id: 'MarketInstall', plugin_name: '市场安装插件' }) await renderList({ installed: () => (installed ? [{ ...target, installed: true }] : []), market: () => [target], }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('MarketInstall'), async () => { await installGate.promise installed = true return apiJson(null) }), ) getHeaderConfig().modelValue.value = 'market' await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'installed-MarketInstall' })) await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed')) expect(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument() expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument() getHeaderConfig().modelValue.value = 'market' await nextTick() expect(screen.queryByTestId('market-MarketInstall')).not.toBeInTheDocument() installGate.resolve() await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 市场安装插件 安装成功!')) await waitForRequestsToFinish() await waitFor(() => expect(screen.queryByTestId('market-MarketInstall')).not.toBeInTheDocument()) }) it('restores a market plugin after an installation failure', async () => { const installGate = createDeferred() const target = createPlugin({ id: 'FailedMarketInstall', plugin_name: '失败市场插件' }) await renderList({ installed: () => [], market: () => [target] }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('FailedMarketInstall'), async () => { await installGate.promise return HttpResponse.json({ message: '安装失败' }, { status: 500 }) }), ) getHeaderConfig().modelValue.value = 'market' await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'installed-FailedMarketInstall' })) await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed')) getHeaderConfig().modelValue.value = 'market' await nextTick() expect(screen.queryByTestId('market-FailedMarketInstall')).not.toBeInTheDocument() installGate.resolve() await waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) await waitFor(() => expect(screen.getByTestId('market-FailedMarketInstall')).toBeInTheDocument()) }) }) describe('PluginCardListView search installation', () => { beforeEach(() => { mocks.appMode = false mocks.keepAliveHandler = undefined mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('opens the install detail before making a request and preserves install failures', async () => { let installRequests = 0 let mode: 'business' | 'http' = 'business' await renderList() await waitForRequestsToFinish() server.use( http.get(apiUrls.install('SearchPlugin'), () => { installRequests += 1 return mode === 'business' ? apiFailureJson('Rejected') : HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) }), ) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin']( createPlugin({ id: 'SearchPlugin', plugin_name: '不兼容插件', system_version_compatible: false, system_version_message: '版本不兼容', }), ) expect(getDialogProps().plugin).toMatchObject({ id: 'SearchPlugin', plugin_name: '不兼容插件' }) expect(installRequests).toBe(0) await getDialogProps().installHandler?.() expect(mocks.toastError).toHaveBeenLastCalledWith('版本不兼容') expect(installRequests).toBe(0) mocks.toastError.mockClear() getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: '业务失败插件' })) await getDialogProps().installHandler?.() await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(installRequests).toBe(1) expect(mocks.toastSuccess).not.toHaveBeenCalled() mocks.toastError.mockClear() mode = 'http' getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](createPlugin({ id: 'SearchPlugin', plugin_name: 'HTTP 失败插件' })) await getDialogProps().installHandler?.() await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(installRequests).toBe(2) expect(mocks.toastSuccess).not.toHaveBeenCalled() await waitForRequestsToFinish() }) it('uses the source-neutral install endpoint and refreshes the list and sidebar after success', async () => { let installed = false let installUrl: URL | undefined const target = createPlugin({ has_update: true, id: 'SearchPlugin', plugin_name: '搜索安装插件', repo_url: 'https://github.com/example/search-plugin', }) const { pinia } = await renderList({ installed: () => installed ? [createPlugin({ id: 'SearchPlugin', installed: true, plugin_name: '搜索安装插件' })] : [], market: () => [target], }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('SearchPlugin'), ({ request }) => { installUrl = new URL(request.url) installed = true return apiJson(null) }), ) const sidebarStore = usePluginSidebarNavStore(pinia) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) await getDialogProps().installHandler?.() expect(await screen.findByText('plugin:搜索安装插件')).toBeInTheDocument() await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)) expect(installUrl?.searchParams.has('repo_url')).toBe(false) expect(installUrl?.searchParams.get('force')).toBe('true') expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 搜索安装插件 安装成功!') await waitForRequestsToFinish() }) it('reuses the inspected source snapshot when installing from the detail dialog', async () => { let sourceOptionRequests = 0 let installRequests = 0 const target = createPlugin({ id: 'InspectedPlugin', plugin_name: '已检查来源插件' }) const sourceOptions: PluginSourceOptions = { plugin_id: 'InspectedPlugin', inventory_complete: true, selection_status: 'selected', selection_reason: '', identity: null, candidates: [ { source_type: 'official', source_key: 'github:jxxghp/moviepilot-plugins', repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins', package_generation: 'v3', plugin_version: '1.0.0', }, ], } await renderList({ market: () => [target], sourceOptions: () => { sourceOptionRequests += 1 return sourceOptions }, }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('InspectedPlugin'), () => { installRequests += 1 return apiJson(null) }), ) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) await getDialogProps().installHandler?.(undefined, undefined, sourceOptions) expect(sourceOptionRequests).toBe(0) expect(installRequests).toBe(1) expect(getHeaderConfig().modelValue.value).toBe('installed') await waitForRequestsToFinish() }) it('opens source selection instead of silently installing a conflicting plugin ID', async () => { let installRequests = 0 const target = createPlugin({ id: 'ConflictPlugin', plugin_name: '重名插件' }) await renderList({ market: () => [target], sourceOptions: pluginId => ({ plugin_id: pluginId, inventory_complete: true, selection_status: 'conflict', selection_reason: '未安装插件存在多个在线来源,不能静默选择', identity: null, candidates: [ { source_type: 'official', source_key: 'github:jxxghp/moviepilot-plugins', repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins', package_generation: 'v3', plugin_version: '1.0.0', }, { source_type: 'third_party', source_key: 'github:example/plugins', repo_url: 'https://github.com/example/plugins', package_generation: 'v3', plugin_version: '2.0.0', }, ], }), }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('ConflictPlugin'), () => { installRequests += 1 return apiJson(null) }), ) getHeaderConfig().modelValue.value = 'market' await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'installed-ConflictPlugin' })) await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalled()) expect(getDialogProps().plugin).toMatchObject({ id: 'ConflictPlugin' }) expect(installRequests).toBe(0) expect(getHeaderConfig().modelValue.value).toBe('market') }) it('shows a per-plugin loading card while installation is still running', async () => { const installGate = createDeferred() const installStarted = createDeferred() let installed = false const target = createPlugin({ id: 'PendingPlugin', plugin_name: '后台安装插件' }) await renderList({ installed: () => installed ? [ createPlugin({ id: 'PendingPlugin', installed: true, plugin_name: '后台安装插件', runtime_status: 'active', }), ] : [], market: () => [target], }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('PendingPlugin'), async () => { installed = true installStarted.resolve() await installGate.promise return apiJson(null) }), ) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) void getDialogProps().installHandler?.() expect(await screen.findByText('plugin:后台安装插件')).toBeInTheDocument() expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument() expect(screen.getByLabelText('runtime-PendingPlugin')).toBeEmptyDOMElement() expect(screen.getByLabelText('settling-PendingPlugin')).toHaveTextContent('true') expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('true') expect(mocks.toastSuccess).not.toHaveBeenCalled() await installStarted.promise await mocks.keepAliveHandler?.({ silent: true }) expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('true') expect(mocks.toastSuccess).not.toHaveBeenCalled() installGate.resolve() await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 后台安装插件 安装成功!')) await waitFor(() => expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('active')) await waitFor(() => expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('false')) await waitForRequestsToFinish() }) it('deduplicates concurrent installation requests for the same plugin', async () => { const installGate = createDeferred() let installRequests = 0 const target = createPlugin({ id: 'DuplicatePlugin', plugin_name: '重复安装插件' }) await renderList({ market: () => [target] }) await waitForRequestsToFinish() server.use( http.get(apiUrls.install('DuplicatePlugin'), async () => { installRequests += 1 await installGate.promise return apiJson(null) }), ) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) const install = getDialogProps().installHandler if (!install) throw new Error('未打开插件安装操作') void install() void install() await waitFor(() => expect(installRequests).toBe(1)) installGate.resolve() await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 重复安装插件 安装成功!')) await waitForRequestsToFinish() }) it('shows installation failure before a slow rollback refresh completes', async () => { const refreshGate = createDeferred() let firstInstalledRequest = true const target = createPlugin({ id: 'SlowRollbackPlugin', plugin_name: '慢回滚插件' }) await renderList({ installed: () => { if (firstInstalledRequest) { firstInstalledRequest = false return [] } return refreshGate.promise }, market: () => [target], }) await waitForRequestsToFinish() server.use(http.get(apiUrls.install('SlowRollbackPlugin'), () => apiFailureJson('依赖安装失败'))) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) void getDialogProps().installHandler?.() await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(getActiveRequestsCount()).toBeGreaterThan(0) refreshGate.resolve([]) await waitForRequestsToFinish() }) it('rolls back only the failed optimistic plugin card', async () => { const stable = createPlugin({ id: 'StablePlugin', installed: true, plugin_name: '稳定插件' }) const target = createPlugin({ id: 'FailedPlugin', plugin_name: '失败插件' }) await renderList({ installed: () => [stable], market: () => [target] }) await waitForRequestsToFinish() server.use(http.get(apiUrls.install('FailedPlugin'), () => apiFailureJson('依赖安装失败'))) getDynamicButtonConfig().onClick() await getDialogEvents()['open-plugin'](target) void getDialogProps().installHandler?.() await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(screen.getByText('plugin:稳定插件')).toBeInTheDocument() expect(screen.queryByText('plugin:失败插件')).not.toBeInTheDocument() await waitForRequestsToFinish() }) }) describe('PluginCardListView folders and persistence', () => { beforeEach(() => { mocks.appMode = false mocks.keepAliveHandler = undefined mocks.openSharedDialog.mockImplementation(() => ({ close: vi.fn(), id: 1, updateProps: vi.fn() })) vi.spyOn(console, 'error').mockImplementation(() => {}) }) it('normalizes legacy folders and mixed PluginOrder while preserving the configured order', async () => { await renderList({ folders: () => ({ Legacy: ['Installed-B'], Modern: { color: '#00ff00', order: 0, plugins: ['Installed-A'], showIcon: false }, }), installed: () => [ createPlugin({ id: 'Installed-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Installed-B', installed: true, plugin_name: '插件 B' }), createPlugin({ id: 'Installed-C', installed: true, plugin_name: '插件 C' }), ], order: [ { id: 'Modern', order: 0, type: 'folder' }, { id: 'Installed-C', order: 1, type: 'plugin' }, { id: 'Legacy', order: 2, type: 'folder' }, ], }) await waitFor(() => expect(screen.getByText('folder:Modern')).toBeInTheDocument()) const labels = [...document.querySelectorAll('[data-testid^="folder-"], [data-testid^="plugin-"]')].map( node => node.querySelector('span')?.textContent, ) expect(labels.slice(0, 3)).toEqual(['folder:Modern', 'plugin:插件 C', 'folder:Legacy']) expect(screen.queryByText('plugin:插件 A')).not.toBeInTheDocument() expect(screen.queryByText('plugin:插件 B')).not.toBeInTheDocument() await waitForRequestsToFinish() }) it('normalizes the legacy PluginOrder string array', async () => { await renderList({ installed: () => [ createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), ], order: ['Plugin-B', 'Plugin-A'], }) await screen.findByText('plugin:插件 B') expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A']) await waitForRequestsToFinish() }) it('rolls back a new folder and reports failure when persistence returns success false', async () => { mocks.appMode = true await renderList({ installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })], }) await screen.findByText('plugin:已安装插件') await waitForRequestsToFinish() server.use(http.post(apiUrls.folders, () => apiFailureJson('保存被拒绝'))) getDynamicMenuItem('plugin.newFolder').action() const events = getDialogEvents() events['update:name']('失败文件夹') await events.create() await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(mocks.toastSuccess).not.toHaveBeenCalled() expect(screen.queryByText('folder:失败文件夹')).not.toBeInTheDocument() await waitForRequestsToFinish() }) it('validates a new folder and persists a unique trimmed name', async () => { mocks.appMode = true await renderList({ folders: () => ({ Existing: [] }) }) await screen.findByText('folder:Existing') await waitForRequestsToFinish() server.use(http.post(apiUrls.folders, () => apiJson(null))) getDynamicMenuItem('plugin.newFolder').action() const events = getDialogEvents() events['update:name'](' ') await events.create() expect(mocks.toastError).toHaveBeenLastCalledWith('文件夹名称不能为空') events['update:name']('Existing') await events.create() expect(mocks.toastError).toHaveBeenLastCalledWith('文件夹已存在') events['update:name'](' New Folder ') await events.create() expect(await screen.findByText('folder:New Folder')).toBeInTheDocument() expect(mocks.toastSuccess).toHaveBeenLastCalledWith('文件夹创建成功') }) it('persists folder appearance, rename, and deletion on successful responses', async () => { await renderList({ folders: () => ({ Tools: { color: '#00ff00', order: 0, plugins: ['Installed'] } }), installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use(http.post(apiUrls.folders, () => apiJson(null))) await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' })) await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000')) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹设置已保存')) await fireEvent.click(screen.getByRole('button', { name: 'rename-folder-Tools' })) expect(await screen.findByText('folder:Tools-renamed')).toBeInTheDocument() await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹重命名成功')) await fireEvent.click(screen.getByRole('button', { name: 'delete-folder-Tools-renamed' })) await waitFor(() => expect(screen.queryByText('folder:Tools-renamed')).not.toBeInTheDocument()) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('文件夹删除成功')) expect(screen.getByText('plugin:文件夹插件')).toBeInTheDocument() }) it('rolls back appearance, rename, and deletion on business or HTTP failures', async () => { await renderList({ folders: () => ({ Tools: { color: '#00ff00', order: 0, plugins: ['Installed'] } }), installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() let saveAttempt = 0 server.use( http.post(apiUrls.folders, () => { saveAttempt += 1 return saveAttempt === 2 ? HttpResponse.json({ message: 'HTTP failure' }, { status: 500 }) : apiFailureJson('Rejected') }), ) await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#00ff00') await fireEvent.click(screen.getByRole('button', { name: 'rename-folder-Tools' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(2)) expect(screen.getByText('folder:Tools')).toBeInTheDocument() expect(screen.queryByText('folder:Tools-renamed')).not.toBeInTheDocument() await fireEvent.click(screen.getByRole('button', { name: 'delete-folder-Tools' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(3)) expect(screen.getByText('folder:Tools')).toBeInTheDocument() expect(mocks.toastSuccess).not.toHaveBeenCalled() }) it('rolls back a failed removal and persists a later removal from a folder', async () => { let saveSucceeds = false await renderList({ folders: () => ({ Tools: ['Installed'] }), installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '文件夹插件' })], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.post(apiUrls.folders, () => saveSucceeds ? apiJson(null) : HttpResponse.json({ message: 'Rejected' }, { status: 500 }), ), ) await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) expect(await screen.findByText('plugin:文件夹插件')).toBeInTheDocument() await fireEvent.click(screen.getByRole('button', { name: 'remove-plugin-Installed' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(screen.getByText('plugin:文件夹插件')).toBeInTheDocument() saveSucceeds = true await fireEvent.click(screen.getByRole('button', { name: 'remove-plugin-Installed' })) await waitFor(() => expect(screen.queryByText('plugin:文件夹插件')).not.toBeInTheDocument()) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件已移出文件夹')) }) it('rolls back mixed order failures and persists a later successful order', async () => { let orderSucceeds = false let savedOrder: unknown await renderList({ folders: () => ({ Tools: [] }), installed: () => [ createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), ], order: [ { id: 'Plugin-A', order: 0, type: 'plugin' }, { id: 'Tools', order: 1, type: 'folder' }, { id: 'Plugin-B', order: 2, type: 'plugin' }, ], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.post(apiUrls.order, async ({ request }) => { savedOrder = await request.json() return orderSucceeds ? apiJson(null) : apiFailureJson('Rejected') }), http.post(apiUrls.folders, () => apiJson(null)), ) getHeaderButton('mdi-sort-variant').action?.() await nextTick() expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 A', 'folder:Tools', 'plugin:插件 B']) await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 A', 'folder:Tools', 'plugin:插件 B']) orderSucceeds = true await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A']), ) expect(savedOrder).toEqual([ { id: 'Plugin-B', order: 0, type: 'plugin' }, { id: 'Tools', order: 1, type: 'folder' }, { id: 'Plugin-A', order: 2, type: 'plugin' }, ]) await waitForRequestsToFinish() }) it('reloads server ordering when folders fail after PluginOrder is persisted', async () => { let persistedOrder: unknown[] = [ { id: 'Plugin-A', order: 0, type: 'plugin' }, { id: 'Tools', order: 1, type: 'folder' }, { id: 'Plugin-B', order: 2, type: 'plugin' }, ] let folderReads = 0 let orderReads = 0 await renderList({ folders: () => ({ Tools: [] }), installed: () => [ createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), ], order: persistedOrder, }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.get(apiUrls.order, () => { orderReads += 1 return apiJson({ value: persistedOrder }) }), http.get(apiUrls.folders, () => { folderReads += 1 return apiJson({ Tools: [] }) }), http.post(apiUrls.order, async ({ request }) => { persistedOrder = (await request.json()) as unknown[] return apiJson(null) }), http.post(apiUrls.folders, () => apiFailureJson('Rejected')), ) getHeaderButton('mdi-sort-variant').action?.() await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(orderReads).toBe(1) expect(folderReads).toBe(1) expect(getInstalledLabels().slice(0, 3)).toEqual(['plugin:插件 B', 'folder:Tools', 'plugin:插件 A']) expect(persistedOrder).toEqual([ { id: 'Plugin-B', order: 0, type: 'plugin' }, { id: 'Tools', order: 1, type: 'folder' }, { id: 'Plugin-A', order: 2, type: 'plugin' }, ]) await waitForRequestsToFinish() }) it('reloads both ordering sources when an internal folder sort is only partially persisted', async () => { let persistedOrder: unknown[] = [ { id: 'Tools', order: 0, type: 'folder' }, { id: 'Plugin-A', order: 0.1, type: 'plugin' }, { id: 'Plugin-B', order: 0.2, type: 'plugin' }, ] let folderReads = 0 let orderReads = 0 await renderList({ folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }), installed: () => [ createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), ], order: persistedOrder, }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.get(apiUrls.order, () => { orderReads += 1 return apiJson({ value: persistedOrder }) }), http.get(apiUrls.folders, () => { folderReads += 1 return apiJson({ Tools: ['Plugin-A', 'Plugin-B'] }) }), http.post(apiUrls.order, async ({ request }) => { persistedOrder = (await request.json()) as unknown[] return apiJson(null) }), http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })), ) await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) getHeaderButton('mdi-sort-variant').action?.() await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(orderReads).toBe(1) expect(folderReads).toBe(1) expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) await waitForRequestsToFinish() }) it('rolls back a failed folder drop and persists a later successful drop', async () => { let folderSaveSucceeds = false await renderList({ folders: () => ({ Tools: [] }), installed: () => [createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' })], order: [ { id: 'Plugin-A', order: 0, type: 'plugin' }, { id: 'Tools', order: 1, type: 'folder' }, ], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.post(apiUrls.order, () => apiJson(null)), http.post(apiUrls.folders, () => (folderSaveSucceeds ? apiJson(null) : apiFailureJson('Rejected'))), ) getHeaderButton('mdi-sort-variant').action?.() await nextTick() await fireEvent.click(screen.getByRole('button', { name: 'start-plugin-drag-0' })) await fireEvent.click(screen.getByRole('button', { name: 'drop-to-folder-Tools' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) folderSaveSucceeds = true await fireEvent.click(screen.getByRole('button', { name: 'finish-plugin-drag' })) await waitFor(() => expect(screen.getByText('plugin:插件 A')).toBeInTheDocument()) await fireEvent.click(screen.getByRole('button', { name: 'start-plugin-drag-0' })) await fireEvent.click(screen.getByRole('button', { name: 'drop-to-folder-Tools' })) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件已移动到文件夹 "Tools"')) await fireEvent.click(screen.getByRole('button', { name: 'finish-plugin-drag' })) await waitForRequestsToFinish() await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) expect(await screen.findByText('plugin:插件 A')).toBeInTheDocument() }) it('rolls back folder-internal order and persists a later successful order', async () => { let orderSucceeds = false await renderList({ folders: () => ({ Tools: ['Plugin-A', 'Plugin-B'] }), installed: () => [ createPlugin({ id: 'Plugin-A', installed: true, plugin_name: '插件 A' }), createPlugin({ id: 'Plugin-B', installed: true, plugin_name: '插件 B' }), ], order: [ { id: 'Tools', order: 0, type: 'folder' }, { id: 'Plugin-A', order: 0.1, type: 'plugin' }, { id: 'Plugin-B', order: 0.2, type: 'plugin' }, ], }) await screen.findByText('folder:Tools') await waitForRequestsToFinish() server.use( http.post(apiUrls.order, () => (orderSucceeds ? apiJson(null) : apiFailureJson('Rejected'))), http.post(apiUrls.folders, () => apiJson(null)), ) await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' })) getHeaderButton('mdi-sort-variant').action?.() await nextTick() expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce()) expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 A', 'plugin:插件 B']) orderSucceeds = true await fireEvent.click(screen.getByRole('button', { name: 'reverse-plugin-order' })) await waitFor(() => expect(getInstalledLabels().slice(0, 2)).toEqual(['plugin:插件 B', 'plugin:插件 A'])) await waitForRequestsToFinish() getHeaderButton('mdi-arrow-left').action?.() expect(await screen.findByText('folder:Tools')).toBeInTheDocument() }) })