diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index d063bc69..1b0cb3a3 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -730,6 +730,7 @@ export const SkillEditorDialog = ({ size="sm" onClick={() => { setManualRawEdit(false); + setCustomMarkdownDetected(false); const nextRawContent = buildSkillTemplate({ name, description, diff --git a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts new file mode 100644 index 00000000..72cce57a --- /dev/null +++ b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts @@ -0,0 +1,245 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SkillDetail } from '@shared/types/extensions'; + +interface StoreState { + previewSkillUpsert: ReturnType; + applySkillUpsert: ReturnType; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/hooks/useMarkdownScrollSync', () => ({ + useMarkdownScrollSync: () => ({ + handleCodeScroll: vi.fn(), + handlePreviewScroll: vi.fn(), + previewScrollRef: { current: null }, + }), +})); + +vi.mock('@renderer/components/team/editor/MarkdownPreviewPane', () => ({ + MarkdownPreviewPane: () => React.createElement('div', null, 'Preview'), +})); + +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', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/checkbox', () => ({ + Checkbox: ({ + checked, + onCheckedChange, + }: { + checked?: boolean; + onCheckedChange?: (value: boolean) => void; + className?: string; + }) => + React.createElement('input', { + type: 'checkbox', + checked, + onChange: (event: React.ChangeEvent) => + onCheckedChange?.(event.target.checked), + }), +})); + +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), + 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, + disabled, + }: React.PropsWithChildren<{ + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + }>) => + React.createElement( + 'select', + { + value, + disabled, + 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/ui/textarea', () => ({ + Textarea: (props: React.TextareaHTMLAttributes) => + React.createElement('textarea', props), +})); + +vi.mock('@renderer/components/extensions/skills/SkillCodeEditor', () => ({ + SkillCodeEditor: () => React.createElement('div', null, 'Editor'), +})); + +vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({ + SkillReviewDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + FileSearch: Icon, + RotateCcw: Icon, + X: Icon, + }; +}); + +import { SkillEditorDialog } from '@renderer/components/extensions/skills/SkillEditorDialog'; + +function makeDetail(rawContent: string): SkillDetail { + return { + item: { + id: '/tmp/project/.claude/skills/custom-skill', + sourceType: 'filesystem', + name: 'Custom Skill', + description: 'Custom markdown skill', + folderName: 'custom-skill', + scope: 'project', + rootKind: 'claude', + projectRoot: '/tmp/project', + discoveryRoot: '/tmp/project/.claude/skills', + skillDir: '/tmp/project/.claude/skills/custom-skill', + skillFile: '/tmp/project/.claude/skills/custom-skill/SKILL.md', + metadata: {}, + invocationMode: 'auto', + flags: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + isValid: true, + issues: [], + modifiedAt: 1, + }, + body: rawContent, + rawContent, + rawFrontmatter: null, + referencesFiles: [], + scriptFiles: [], + assetFiles: [], + }; +} + +describe('SkillEditorDialog', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.previewSkillUpsert = vi.fn(); + storeState.applySkillUpsert = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('unlocks structured editing after resetting a custom markdown skill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const detail = makeDetail(`--- +name: Custom Skill +description: Custom markdown skill +--- + +# Custom Skill + +This file uses a freeform layout without generated sections. +`); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'edit', + projectPath: '/tmp/project', + projectLabel: 'Project', + detail, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('#skill-when-to-use')).toBeNull(); + + const resetButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Reset From Structured Fields') + ) as HTMLButtonElement; + expect(resetButton).toBeDefined(); + + await act(async () => { + resetButton.click(); + await Promise.resolve(); + }); + + const whenToUseField = host.querySelector('#skill-when-to-use') as HTMLTextAreaElement; + expect(whenToUseField).not.toBeNull(); + expect(whenToUseField.disabled).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +});