import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EnrichedPlugin } from '@shared/types/extensions'; interface StoreState { fetchPluginReadme: ReturnType; pluginReadmes: Record; pluginReadmeLoading: Record; installPlugin: ReturnType; uninstallPlugin: ReturnType; pluginCatalogProjectPath: string | null; pluginInstallProgress: Record; installErrors: Record; } const storeState = {} as StoreState; vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector, })); vi.mock('@renderer/api', () => ({ api: { openExternal: vi.fn(), }, })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', { 'data-testid': 'markdown' }, content), })); vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), })); vi.mock('@renderer/components/ui/button', () => ({ Button: ({ children, onClick, type = 'button', }: React.PropsWithChildren<{ onClick?: () => void; type?: 'button' | 'submit' | 'reset'; }>) => React.createElement( 'button', { type, onClick, }, children ), })); vi.mock('@renderer/components/ui/dialog', () => ({ Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null, DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', { 'data-testid': 'dialog-content' }, children), DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), DialogDescription: ({ children }: React.PropsWithChildren) => React.createElement('p', null, children), })); vi.mock('@renderer/components/ui/label', () => ({ Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), })); vi.mock('@renderer/components/ui/select', () => ({ Select: ({ children, value, onValueChange, }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => React.createElement( 'select', { 'data-testid': 'scope-select', value, onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), }, children ), SelectTrigger: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), SelectValue: () => null, SelectContent: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), SelectItem: ({ children, value, disabled, }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => React.createElement( 'option', { value, disabled, }, children ), })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ state, errorMessage, isInstalled, onInstall, onUninstall, }: { state?: string; errorMessage?: string; isInstalled: boolean; onInstall: () => void; onUninstall: () => void; }) => React.createElement( 'button', { type: 'button', 'data-testid': 'install-button', 'data-state': state, 'data-error-message': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' ), })); vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({ InstallCountBadge: ({ count }: { count: number }) => React.createElement('span', { 'data-testid': 'install-count' }, String(count)), })); vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ SourceBadge: ({ source }: { source: string }) => React.createElement('span', { 'data-testid': 'source-badge' }, source), })); vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { ExternalLink: Icon, Loader2: Icon, Mail: Icon, }; }); import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; const makePlugin = (): EnrichedPlugin => ({ pluginId: 'context7@claude-plugins-official', marketplaceId: 'context7@claude-plugins-official', qualifiedName: 'context7@claude-plugins-official', name: 'Context7', source: 'official', description: 'Fresh docs in Claude', category: 'docs', author: { name: 'Anthropic', email: 'help@example.com' }, version: '1.0.0', homepage: 'https://example.com/context7', tags: [], hasLspServers: false, hasMcpServers: true, hasAgents: false, hasCommands: false, hasHooks: false, isExternal: true, installCount: 42, isInstalled: false, installations: [], }); describe('PluginDetailDialog project context', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.fetchPluginReadme = vi.fn(); storeState.pluginReadmes = {}; storeState.pluginReadmeLoading = {}; storeState.installPlugin = vi.fn(); storeState.uninstallPlugin = vi.fn(); storeState.pluginCatalogProjectPath = '/tmp/global-project'; storeState.pluginInstallProgress = {}; storeState.installErrors = {}; }); afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); it('uses the current tab project path for project-scope installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const plugin = makePlugin(); await act(async () => { root.render( React.createElement(PluginDetailDialog, { plugin, open: true, onClose: vi.fn(), projectPath: '/tmp/tab-project', }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; expect(scopeSelect).not.toBeNull(); await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; expect(installButton).not.toBeNull(); await act(async () => { installButton.click(); await Promise.resolve(); }); expect(storeState.installPlugin).toHaveBeenCalledWith({ pluginId: plugin.pluginId, scope: 'project', projectPath: '/tmp/tab-project', }); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('disables project and local scopes when the current tab has no project', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(PluginDetailDialog, { plugin: makePlugin(), open: true, onClose: vi.fn(), projectPath: null, }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; expect(scopeSelect).not.toBeNull(); expect( (scopeSelect.querySelector('option[value="project"]') as HTMLOptionElement | null)?.disabled ).toBe(true); expect( (scopeSelect.querySelector('option[value="local"]') as HTMLOptionElement | null)?.disabled ).toBe(true); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('reads project-scope action state from the current tab project path', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const plugin = makePlugin(); storeState.pluginInstallProgress = { [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/tab-project')]: 'pending', }; storeState.installErrors = { [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/other-project')]: 'Wrong project', }; await act(async () => { root.render( React.createElement(PluginDetailDialog, { plugin, open: true, onClose: vi.fn(), projectPath: '/tmp/tab-project', }) ); await Promise.resolve(); }); const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; expect(scopeSelect).not.toBeNull(); expect(installButton).not.toBeNull(); await act(async () => { scopeSelect.value = 'project'; scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); await Promise.resolve(); }); expect(installButton.getAttribute('data-state')).toBe('pending'); expect(installButton.getAttribute('data-error-message')).toBeNull(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });