import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; interface StoreState { fetchSkillsCatalog: ReturnType; fetchSkillDetail: ReturnType; skillsCatalogLoadingByProjectPath: Record; skillsCatalogErrorByProjectPath: Record; skillsDetailsById: Record; skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; cliStatus: CliInstallationStatus | null; cliStatusLoading: boolean; appConfig: { general: { multimodelEnabled: boolean; }; } | null; } const storeState = {} as StoreState; const startWatchingMock = vi.fn(); const stopWatchingMock = vi.fn(); const onChangedMock = vi.fn(); const codexAccountHookState = { snapshot: null as CodexAccountSnapshotDto | null, loading: false, error: null as string | null, refresh: vi.fn(() => Promise.resolve(undefined)), startChatgptLogin: vi.fn(() => Promise.resolve(true)), cancelChatgptLogin: vi.fn(() => Promise.resolve(true)), logout: vi.fn(() => Promise.resolve(true)), }; let skillsChangedHandler: ((event: { scope: 'user' | 'project'; projectPath: string | null; path: string; type: 'create' | 'change' | 'delete'; }) => void) | null = null; vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); vi.mock('@features/codex-account/renderer', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useCodexAccountSnapshot: () => codexAccountHookState, }; }); vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: T) => selector, })); vi.mock('@renderer/api', () => ({ api: { skills: { startWatching: (...args: unknown[]) => startWatchingMock(...args), stopWatching: (...args: unknown[]) => stopWatchingMock(...args), onChanged: (...args: unknown[]) => onChangedMock(...args), }, }, })); 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'; variant?: string; size?: string; className?: string; }>) => React.createElement( 'button', { type, onClick, }, children ), })); vi.mock('@renderer/components/ui/popover', () => ({ Popover: ({ children }: React.PropsWithChildren<{ open?: boolean; onOpenChange?: (open: boolean) => void }>) => React.createElement(React.Fragment, null, children), PopoverTrigger: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), PopoverContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), })); vi.mock('@renderer/components/ui/tooltip', () => ({ Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), TooltipTrigger: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), })); vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ SearchInput: ({ value, onChange, placeholder, }: { value: string; onChange: (value: string) => void; placeholder?: string; }) => React.createElement('input', { value, placeholder, onChange: (event: React.ChangeEvent) => onChange(event.target.value), }), })); vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({ SkillDetailDialog: () => null, })); vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ SkillEditorDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => React.createElement('div', { 'data-testid': 'skill-editor-dialog', 'data-allow-codex-root-kind': String(allowCodexRootKind), }), })); vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({ SkillImportDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => React.createElement('div', { 'data-testid': 'skill-import-dialog', 'data-allow-codex-root-kind': String(allowCodexRootKind), }), })); vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { AlertTriangle: Icon, ArrowUpAZ: Icon, ArrowUpDown: Icon, BookOpen: Icon, Check: Icon, CheckCircle2: Icon, Clock3: Icon, Download: Icon, Plus: Icon, Search: Icon, }; }); import { SkillsPanel } from '@renderer/components/extensions/skills/SkillsPanel'; function makeUserSkill(): SkillCatalogItem { return { id: '/Users/me/.claude/skills/review-helper', sourceType: 'filesystem', name: 'Review Helper', description: 'Helps with code review', folderName: 'review-helper', scope: 'user', rootKind: 'claude', projectRoot: null, discoveryRoot: '/Users/me/.claude/skills', skillDir: '/Users/me/.claude/skills/review-helper', skillFile: '/Users/me/.claude/skills/review-helper/SKILL.md', metadata: {}, invocationMode: 'auto', flags: { hasScripts: false, hasReferences: false, hasAssets: false, }, isValid: true, issues: [], modifiedAt: 1, }; } function makeCodexSkill(): SkillCatalogItem { return { ...makeUserSkill(), id: '/Users/me/.codex/skills/codex-helper', name: 'Codex Helper', description: 'Helps only Codex sessions', folderName: 'codex-helper', rootKind: 'codex', discoveryRoot: '/Users/me/.codex/skills', skillDir: '/Users/me/.codex/skills/codex-helper', skillFile: '/Users/me/.codex/skills/codex-helper/SKILL.md', }; } function makeMultimodelStatus( overrides?: Partial ): CliInstallationStatus { return { flavor: 'agent_teams_orchestrator', displayName: 'Multimodel runtime', supportsSelfUpdate: false, showVersionDetails: true, showBinaryPath: true, installed: true, installedVersion: '1.0.0', binaryPath: '/usr/local/bin/agent-teams', latestVersion: '1.0.0', updateAvailable: false, authLoggedIn: false, authStatusChecking: false, authMethod: null, providers: [ { providerId: 'anthropic', displayName: 'Anthropic', supported: true, authenticated: true, authMethod: 'oauth', verificationState: 'verified', statusMessage: 'Connected', models: [], canLoginFromUi: true, capabilities: { teamLaunch: true, oneShot: true, extensions: createDefaultCliExtensionCapabilities({ plugins: { status: 'supported', ownership: 'provider-scoped', reason: null }, }), }, connection: null, backend: null, }, ], ...overrides, }; } describe('SkillsPanel', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.fetchSkillsCatalog = vi.fn().mockResolvedValue(undefined); storeState.fetchSkillDetail = vi.fn().mockResolvedValue(undefined); storeState.skillsCatalogLoadingByProjectPath = {}; storeState.skillsCatalogErrorByProjectPath = {}; storeState.skillsDetailsById = {}; storeState.skillsUserCatalog = [makeUserSkill()]; storeState.skillsProjectCatalogByProjectPath = { '/tmp/project-a': [], }; storeState.cliStatusLoading = false; storeState.appConfig = { general: { multimodelEnabled: true, }, }; storeState.cliStatus = { flavor: 'claude', displayName: 'Claude CLI', supportsSelfUpdate: true, showVersionDetails: true, showBinaryPath: true, installed: true, installedVersion: '1.0.0', binaryPath: '/usr/local/bin/claude', latestVersion: '1.0.0', updateAvailable: false, authLoggedIn: true, authStatusChecking: false, authMethod: 'oauth', providers: [], }; codexAccountHookState.snapshot = null; codexAccountHookState.loading = false; codexAccountHookState.error = null; startWatchingMock.mockReset(); stopWatchingMock.mockReset(); onChangedMock.mockReset(); skillsChangedHandler = null; startWatchingMock.mockResolvedValue('watch-1'); onChangedMock.mockImplementation((handler: typeof skillsChangedHandler) => { skillsChangedHandler = handler; return () => { skillsChangedHandler = null; }; }); }); afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); it('refetches personal skill details without forcing the current project path', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const skill = storeState.skillsUserCatalog[0]!; await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: skill.id, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(startWatchingMock).toHaveBeenCalledWith('/tmp/project-a'); expect(skillsChangedHandler).not.toBeNull(); await act(async () => { skillsChangedHandler?.({ scope: 'user', projectPath: null, path: `${skill.skillDir}/SKILL.md`, type: 'change', }); await Promise.resolve(); }); expect(storeState.fetchSkillsCatalog).toHaveBeenCalledWith('/tmp/project-a'); expect(storeState.fetchSkillDetail).toHaveBeenCalledWith(skill.id, undefined); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('hides codex-only create and import affordances when codex runtime is unavailable', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(host.textContent).not.toContain('Codex only'); for (const node of host.querySelectorAll('[data-testid="skill-editor-dialog"]')) { expect(node.getAttribute('data-allow-codex-root-kind')).toBe('false'); } const importDialog = host.querySelector('[data-testid="skill-import-dialog"]'); expect(importDialog?.getAttribute('data-allow-codex-root-kind')).toBe('false'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses a runtime-aware shared skills banner when codex is unavailable', async () => { storeState.cliStatus = makeMultimodelStatus(); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(host.textContent).toContain( 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic.' ); expect(host.textContent).not.toContain('available to both Anthropic and Codex'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses the live Codex snapshot to expose Codex-only skill affordances after a stale provider bootstrap', async () => { storeState.cliStatus = makeMultimodelStatus({ providers: [ ...makeMultimodelStatus().providers, { providerId: 'codex', displayName: 'Codex', supported: false, authenticated: false, authMethod: null, verificationState: 'unknown', statusMessage: 'Checking...', models: [], canLoginFromUi: false, capabilities: { teamLaunch: true, oneShot: true, extensions: createDefaultCliExtensionCapabilities({ plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null }, }), }, connection: null, backend: null, }, ], }); codexAccountHookState.snapshot = { preferredAuthMode: 'chatgpt', effectiveAuthMode: 'chatgpt', launchAllowed: true, launchIssueMessage: null, launchReadinessState: 'ready_chatgpt', appServerState: 'healthy', appServerStatusMessage: null, managedAccount: { type: 'chatgpt', email: 'user@example.com', planType: 'pro', }, apiKey: { available: true, source: 'environment', sourceLabel: 'Detected from OPENAI_API_KEY', }, requiresOpenaiAuth: false, login: { status: 'idle', error: null, startedAt: null, }, rateLimits: null, updatedAt: new Date().toISOString(), }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(host.textContent).toContain( 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic and Codex.' ); expect(host.textContent).toContain('Codex only'); expect(host.textContent).toContain('Use `.codex` when a skill should stay Codex-only.'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('uses the live Codex snapshot even while multimodel provider status is still loading', async () => { storeState.cliStatus = null; storeState.cliStatusLoading = true; codexAccountHookState.snapshot = { preferredAuthMode: 'chatgpt', effectiveAuthMode: 'chatgpt', launchAllowed: true, launchIssueMessage: null, launchReadinessState: 'ready_chatgpt', appServerState: 'healthy', appServerStatusMessage: null, managedAccount: { type: 'chatgpt', email: 'user@example.com', planType: 'pro', }, apiKey: { available: true, source: 'environment', sourceLabel: 'Detected from OPENAI_API_KEY', }, requiresOpenaiAuth: false, login: { status: 'idle', error: null, startedAt: null, }, rateLimits: null, updatedAt: new Date().toISOString(), }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(host.textContent).toContain( 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).' ); expect(host.textContent).toContain('Codex only'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('resets the codex-only quick filter when codex entries disappear', async () => { storeState.cliStatus = makeMultimodelStatus({ providers: [ ...makeMultimodelStatus().providers, { providerId: 'codex', displayName: 'Codex', supported: true, authenticated: true, authMethod: 'api_key', verificationState: 'verified', statusMessage: 'Connected', models: [], canLoginFromUi: true, capabilities: { teamLaunch: true, oneShot: true, extensions: createDefaultCliExtensionCapabilities({ plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null }, }), }, connection: null, backend: null, }, ], }); storeState.skillsUserCatalog = [makeUserSkill(), makeCodexSkill()]; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); const codexOnlyButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Codex only') ); expect(codexOnlyButton).toBeDefined(); await act(async () => { (codexOnlyButton as HTMLButtonElement).click(); await Promise.resolve(); }); expect(host.textContent).toContain('Codex Helper'); expect(host.textContent).not.toContain('Review Helper'); storeState.cliStatus = { ...storeState.cliStatus, providers: storeState.cliStatus.providers.filter((provider) => provider.providerId !== 'codex'), }; storeState.skillsUserCatalog = [makeUserSkill()]; await act(async () => { root.render( React.createElement(SkillsPanel, { projectPath: '/tmp/project-a', projectLabel: 'Project A', skillsSearchQuery: '', setSkillsSearchQuery: vi.fn(), skillsSort: 'name-asc', setSkillsSort: vi.fn(), selectedSkillId: null, setSelectedSkillId: vi.fn(), }) ); await Promise.resolve(); await Promise.resolve(); }); expect(host.textContent).toContain('Review Helper'); expect(host.textContent).not.toContain('Codex Helper'); expect(host.textContent).not.toContain('No skills yet'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });