import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { previewSkillImport: ReturnType; applySkillImport: ReturnType; } const storeState = {} as StoreState; const selectFoldersMock = vi.fn(); vi.mock('@renderer/store', () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); vi.mock('@renderer/api', () => ({ api: { config: { selectFolders: (...args: unknown[]) => selectFoldersMock(...args), }, }, })); vi.mock('@renderer/components/ui/button', () => ({ Button: ({ children, onClick, type = 'button', disabled, }: React.PropsWithChildren<{ onClick?: () => void; type?: 'button' | 'submit' | 'reset'; disabled?: boolean; }>) => React.createElement( 'button', { type, disabled, onClick, }, children ), })); vi.mock('@renderer/components/ui/dialog', () => ({ Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? React.createElement('div', null, children) : null, DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogDescription: ({ children }: React.PropsWithChildren) => React.createElement('p', null, children), DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), })); vi.mock('@renderer/components/ui/input', () => ({ Input: (props: React.InputHTMLAttributes) => React.createElement('input', props), })); vi.mock('@renderer/components/ui/label', () => ({ Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) => React.createElement('label', { htmlFor }, children), })); vi.mock('@renderer/components/ui/select', () => ({ Select: ({ children, value, onValueChange, }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => React.createElement( 'select', { 'data-testid': 'select', value, onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), }, children ), SelectTrigger: () => null, 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/skills/SkillReviewDialog', () => ({ SkillReviewDialog: ({ open }: { open: boolean }) => open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null, })); vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { FileSearch: Icon, FolderOpen: Icon, X: Icon, }; }); import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog'; describe('SkillImportDialog', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.previewSkillImport = vi.fn(); storeState.applySkillImport = vi.fn(); selectFoldersMock.mockReset(); }); afterEach(() => { document.body.innerHTML = ''; vi.unstubAllGlobals(); }); it('keeps destination folder empty until a source folder is chosen', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; expect(sourceInput.value).toBe(''); expect(folderInput.value).toBe(''); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps destination folder name synced with the chosen source until edited manually', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); selectFoldersMock .mockResolvedValueOnce(['/tmp/first-skill']) .mockResolvedValueOnce(['/tmp/second-skill']) .mockResolvedValueOnce(['/tmp/third-skill']); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const browseButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent?.includes('Browse') ) as HTMLButtonElement; const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; await act(async () => { browseButton.click(); await Promise.resolve(); }); expect(sourceInput.value).toBe('/tmp/first-skill'); expect(folderInput.value).toBe('first-skill'); await act(async () => { browseButton.click(); await Promise.resolve(); }); expect(sourceInput.value).toBe('/tmp/second-skill'); expect(folderInput.value).toBe('second-skill'); await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setValue?.call(folderInput, 'custom-name'); folderInput.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); }); expect(folderInput.value).toBe('custom-name'); await act(async () => { browseButton.click(); await Promise.resolve(); }); expect(sourceInput.value).toBe('/tmp/third-skill'); expect(folderInput.value).toBe('custom-name'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('sanitizes the suggested destination folder when the source folder name is not CLI-safe', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); selectFoldersMock.mockResolvedValueOnce(['/tmp/My Skill Folder']); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const browseButton = Array.from(host.querySelectorAll('button')).find( (button) => button.textContent?.includes('Browse') ) as HTMLButtonElement; await act(async () => { browseButton.click(); await Promise.resolve(); }); const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; expect(folderInput.value).toBe('my-skill-folder'); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('falls back to user scope when the project context disappears mid-dialog', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: '/tmp/project-a', projectLabel: 'Project A', allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; expect(scopeSelect.value).toBe('project'); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; expect(updatedScopeSelect.value).toBe('user'); const projectOption = Array.from(updatedScopeSelect.options).find( (option) => option.value === 'project' ) as HTMLOptionElement; expect(projectOption.disabled).toBe(true); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('clears review state when the import dialog closes externally', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); storeState.previewSkillImport.mockResolvedValue({ planId: 'plan-1', targetSkillDir: '/tmp/imported-skill', changes: [ { relativePath: 'SKILL.md', absolutePath: '/tmp/imported-skill/SKILL.md', action: 'create', oldContent: null, newContent: '# Skill', isBinary: false, }, ], warnings: [], summary: { created: 1, updated: 0, deleted: 0, binary: 0 }, }); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setValue?.call(sourceInput, '/tmp/source-skill'); sourceInput.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); }); const reviewButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Review And Import') ) as HTMLButtonElement; await act(async () => { reviewButton.click(); await Promise.resolve(); await Promise.resolve(); }); expect(host.querySelector('[data-testid="skill-review-dialog"]')).not.toBeNull(); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: false, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('blocks import review locally when the folder name is invalid', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setValue?.call(sourceInput, '/tmp/source-skill'); sourceInput.dispatchEvent(new Event('input', { bubbles: true })); setValue?.call(folderInput, 'bad/name'); folderInput.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); }); const reviewButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Review And Import') ) as HTMLButtonElement; await act(async () => { reviewButton.click(); await Promise.resolve(); }); expect(storeState.previewSkillImport).not.toHaveBeenCalled(); expect(host.textContent).toContain( 'Pick a simpler folder name using letters, numbers, dots, dashes, or underscores.' ); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('keeps review disabled for whitespace-only source folders', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( React.createElement(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; setValue?.call(sourceInput, ' '); sourceInput.dispatchEvent(new Event('input', { bubbles: true })); await Promise.resolve(); }); const reviewButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Review And Import') ) as HTMLButtonElement; expect(reviewButton.disabled).toBe(true); await act(async () => { reviewButton.click(); await Promise.resolve(); }); expect(storeState.previewSkillImport).not.toHaveBeenCalled(); await act(async () => { root.unmount(); await Promise.resolve(); }); }); it('hides the codex root option 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(SkillImportDialog, { open: true, projectPath: null, projectLabel: null, allowCodexRootKind: false, onClose: vi.fn(), onImported: vi.fn(), }) ); await Promise.resolve(); }); const selects = host.querySelectorAll('select'); const rootSelect = selects[1] as HTMLSelectElement; expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); await act(async () => { root.unmount(); await Promise.resolve(); }); }); });