mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-11 08:33:46 +08:00
test(plugin): cover dynamic sidebar navigation (#629)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
124
src/pages/__tests__/appcenter.spec.ts
Normal file
124
src/pages/__tests__/appcenter.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
146
src/router/__tests__/plugin-sidebar-permission.spec.ts
Normal file
146
src/router/__tests__/plugin-sidebar-permission.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
178
src/stores/__tests__/pluginSidebarNav.spec.ts
Normal file
178
src/stores/__tests__/pluginSidebarNav.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
92
src/utils/__tests__/pluginSidebarNav.spec.ts
Normal file
92
src/utils/__tests__/pluginSidebarNav.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user