test(plugin): cover dynamic sidebar navigation (#629)

This commit is contained in:
InfinityPacer
2026-08-03 13:30:20 +08:00
committed by GitHub
parent b2c2cacc4c
commit 1298a5f72c
10 changed files with 774 additions and 79 deletions

View File

@@ -688,14 +688,6 @@
"count": 1
}
},
"src/layouts/default/components/DefaultLayout.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"vue/valid-v-for": {
"count": 5
}
},
"src/layouts/default/components/Footer.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 6
@@ -814,14 +806,6 @@
"count": 2
}
},
"src/stores/pluginSidebarNav.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"sonarjs/no-ignored-exceptions": {
"count": 1
}
},
"src/stores/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2

View File

@@ -142,6 +142,10 @@ interface DynamicHeaderTab {
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
type DynamicHeaderTabWindow = Window & {
__VUE_INJECT_DYNAMIC_HEADER_TAB__?: (tab: DynamicHeaderTab) => void
}
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(null)
@@ -179,7 +183,7 @@ const handleTabChange = (newValue: string) => {
// 添加全局注册方法,解决注入不可用的问题
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
;(window as DynamicHeaderTabWindow).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
}
// 提供给其他组件使用
@@ -233,7 +237,7 @@ onUnmounted(() => {
dynamicHeaderTab.value = null
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
delete (window as DynamicHeaderTabWindow).__VUE_INJECT_DYNAMIC_HEADER_TAB__
}
})
@@ -286,6 +290,8 @@ const getMenuList = (header: string) => {
return filteredMenus.filter((item: NavMenu) => item.header === header)
}
const getMenuIdentity = (item: NavMenu) => `${item.title}-${JSON.stringify(item.to ?? null)}`
/** 返回浏览历史中的上一页。 */
function goBack() {
history.back()
@@ -458,20 +464,29 @@ function appendPluginSidebarMenus() {
}
}
onMounted(async () => {
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
// 获取菜单列表
/** 从当前内置菜单、权限上下文与插件快照重建所有侧栏分组。 */
function rebuildSidebarMenus() {
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
subscribeMenus.value = getMenuList(t('menu.subscribe'))
organizeMenus.value = getMenuList(t('menu.organize'))
systemMenus.value = getMenuList(t('menu.system'))
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
}
let sidebarMenusMounted = false
watch([() => pluginSidebarNavStore.items, userPermissions], () => {
if (sidebarMenusMounted) rebuildSidebarMenus()
})
onMounted(async () => {
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
rebuildSidebarMenus()
sidebarMenusMounted = true
await pluginSidebarNavStore.ensureSidebarNav()
})
</script>
@@ -642,7 +657,7 @@ onMounted(async () => {
</template>
<template #vertical-nav-content>
<VerticalNavLink v-for="item in startMenus" :item="item" />
<VerticalNavLink v-for="item in startMenus" :key="`start-${getMenuIdentity(item)}`" :item="item" />
<!-- 👉 发现 -->
<VerticalNavSectionTitle
v-if="discoveryMenus.length > 0"
@@ -650,7 +665,7 @@ onMounted(async () => {
heading: t('menu.discovery'),
}"
/>
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
<VerticalNavLink v-for="item in discoveryMenus" :key="`discovery-${getMenuIdentity(item)}`" :item="item" />
<!-- 👉 订阅 -->
<VerticalNavSectionTitle
v-if="subscribeMenus.length > 0"
@@ -658,7 +673,7 @@ onMounted(async () => {
heading: t('menu.subscribe'),
}"
/>
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
<VerticalNavLink v-for="item in subscribeMenus" :key="`subscribe-${getMenuIdentity(item)}`" :item="item" />
<!-- 👉 整理 -->
<VerticalNavSectionTitle
v-if="organizeMenus.length > 0"
@@ -666,7 +681,7 @@ onMounted(async () => {
heading: t('menu.organize'),
}"
/>
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
<VerticalNavLink v-for="item in organizeMenus" :key="`organize-${getMenuIdentity(item)}`" :item="item" />
<!-- 👉 系统 -->
<VerticalNavSectionTitle
v-if="systemMenus.length > 0"
@@ -674,7 +689,7 @@ onMounted(async () => {
heading: t('menu.system'),
}"
/>
<VerticalNavLink v-for="item in systemMenus" :item="item" />
<VerticalNavLink v-for="item in systemMenus" :key="`system-${getMenuIdentity(item)}`" :item="item" />
</template>
<template #after-vertical-nav-items />

View File

@@ -1,18 +1,35 @@
import DefaultLayout from '@/layouts/default/components/DefaultLayout.vue'
import {
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
} from '@/composables/useThemeCustomizer'
import { THEME_CUSTOMIZER_CHANGE_EVENT, THEME_CUSTOMIZER_OPEN_EVENT } from '@/composables/useThemeCustomizer'
import { flushPromises, shallowMount } from '@vue/test-utils'
import type { PluginSidebarNavItem } from '@/api/types'
import { nextTick, type Component } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
interface SidebarStoreMock {
ensureSidebarNav: ReturnType<typeof vi.fn>
items: PluginSidebarNavItem[]
}
interface UserStoreMock {
permissions: Record<string, unknown>
superUser: boolean
}
const mocks = vi.hoisted(() => ({
emptyComponent: { template: '<div><slot /></div>' },
ensureSidebarNav: vi.fn(),
navLink: {
name: 'VerticalNavLink',
props: ['item'],
template: '<span data-testid="vertical-nav-link">{{ item.title }}</span>',
},
sidebarStore: undefined as SidebarStoreMock | undefined,
userStore: undefined as UserStoreMock | undefined,
verticalNavLayout: { template: '<div><slot name="vertical-nav-content" /></div>' },
}))
vi.mock('@layouts/components/VerticalNavLayout.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@layouts/components/VerticalNavLink.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@layouts/components/VerticalNavLayout.vue', () => ({ default: mocks.verticalNavLayout }))
vi.mock('@layouts/components/VerticalNavLink.vue', () => ({ default: mocks.navLink }))
vi.mock('@layouts/components/VerticalNavSectionTitle.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/components/agent/AgentAssistantWidget.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/components/misc/ThemeLogoMark.vue', () => ({ default: mocks.emptyComponent }))
@@ -26,24 +43,34 @@ vi.mock('@/layouts/default/components/ShortcutBar.vue', () => ({ default: mocks.
vi.mock('@/layouts/default/components/UserNotification.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/layouts/default/components/UserProfile.vue', () => ({ default: mocks.emptyComponent }))
vi.mock('@/stores', () => ({
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
usePluginSidebarNavStore: () => ({
vi.mock('@/stores', async () => {
const { reactive } = await import('vue')
mocks.sidebarStore = reactive({
ensureSidebarNav: mocks.ensureSidebarNav,
items: [],
}),
useUserStore: () => ({ permissions: [], superUser: true }),
}))
items: [] as PluginSidebarNavItem[],
})
mocks.userStore = reactive({
permissions: {
admin: false,
discovery: true,
features: {},
manage: false,
search: true,
subscribe: true,
},
superUser: false,
})
vi.mock('@/router/i18n-menu', () => ({ getNavMenus: () => [] }))
vi.mock('@/utils/pluginSidebarNav', () => ({ filterPluginSidebarNavEntries: () => [] }))
vi.mock('@/utils/permission', async importOriginal => ({
...(await importOriginal<typeof import('@/utils/permission')>()),
buildUserPermissionContext: () => ({}),
filterItemsByPermission: <T>(items: T[]) => items,
filterMenusByPermission: () => [],
hasItemPermission: () => true,
hasPermission: () => true,
return {
useGlobalSettingsStore: () => ({ get: vi.fn(() => false) }),
usePluginSidebarNavStore: () => mocks.sidebarStore,
useUserStore: () => mocks.userStore,
}
})
vi.mock('@/router/i18n-menu', () => ({
getNavMenus: () => [{ header: 'menu.start', title: 'Built-in dashboard', to: '/dashboard' }],
pluginSidebarSectionToHeaderKey: (section: string) => `menu.${section}`,
}))
vi.mock('@/composables/useOfflineStatus', () => ({
useGlobalOfflineStatus: () => ({ isOffline: { value: false } }),
@@ -82,6 +109,17 @@ vi.mock('vuetify', async importOriginal => ({
describe('DefaultLayout', () => {
beforeEach(() => {
mocks.ensureSidebarNav.mockReset()
mocks.ensureSidebarNav.mockResolvedValue(undefined)
mocks.sidebarStore!.items = []
mocks.userStore!.permissions = {
admin: false,
discovery: true,
features: {},
manage: false,
search: true,
subscribe: true,
}
mocks.userStore!.superUser = false
})
it('removes theme customizer listeners while sidebar loading is still pending', async () => {
@@ -96,14 +134,19 @@ describe('DefaultLayout', () => {
const removeEventListener = vi.spyOn(window, 'removeEventListener')
const wrapper = shallowMount(DefaultLayout, {
global: {
renderStubDefaultSlot: true,
stubs: {
IconBtn: mocks.emptyComponent,
RouterLink: mocks.emptyComponent,
VerticalNavLayout: mocks.verticalNavLayout,
VerticalNavLink: mocks.navLink,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Built-in dashboard')
const changeHandler = addEventListener.mock.calls.find(
([event]) => String(event) === THEME_CUSTOMIZER_CHANGE_EVENT,
)?.[1]
@@ -122,4 +165,89 @@ describe('DefaultLayout', () => {
resolveSidebarNav()
await flushPromises()
})
it('replaces plugin links when the shared snapshot refreshes after mount', async () => {
mocks.sidebarStore!.items = [
{
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'old',
section: 'system',
title: 'Old plugin',
},
]
const wrapper = shallowMount(DefaultLayout, {
global: {
renderStubDefaultSlot: true,
stubs: {
IconBtn: mocks.emptyComponent as Component,
RouterLink: mocks.emptyComponent as Component,
VerticalNavLayout: mocks.verticalNavLayout,
VerticalNavLink: mocks.navLink,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Old plugin')
mocks.sidebarStore!.items = [
{
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'new',
section: 'system',
title: 'New plugin',
},
]
await nextTick()
expect(wrapper.text()).toContain('New plugin')
expect(wrapper.text()).not.toContain('Old plugin')
})
it('rebuilds plugin links when permissions change after mount', async () => {
mocks.sidebarStore!.items = [
{
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'visible',
section: 'system',
title: 'Visible plugin',
},
{
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 2,
plugin_id: 'hidden',
section: 'system',
title: 'Hidden plugin',
},
]
const wrapper = shallowMount(DefaultLayout, {
global: {
renderStubDefaultSlot: true,
stubs: {
IconBtn: mocks.emptyComponent as Component,
RouterLink: mocks.emptyComponent as Component,
VerticalNavLayout: mocks.verticalNavLayout,
VerticalNavLink: mocks.navLink,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Visible plugin')
expect(wrapper.text()).toContain('Hidden plugin')
mocks.userStore!.permissions = {
...mocks.userStore!.permissions,
features: { 'plugin.hidden.main': false },
}
await nextTick()
expect(wrapper.text()).not.toContain('Hidden plugin')
})
})

View File

@@ -0,0 +1,124 @@
import AppCenter from '@/pages/appcenter.vue'
import type { PluginSidebarNavItem } from '@/api/types'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { useUserStore } from '@/stores/user'
import { screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { server } from '@tests/support/msw/server'
import { http, HttpResponse } from 'msw'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
const SIDEBAR_NAV_URL = 'http://localhost/api/v1/plugin/sidebar_nav'
const ListItemStub = defineComponent({
name: 'VListItem',
setup(_, { slots }) {
return () => h('div', [slots.prepend?.(), slots.default?.(), slots.append?.()])
},
})
function createNavItem(overrides: Partial<PluginSidebarNavItem> = {}): PluginSidebarNavItem {
return {
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
...overrides,
}
}
function sidebarNavHandler(items: PluginSidebarNavItem[]) {
return http.get(SIDEBAR_NAV_URL, () => HttpResponse.json(items))
}
async function renderAppCenter(items: PluginSidebarNavItem[], permissions: Record<string, unknown> = {}) {
server.use(sidebarNavHandler(items))
return renderWithProviders(AppCenter, {
global: {
stubs: {
VListItem: ListItemStub,
},
},
initialRoute: '/apps',
initialState: {
user: {
permissions: {
admin: false,
discovery: true,
features: {},
manage: false,
search: true,
subscribe: true,
...permissions,
},
superUser: false,
},
},
stubActions: false,
})
}
describe('app center plugin navigation', () => {
it('uses the shared category, admin and feature permission semantics', async () => {
await renderAppCenter(
[
createNavItem({ permission: null, plugin_id: 'open', title: 'Open plugin' }),
createNavItem({ permission: 'manage', plugin_id: 'category-denied', title: 'Category denied plugin' }),
createNavItem({ permission: 'admin', plugin_id: 'admin-denied', title: 'Admin denied plugin' }),
createNavItem({ permission: 'discovery', plugin_id: 'feature-denied', title: 'Feature denied plugin' }),
createNavItem({ permission: 'discovery', plugin_id: 'allowed', title: 'Allowed plugin' }),
],
{
admin: true,
discovery: true,
features: {
'plugin.allowed.main': true,
'plugin.feature-denied.main': false,
},
manage: false,
},
)
expect(await screen.findByText('Open plugin')).toBeInTheDocument()
expect(screen.getByText('Allowed plugin')).toBeInTheDocument()
expect(screen.queryByText('Category denied plugin')).not.toBeInTheDocument()
expect(screen.queryByText('Admin denied plugin')).not.toBeInTheDocument()
expect(screen.queryByText('Feature denied plugin')).not.toBeInTheDocument()
const userStore = useUserStore()
userStore.setPermissions({
admin: true,
discovery: true,
features: {
'plugin.allowed.main': true,
'plugin.feature-denied.main': true,
},
manage: true,
search: true,
subscribe: true,
})
userStore.setSuperUser(true)
await waitFor(() => {
expect(screen.getByText('Category denied plugin')).toBeInTheDocument()
expect(screen.getByText('Admin denied plugin')).toBeInTheDocument()
expect(screen.getByText('Feature denied plugin')).toBeInTheDocument()
})
})
it('updates an already mounted consumer after the shared snapshot is force-refreshed', async () => {
await renderAppCenter([createNavItem({ plugin_id: 'old', title: 'Old plugin' })])
expect(await screen.findByText('Old plugin')).toBeInTheDocument()
server.use(sidebarNavHandler([createNavItem({ plugin_id: 'new', title: 'New plugin' })]))
await usePluginSidebarNavStore().ensureSidebarNav(true)
await waitFor(() => {
expect(screen.getByText('New plugin')).toBeInTheDocument()
expect(screen.queryByText('Old plugin')).not.toBeInTheDocument()
})
})
})

View File

@@ -18,19 +18,16 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
// 应用分组以header分组
const appGroups = ref<Record<string, NavMenu[]>>({})
/** 按菜单 header 聚合内置与插件入口,并保持与桌面侧栏一致的权限过滤结果。 */
async function categorizeApps() {
/** 按菜单 header 聚合当前内置与插件入口,并保持与桌面侧栏一致的权限过滤结果。 */
function categorizeApps() {
const allMenus = getNavMenus(t)
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
let menus = filteredMenus.filter((item: NavMenu) => !item.footer)
await pluginSidebarNavStore.ensureSidebarNav()
if (pluginSidebarNavStore.items.length > 0) {
const pluginNavMenus = filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
t,
userPermissions.value,
).map(e => e.navMenu)
const pluginNavMenus = filterPluginSidebarNavEntries(pluginSidebarNavStore.items, t, userPermissions.value).map(
e => e.navMenu,
)
menus = [...menus, ...pluginNavMenus]
}
@@ -47,8 +44,15 @@ async function categorizeApps() {
appGroups.value = groupedMenus
}
onMounted(() => {
let appGroupsMounted = false
watch([() => pluginSidebarNavStore.items, userPermissions], () => {
if (appGroupsMounted) categorizeApps()
})
onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav()
categorizeApps()
appGroupsMounted = true
})
</script>
<template>
@@ -94,11 +98,7 @@ onMounted(() => {
<VIcon class="settings-list-chevron" icon="mdi-chevron-right"></VIcon>
</template>
<span
v-if="appIndex < apps.length - 1"
class="settings-list-separator"
aria-hidden="true"
></span>
<span v-if="appIndex < apps.length - 1" class="settings-list-separator" aria-hidden="true"></span>
</VListItem>
</VList>
</VCard>
@@ -130,7 +130,10 @@ onMounted(() => {
backdrop-filter: blur(10px);
background-color: rgb(var(--v-theme-surface));
box-shadow: var(--app-surface-shadow);
transition: border-color 0.2s ease, border-width 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
border-width 0.2s ease,
box-shadow 0.2s ease;
}
.settings-list {

View File

@@ -0,0 +1,146 @@
import type { PluginSidebarNavItem } from '@/api/types'
import '@/router'
import { useAuthStore } from '@/stores/auth'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { useUserStore } from '@/stores/user'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type NavigationGuard = (
to: Record<string, unknown>,
from: Record<string, unknown>,
next: ReturnType<typeof vi.fn>,
) => Promise<void>
const routerMocks = vi.hoisted(() => ({
guard: undefined as NavigationGuard | undefined,
next: vi.fn(),
push: vi.fn(),
setRequestNavigatingState: vi.fn(),
}))
vi.mock('vue-router', () => ({
createRouter: () => ({
afterEach: vi.fn(),
beforeEach: (guard: NavigationGuard) => {
routerMocks.guard = guard
},
push: routerMocks.push,
}),
createWebHashHistory: vi.fn(),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
}))
vi.mock('@/utils/requestOptimizer', () => ({
abortAllRequests: vi.fn(),
initializeRequestOptimizer: vi.fn(),
setNavigatingState: routerMocks.setRequestNavigatingState,
}))
function createNavItem(overrides: Partial<PluginSidebarNavItem> = {}): PluginSidebarNavItem {
return {
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
...overrides,
}
}
async function runGuard(path: string, pluginId: string, navKey: string) {
await routerMocks.guard?.(
{
fullPath: path,
meta: { requiresAuth: true },
name: 'plugin-app',
params: { navKey, pluginId },
path,
},
{},
routerMocks.next,
)
}
describe('plugin sidebar route permission', () => {
beforeEach(() => {
setActivePinia(createPinia())
useAuthStore().login({ remember: false, token: 'test-token' })
routerMocks.next.mockReset()
})
it('matches menu feature denial and preserves undeclared deep-link compatibility', async () => {
const userStore = useUserStore()
const sidebarStore = usePluginSidebarNavStore()
userStore.setPermissions({
discovery: true,
features: { 'plugin.demo.settings': false },
})
sidebarStore.$patch({
items: [createNavItem({ nav_key: 'settings', permission: 'discovery' })],
loaded: true,
})
await runGuard('/plugin-app/demo/settings', 'demo', 'settings')
expect(routerMocks.next).toHaveBeenLastCalledWith('/apps')
routerMocks.next.mockClear()
sidebarStore.$patch({
items: [createNavItem({ nav_key: 'settings', permission: 'manage' })],
loaded: true,
})
userStore.setPermissions({
discovery: true,
features: { 'plugin.demo.settings': true },
manage: false,
search: true,
subscribe: true,
})
await runGuard('/plugin-app/demo/settings', 'demo', 'settings')
expect(routerMocks.next).toHaveBeenLastCalledWith('/apps')
routerMocks.next.mockClear()
sidebarStore.$patch({
items: [createNavItem({ nav_key: 'settings', permission: 'admin' })],
loaded: true,
})
userStore.setPermissions({
admin: true,
discovery: true,
features: { 'plugin.demo.settings': true },
manage: true,
search: true,
subscribe: true,
})
userStore.setSuperUser(false)
await runGuard('/plugin-app/demo/settings', 'demo', 'settings')
expect(routerMocks.next).toHaveBeenLastCalledWith('/apps')
routerMocks.next.mockClear()
userStore.setSuperUser(true)
await runGuard('/plugin-app/demo/settings', 'demo', 'settings')
expect(routerMocks.next).toHaveBeenLastCalledWith()
routerMocks.next.mockClear()
userStore.setSuperUser(false)
sidebarStore.$patch({
items: [createNavItem({ nav_key: 'settings', permission: 'discovery' })],
loaded: true,
})
userStore.setPermissions({
discovery: true,
features: { 'plugin.demo.settings': true },
})
await runGuard('/plugin-app/demo/settings', 'demo', 'settings')
expect(routerMocks.next).toHaveBeenLastCalledWith()
routerMocks.next.mockClear()
sidebarStore.$patch({ items: [], loaded: true })
await runGuard('/plugin-app/undeclared/details', 'undeclared', 'details')
expect(routerMocks.next).toHaveBeenLastCalledWith()
})
})

View File

@@ -0,0 +1,178 @@
import type { PluginSidebarNavItem } from '@/api/types'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
get: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: apiMocks.get,
},
}))
function createNavItem(overrides: Partial<PluginSidebarNavItem> = {}): PluginSidebarNavItem {
return {
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
...overrides,
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('plugin sidebar navigation store', () => {
beforeEach(() => {
setActivePinia(createPinia())
apiMocks.get.mockReset()
})
it('shares one request between concurrent callers and caches an empty successful snapshot', async () => {
const response = deferred<PluginSidebarNavItem[]>()
apiMocks.get.mockReturnValueOnce(response.promise)
const store = usePluginSidebarNavStore()
const first = store.ensureSidebarNav()
const second = store.ensureSidebarNav()
expect(apiMocks.get).toHaveBeenCalledTimes(1)
expect(apiMocks.get).toHaveBeenCalledWith('plugin/sidebar_nav')
response.resolve([])
await Promise.all([first, second])
expect(store.items).toEqual([])
expect(store.loaded).toBe(true)
expect(store.inflight).toBeNull()
await store.ensureSidebarNav()
expect(apiMocks.get).toHaveBeenCalledTimes(1)
})
it('permanently ignores a response from before reset when a new generation is loading', async () => {
const staleResponse = deferred<PluginSidebarNavItem[]>()
const currentResponse = deferred<PluginSidebarNavItem[]>()
apiMocks.get.mockReturnValueOnce(staleResponse.promise).mockReturnValueOnce(currentResponse.promise)
const store = usePluginSidebarNavStore()
const staleRequest = store.ensureSidebarNav()
store.reset()
const currentRequest = store.ensureSidebarNav()
expect(apiMocks.get).toHaveBeenCalledTimes(2)
staleResponse.resolve([createNavItem({ plugin_id: 'stale', title: 'Stale' })])
await staleRequest
expect(store.items).toEqual([])
expect(store.loaded).toBe(false)
currentResponse.resolve([createNavItem({ plugin_id: 'current', title: 'Current' })])
await currentRequest
expect(store.items.map(item => item.plugin_id)).toEqual(['current'])
expect(store.loaded).toBe(true)
})
it('does not retry an old failure after reset interrupts its retry delay', async () => {
vi.useFakeTimers()
const staleResponse = deferred<PluginSidebarNavItem[]>()
const currentResponse = deferred<PluginSidebarNavItem[]>()
apiMocks.get.mockReturnValueOnce(staleResponse.promise).mockReturnValueOnce(currentResponse.promise)
const store = usePluginSidebarNavStore()
const staleRequest = store.ensureSidebarNav()
staleResponse.reject(new Error('stale request'))
await vi.advanceTimersByTimeAsync(0)
store.reset()
const currentRequest = store.ensureSidebarNav()
await vi.advanceTimersByTimeAsync(500)
await staleRequest
expect(apiMocks.get).toHaveBeenCalledTimes(2)
currentResponse.resolve([createNavItem({ plugin_id: 'current', title: 'Current' })])
await currentRequest
expect(store.items.map(item => item.plugin_id)).toEqual(['current'])
expect(store.loaded).toBe(true)
})
it('starts a new generation when force refresh overlaps an older inflight request', async () => {
const staleResponse = deferred<PluginSidebarNavItem[]>()
const forcedResponse = deferred<PluginSidebarNavItem[]>()
apiMocks.get.mockReturnValueOnce(staleResponse.promise).mockReturnValueOnce(forcedResponse.promise)
const store = usePluginSidebarNavStore()
const staleRequest = store.ensureSidebarNav()
const forcedRequest = store.ensureSidebarNav(true)
expect(apiMocks.get).toHaveBeenCalledTimes(2)
forcedResponse.resolve([createNavItem({ plugin_id: 'fresh', title: 'Fresh' })])
await forcedRequest
staleResponse.resolve([createNavItem({ plugin_id: 'stale', title: 'Stale' })])
await staleRequest
expect(store.items.map(item => item.plugin_id)).toEqual(['fresh'])
expect(store.loaded).toBe(true)
expect(store.inflight).toBeNull()
})
it('preserves the last successful snapshot after force failure and remains retryable', async () => {
const previous = [createNavItem({ plugin_id: 'previous', title: 'Previous' })]
apiMocks.get.mockResolvedValueOnce(previous)
const store = usePluginSidebarNavStore()
await store.ensureSidebarNav()
vi.useFakeTimers()
apiMocks.get.mockRejectedValueOnce(new Error('temporary')).mockRejectedValueOnce(new Error('still unavailable'))
const failedRefresh = store.ensureSidebarNav(true)
await vi.advanceTimersByTimeAsync(500)
await failedRefresh
expect(store.items).toEqual(previous)
expect(store.loaded).toBe(true)
expect(store.inflight).toBeNull()
const recovered = [createNavItem({ plugin_id: 'recovered', title: 'Recovered' })]
apiMocks.get.mockResolvedValueOnce(recovered)
await store.ensureSidebarNav(true)
expect(store.items).toEqual(recovered)
expect(store.loaded).toBe(true)
})
it('does not cache an initial HTTP failure and retries on a later ensure', async () => {
vi.useFakeTimers()
apiMocks.get.mockRejectedValueOnce(new Error('temporary')).mockRejectedValueOnce(new Error('still unavailable'))
const store = usePluginSidebarNavStore()
const failedRequest = store.ensureSidebarNav()
await vi.advanceTimersByTimeAsync(500)
await failedRequest
expect(store.items).toEqual([])
expect(store.loaded).toBe(false)
expect(store.inflight).toBeNull()
apiMocks.get.mockResolvedValueOnce([createNavItem({ plugin_id: 'retry' })])
await store.ensureSidebarNav()
expect(apiMocks.get).toHaveBeenCalledTimes(3)
expect(store.items.map(item => item.plugin_id)).toEqual(['retry'])
})
})

View File

@@ -10,51 +10,55 @@ export const usePluginSidebarNavStore = defineStore('pluginSidebarNav', {
items: [] as PluginSidebarNavItem[],
/** 是否已成功拉取过一次(含空数组) */
loaded: false,
/** 并发去重:同一时刻只进行一次请求 */
/** 当前可提交代际的请求;普通 ensure 复用force 会启动新代 */
inflight: null as Promise<void> | null,
/** 请求代际reset 或 force 会使更早请求永久失去提交资格 */
generation: 0,
}),
actions: {
/**
* 确保侧栏导航数据已加载;已缓存则直接返回,并发调用共享同一请求。
* @param force 为 true 时忽略缓存重新请求(如登出后再登录可配合 reset + ensure
* 确保侧栏导航数据已加载;已缓存则直接返回,同代普通调用共享请求。
* @param force 为 true 时忽略缓存并立即启动新代请求
*/
async ensureSidebarNav(force = false): Promise<void> {
if (!force && this.loaded) {
return
}
if (this.inflight) {
if (!force && this.inflight) {
return this.inflight
}
this.inflight = this._doFetchSidebarNav()
const generation = ++this.generation
this.inflight = this._doFetchSidebarNav(generation)
return this.inflight
},
async _doFetchSidebarNav(): Promise<void> {
async _doFetchSidebarNav(generation: number): Promise<void> {
const maxRetries = 1
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await api.get('plugin/sidebar_nav')
if (!this.inflight) return
if (generation !== this.generation) return
this.items = Array.isArray(res) ? res : []
this.loaded = true
this.inflight = null
return
} catch (e) {
} catch {
if (generation !== this.generation) return
if (attempt < maxRetries) {
// 短暂延迟后重试,应对登录后导航过渡期的请求中断
await new Promise(resolve => setTimeout(resolve, 500))
if (!this.inflight) return
if (generation !== this.generation) return
}
}
}
// 重试全部失败,不缓存失败状态以允许后续调用方再次尝试
if (!this.inflight) return
this.items = []
if (generation !== this.generation) return
this.inflight = null
},
reset() {
this.generation++
this.items = []
this.loaded = false
this.inflight = null

View File

@@ -0,0 +1,92 @@
import type { PluginSidebarNavItem } from '@/api/types'
import { filterPluginSidebarNavEntries, navMenuFromPluginSidebarItem } from '@/utils/pluginSidebarNav'
import type { Composer } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
const t = ((key: string) => key) as Composer['t']
function createNavItem(overrides: Partial<PluginSidebarNavItem> = {}): PluginSidebarNavItem {
return {
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
...overrides,
}
}
describe('plugin sidebar navigation utilities', () => {
it('projects stable route params, permission and feature keys into a shared menu entry', () => {
const menu = navMenuFromPluginSidebarItem(
createNavItem({ nav_key: 'settings', permission: 'manage', plugin_id: 'workflow' }),
t,
)
expect(menu).toMatchObject({
feature: 'plugin.workflow.settings',
header: 'menu.system',
permission: 'manage',
to: {
name: 'plugin-app',
params: { navKey: 'settings', pluginId: 'workflow' },
},
})
})
it('defaults an omitted section to system without adding a category permission', () => {
const item = {
icon: 'mdi-puzzle-outline',
nav_key: 'main',
order: 1,
permission: null,
plugin_id: 'demo',
title: 'Demo',
} satisfies Omit<PluginSidebarNavItem, 'section'>
const [entry] = filterPluginSidebarNavEntries([item as PluginSidebarNavItem], t, {
features: {},
})
expect(entry.section).toBe('system')
expect(entry.navMenu.header).toBe('menu.system')
expect(entry.navMenu.permission).toBeUndefined()
})
it('applies category, admin and feature denials through the shared permission filter', () => {
const entries = filterPluginSidebarNavEntries(
[
createNavItem({ permission: null, plugin_id: 'open', title: 'Open' }),
createNavItem({ permission: 'manage', plugin_id: 'category-denied', title: 'Category denied' }),
createNavItem({ permission: 'admin', plugin_id: 'admin-denied', title: 'Admin denied' }),
createNavItem({ permission: 'discovery', plugin_id: 'feature-denied', title: 'Feature denied' }),
createNavItem({ permission: 'discovery', plugin_id: 'allowed', title: 'Allowed' }),
],
t,
{
admin: true,
discovery: true,
features: {
'plugin.allowed.main': true,
'plugin.feature-denied.main': false,
},
is_superuser: false,
manage: false,
},
)
expect(entries.map(entry => entry.navMenu.title)).toEqual(['Open', 'Allowed'])
})
it('keeps the declared section for layout placement independently of its translated header', () => {
const [entry] = filterPluginSidebarNavEntries(
[createNavItem({ plugin_id: 'discover-demo', section: 'discovery' })],
t,
{ discovery: true, features: {} },
)
expect(entry.section).toBe('discovery')
expect(entry.navMenu.header).toBe('menu.discovery')
})
})

View File

@@ -306,11 +306,14 @@ export default defineConfig(({ command, mode, isPreview }) => ({
include: [
'src/utils/recommendSources.ts',
'src/utils/permission.ts',
'src/utils/pluginSidebarNav.ts',
'src/utils/requestOptimizer.ts',
'src/utils/sseManager.ts',
'src/utils/federationLoader.ts',
'src/utils/federationRuntime.ts',
'src/stores/auth.ts',
'src/stores/pluginSidebarNav.ts',
'src/pages/appcenter.vue',
'src/pages/recommend.vue',
'src/pages/discover.vue',
'src/pages/browse.vue',
@@ -567,12 +570,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 80,
statements: 80,
},
'src/stores/pluginSidebarNav.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/pages/appcenter.vue': {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
},
'src/utils/permission.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/pluginSidebarNav.ts': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/utils/recommendSources.ts': {
branches: 75,
functions: 80,