skills_test.go

  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, "&amp;") // 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}