// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import ArcoVue from '@arco-design/web-vue' const messageMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), })) const profilesStoreMock = vi.hoisted(() => ({ activeProfileName: 'default', profiles: [{ name: 'default' }], fetchProfiles: vi.fn(), })) const fetchPluginsMock = vi.hoisted(() => vi.fn()) const setPluginEnabledMock = vi.hoisted(() => vi.fn()) vi.mock('@/stores/hermes/profiles', () => ({ useProfilesStore: () => profilesStoreMock, })) vi.mock('@/api/hermes/plugins', () => ({ fetchPlugins: fetchPluginsMock, setPluginEnabled: setPluginEnabledMock, })) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string, values?: Record) => values?.name ? `${key}:${values.name}` : key, te: () => false, }), })) vi.mock('@/composables/useMessage', () => ({ useMessage: () => messageMock })) import PluginsView from '@/views/hermes/PluginsView.vue' const arcoGlobal = { plugins: [ArcoVue] } function buttonByText(wrapper: any, text: string) { const button = wrapper.findAll('button').find((node: any) => node.text().trim() === text) if (!button) throw new Error(`no button labelled "${text}"`) return button } describe('PluginsView management actions', () => { // a-spin drives its indicator with requestAnimationFrame. Without unmounting, // those callbacks land after jsdom tears down and surface as an unhandled // "Cannot read properties of null (reading '_location')" roughly a third of // the time. Unmount before the environment goes away. const wrappers: VueWrapper[] = [] afterEach(() => { while (wrappers.length) wrappers.pop()?.unmount() }) const mountView = () => { const wrapper = mount(PluginsView, { global: arcoGlobal }) wrappers.push(wrapper) return wrapper } beforeEach(() => { vi.clearAllMocks() profilesStoreMock.activeProfileName = 'default' profilesStoreMock.profiles = [{ name: 'default' }] fetchPluginsMock.mockResolvedValue({ plugins: [{ key: 'local-plugin', name: 'local-plugin', kind: 'standalone', source: 'user', configStatus: 'not-enabled', effectiveStatus: 'inactive', version: '', description: '', author: '', path: '/tmp/local-plugin', providesTools: [], providesHooks: [], requiresEnv: [], }], warnings: [], metadata: null, }) setPluginEnabledMock.mockResolvedValue({ key: 'local-plugin', enabled: true }) }) it('enables manageable standalone plugins and refreshes the inventory', async () => { const wrapper = mountView() await flushPromises() await buttonByText(wrapper, 'common.enable').trigger('click') await flushPromises() expect(setPluginEnabledMock).toHaveBeenCalledWith('local-plugin', true) expect(messageMock.success).toHaveBeenCalledWith('plugins.enableSuccess:local-plugin') expect(fetchPluginsMock).toHaveBeenCalledTimes(2) }) it('leaves bundled plugins read-only', async () => { fetchPluginsMock.mockResolvedValueOnce({ plugins: [{ key: 'bundled-plugin', name: 'bundled-plugin', kind: 'standalone', source: 'bundled', configStatus: 'auto', effectiveStatus: 'auto-active', version: '', description: '', author: '', path: '', providesTools: [], providesHooks: [], requiresEnv: [], }], warnings: [], metadata: null, }) const wrapper = mountView() await flushPromises() expect(wrapper.text()).toContain('plugins.managedElsewhere') expect(wrapper.text()).not.toContain('common.disable') }) })