agent-ecosystem/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts

451 lines
13 KiB
TypeScript

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<typeof vi.fn>;
applySkillUpsert: ReturnType<typeof vi.fn>;
}
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<HTMLInputElement>) =>
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<HTMLInputElement>) =>
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<HTMLSelectElement>) => 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<HTMLTextAreaElement>) =>
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: ({ open }: { open: boolean }) =>
open ? React.createElement('div', { 'data-testid': 'skill-review-dialog' }, 'Review') : null,
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => 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: [],
};
}
function makeCodexDetail(rawContent: string): SkillDetail {
const detail = makeDetail(rawContent);
return {
...detail,
item: {
...detail.item,
rootKind: 'codex',
discoveryRoot: '/tmp/project/.codex/skills',
skillDir: '/tmp/project/.codex/skills/custom-skill',
skillFile: '/tmp/project/.codex/skills/custom-skill/SKILL.md',
},
};
}
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',
allowCodexRootKind: true,
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();
});
});
it('clears review state when the editor closes externally', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
storeState.previewSkillUpsert.mockResolvedValue({
planId: 'plan-1',
targetSkillDir: '/tmp/project/.claude/skills/new-skill',
changes: [
{
relativePath: 'SKILL.md',
absolutePath: '/tmp/project/.claude/skills/new-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(SkillEditorDialog, {
open: true,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
allowCodexRootKind: true,
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
const reviewButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Review And Create')
) 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(SkillEditorDialog, {
open: false,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
allowCodexRootKind: true,
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="skill-review-dialog"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('blocks 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(SkillEditorDialog, {
open: true,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
allowCodexRootKind: true,
detail: null,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
const folderInput = host.querySelector('#skill-folder') as HTMLInputElement;
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
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 Create')
) as HTMLButtonElement;
await act(async () => {
reviewButton.click();
await Promise.resolve();
});
expect(storeState.previewSkillUpsert).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('hides the codex root option in create mode 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(SkillEditorDialog, {
open: true,
mode: 'create',
projectPath: '/tmp/project',
projectLabel: 'Project',
allowCodexRootKind: false,
detail: null,
onClose: vi.fn(),
onSaved: 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();
});
});
it('keeps the codex root visible when editing an existing codex-only skill', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const detail = makeCodexDetail(`---
name: Codex Skill
description: Codex markdown skill
---
# Codex Skill
`);
await act(async () => {
root.render(
React.createElement(SkillEditorDialog, {
open: true,
mode: 'edit',
projectPath: '/tmp/project',
projectLabel: 'Project',
allowCodexRootKind: false,
detail,
onClose: vi.fn(),
onSaved: vi.fn(),
})
);
await Promise.resolve();
});
const selects = host.querySelectorAll('select');
const rootSelect = selects[1] as HTMLSelectElement;
expect(rootSelect.value).toBe('codex');
expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});