1package skills
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10)
11
12func TestParse(t *testing.T) {
13 t.Parallel()
14
15 tests := []struct {
16 name string
17 content string
18 wantName string
19 wantDesc string
20 wantLicense string
21 wantCompat string
22 wantMeta map[string]string
23 wantTools string
24 wantInstr string
25 wantErr bool
26 }{
27 {
28 name: "full skill",
29 content: `---
30name: pdf-processing
31description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32license: Apache-2.0
33compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34metadata:
35 author: example-org
36 version: "1.0"
37---
38
39# PDF Processing
40
41## When to use this skill
42Use this skill when the user needs to work with PDF files.
43`,
44 wantName: "pdf-processing",
45 wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46 wantLicense: "Apache-2.0",
47 wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48 wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49 wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50 },
51 {
52 name: "minimal skill",
53 content: `---
54name: my-skill
55description: A simple skill for testing.
56---
57
58# My Skill
59
60Instructions here.
61`,
62 wantName: "my-skill",
63 wantDesc: "A simple skill for testing.",
64 wantInstr: "# My Skill\n\nInstructions here.",
65 },
66 {
67 name: "no frontmatter",
68 content: "# Just Markdown\n\nNo frontmatter here.",
69 wantErr: true,
70 },
71 }
72
73 for _, tt := range tests {
74 t.Run(tt.name, func(t *testing.T) {
75 t.Parallel()
76
77 // Write content to temp file.
78 dir := t.TempDir()
79 path := filepath.Join(dir, "SKILL.md")
80 require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81
82 skill, err := Parse(path)
83 if tt.wantErr {
84 require.Error(t, err)
85 return
86 }
87 require.NoError(t, err)
88
89 require.Equal(t, tt.wantName, skill.Name)
90 require.Equal(t, tt.wantDesc, skill.Description)
91 require.Equal(t, tt.wantLicense, skill.License)
92 require.Equal(t, tt.wantCompat, skill.Compatibility)
93
94 if tt.wantMeta != nil {
95 require.Equal(t, tt.wantMeta, skill.Metadata)
96 }
97
98 require.Equal(t, tt.wantInstr, skill.Instructions)
99 })
100 }
101}
102
103func TestSkillValidate(t *testing.T) {
104 t.Parallel()
105
106 tests := []struct {
107 name string
108 skill Skill
109 wantErr bool
110 errMsg string
111 }{
112 {
113 name: "valid skill",
114 skill: Skill{
115 Name: "pdf-processing",
116 Description: "Processes PDF files.",
117 Path: "/skills/pdf-processing",
118 },
119 },
120 {
121 name: "missing name",
122 skill: Skill{Description: "Some description."},
123 wantErr: true,
124 errMsg: "name is required",
125 },
126 {
127 name: "missing description",
128 skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129 wantErr: true,
130 errMsg: "description is required",
131 },
132 {
133 name: "name too long",
134 skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135 wantErr: true,
136 errMsg: "exceeds",
137 },
138 {
139 name: "valid name - mixed case",
140 skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141 wantErr: false,
142 },
143 {
144 name: "invalid name - starts with hyphen",
145 skill: Skill{Name: "-my-skill", Description: "Some description."},
146 wantErr: true,
147 errMsg: "alphanumeric with hyphens",
148 },
149 {
150 name: "name doesn't match directory",
151 skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152 wantErr: true,
153 errMsg: "must match directory",
154 },
155 {
156 name: "description too long",
157 skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158 wantErr: true,
159 errMsg: "description exceeds",
160 },
161 {
162 name: "compatibility too long",
163 skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164 wantErr: true,
165 errMsg: "compatibility exceeds",
166 },
167 }
168
169 for _, tt := range tests {
170 t.Run(tt.name, func(t *testing.T) {
171 t.Parallel()
172
173 err := tt.skill.Validate()
174 if tt.wantErr {
175 require.Error(t, err)
176 require.Contains(t, err.Error(), tt.errMsg)
177 } else {
178 require.NoError(t, err)
179 }
180 })
181 }
182}
183
184func TestDiscover(t *testing.T) {
185 t.Parallel()
186
187 tmpDir := t.TempDir()
188
189 // Create valid skill 1.
190 skill1Dir := filepath.Join(tmpDir, "skill-one")
191 require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192 require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193name: skill-one
194description: First test skill.
195---
196# Skill One
197`), 0o644))
198
199 // Create valid skill 2 in nested directory.
200 skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201 require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202 require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203name: skill-two
204description: Second test skill.
205---
206# Skill Two
207`), 0o644))
208
209 // Create invalid skill (won't be included).
210 invalidDir := filepath.Join(tmpDir, "invalid-dir")
211 require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212 require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213name: wrong-name
214description: Name doesn't match directory.
215---
216`), 0o644))
217
218 skills := Discover([]string{tmpDir})
219 require.Len(t, skills, 2)
220
221 names := make(map[string]bool)
222 for _, s := range skills {
223 names[s.Name] = true
224 }
225 require.True(t, names["skill-one"])
226 require.True(t, names["skill-two"])
227}
228
229func TestToPromptXML(t *testing.T) {
230 t.Parallel()
231
232 skills := []*Skill{
233 {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234 {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235 }
236
237 xml := ToPromptXML(skills)
238
239 require.Contains(t, xml, "<available_skills>")
240 require.Contains(t, xml, "<name>pdf-processing</name>")
241 require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242 require.Contains(t, xml, "&") // XML escaping
243}
244
245func TestToPromptXMLEmpty(t *testing.T) {
246 t.Parallel()
247 require.Empty(t, ToPromptXML(nil))
248 require.Empty(t, ToPromptXML([]*Skill{}))
249}