1import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2import fs from 'fs';
3import path from 'path';
4import {
5 parseFrontmatter,
6 readFilesRecursive,
7 readSourceFiles,
8 ensureDir,
9 cleanDir,
10 writeFile,
11 generateYamlFrontmatter,
12 readPatterns,
13 replacePlaceholders
14} from '../../scripts/lib/utils.js';
15
16// Temporary test directory
17const TEST_DIR = path.join(process.cwd(), 'test-tmp');
18
19describe('parseFrontmatter', () => {
20 test('should parse basic frontmatter with simple key-value pairs', () => {
21 const content = `---
22name: test-skill
23description: A test skill
24---
25
26This is the body content.`;
27
28 const result = parseFrontmatter(content);
29 expect(result.frontmatter.name).toBe('test-skill');
30 expect(result.frontmatter.description).toBe('A test skill');
31 expect(result.body).toBe('This is the body content.');
32 });
33
34 test('should parse frontmatter with argument-hint', () => {
35 const content = `---
36name: test-skill
37description: A test skill
38argument-hint: <output> [TARGET=<value>]
39---
40
41Body here.`;
42
43 const result = parseFrontmatter(content);
44 expect(result.frontmatter.name).toBe('test-skill');
45 expect(result.frontmatter['argument-hint']).toBe('<output> [TARGET=<value>]');
46 });
47
48 test('should return empty frontmatter when no frontmatter present', () => {
49 const content = 'Just some content without frontmatter.';
50 const result = parseFrontmatter(content);
51
52 expect(result.frontmatter).toEqual({});
53 expect(result.body).toBe(content);
54 });
55
56 test('should handle empty body', () => {
57 const content = `---
58name: test
59---
60`;
61 const result = parseFrontmatter(content);
62
63 expect(result.frontmatter.name).toBe('test');
64 expect(result.body).toBe('');
65 });
66
67 test('should handle frontmatter with license field', () => {
68 const content = `---
69name: skill-name
70description: A skill
71license: MIT
72---
73
74Skill body.`;
75
76 const result = parseFrontmatter(content);
77 expect(result.frontmatter.license).toBe('MIT');
78 });
79
80 test('should parse user-invocable boolean', () => {
81 const content = `---
82name: test-skill
83user-invocable: true
84---
85
86Body.`;
87
88 const result = parseFrontmatter(content);
89 expect(result.frontmatter['user-invocable']).toBe(true);
90 });
91
92 test('should parse quoted user-invocable boolean as true', () => {
93 const content = `---
94name: test-skill
95user-invocable: 'true'
96---
97
98Body.`;
99
100 const result = parseFrontmatter(content);
101 expect(result.frontmatter['user-invocable']).toBe(true);
102 });
103
104 test('should keep quoted non-user-invocable booleans as plain strings', () => {
105 const content = `---
106name: test-skill
107description: 'true'
108---
109
110Body.`;
111
112 const result = parseFrontmatter(content);
113 expect(result.frontmatter.description).toBe('true');
114 });
115
116 test('should parse allowed-tools field', () => {
117 const content = `---
118name: test-skill
119allowed-tools: Bash
120---
121
122Body.`;
123
124 const result = parseFrontmatter(content);
125 expect(result.frontmatter['allowed-tools']).toBe('Bash');
126 });
127});
128
129describe('generateYamlFrontmatter', () => {
130 test('should generate basic frontmatter', () => {
131 const data = {
132 name: 'test-skill',
133 description: 'A test'
134 };
135
136 const result = generateYamlFrontmatter(data);
137 expect(result).toContain('---');
138 expect(result).toContain('name: test-skill');
139 expect(result).toContain('description: A test');
140 });
141
142 test('should generate frontmatter with argument-hint', () => {
143 const data = {
144 name: 'test',
145 description: 'Test skill',
146 'argument-hint': '<output> [TARGET=<value>]'
147 };
148
149 const result = generateYamlFrontmatter(data);
150 expect(result).toContain('argument-hint: <output> [TARGET=<value>]');
151 });
152
153 test('should generate frontmatter with boolean', () => {
154 const data = {
155 name: 'test',
156 description: 'Test',
157 'user-invocable': true
158 };
159
160 const result = generateYamlFrontmatter(data);
161 expect(result).toContain('user-invocable: true');
162 });
163
164 test('should roundtrip: generate and parse back', () => {
165 const original = {
166 name: 'roundtrip-test',
167 description: 'Testing roundtrip',
168 'argument-hint': '<arg1>'
169 };
170
171 const yaml = generateYamlFrontmatter(original);
172 const content = `${yaml}\n\nBody content`;
173 const parsed = parseFrontmatter(content);
174
175 expect(parsed.frontmatter.name).toBe(original.name);
176 expect(parsed.frontmatter.description).toBe(original.description);
177 expect(parsed.frontmatter['argument-hint']).toBe('<arg1>');
178 });
179
180 test('should quote strings containing colon-space (breaks plain scalars)', () => {
181 const data = {
182 name: 'impeccable',
183 description: 'Design fluency. Also handles: critique, audit. Commands: craft, polish.'
184 };
185
186 const result = generateYamlFrontmatter(data);
187 // Must be wrapped in quotes so YAML parsers don't mis-read the inner `: ` as a mapping
188 expect(result).toContain('description: "Design fluency. Also handles: critique, audit. Commands: craft, polish."');
189
190 // Roundtrip through our parser should recover the original string intact
191 const parsed = parseFrontmatter(`${result}\n\nbody`);
192 expect(parsed.frontmatter.description).toBe(data.description);
193 });
194
195 test('should quote strings starting with YAML flow indicators', () => {
196 const data = {
197 name: 'test',
198 'argument-hint': '[command] [target]'
199 };
200
201 const result = generateYamlFrontmatter(data);
202 expect(result).toContain('argument-hint: "[command] [target]"');
203 });
204
205 test('should not quote plain strings without special chars', () => {
206 const data = {
207 name: 'simple',
208 description: 'A plain description with no colons or hashes'
209 };
210
211 const result = generateYamlFrontmatter(data);
212 expect(result).toContain('description: A plain description with no colons or hashes');
213 expect(result).not.toContain('"A plain');
214 });
215});
216
217describe('ensureDir', () => {
218 afterEach(() => {
219 if (fs.existsSync(TEST_DIR)) {
220 fs.rmSync(TEST_DIR, { recursive: true, force: true });
221 }
222 });
223
224 test('should create directory if it does not exist', () => {
225 const testPath = path.join(TEST_DIR, 'new-dir');
226 ensureDir(testPath);
227
228 expect(fs.existsSync(testPath)).toBe(true);
229 expect(fs.statSync(testPath).isDirectory()).toBe(true);
230 });
231
232 test('should create nested directories', () => {
233 const testPath = path.join(TEST_DIR, 'level1', 'level2', 'level3');
234 ensureDir(testPath);
235
236 expect(fs.existsSync(testPath)).toBe(true);
237 });
238
239 test('should not throw if directory already exists', () => {
240 const testPath = path.join(TEST_DIR, 'existing');
241 fs.mkdirSync(testPath, { recursive: true });
242
243 expect(() => ensureDir(testPath)).not.toThrow();
244 });
245});
246
247describe('cleanDir', () => {
248 beforeEach(() => {
249 ensureDir(TEST_DIR);
250 });
251
252 afterEach(() => {
253 if (fs.existsSync(TEST_DIR)) {
254 fs.rmSync(TEST_DIR, { recursive: true, force: true });
255 }
256 });
257
258 test('should remove directory and all contents', () => {
259 const filePath = path.join(TEST_DIR, 'test.txt');
260 fs.writeFileSync(filePath, 'content');
261
262 expect(fs.existsSync(filePath)).toBe(true);
263
264 cleanDir(TEST_DIR);
265 expect(fs.existsSync(TEST_DIR)).toBe(false);
266 });
267
268 test('should not throw if directory does not exist', () => {
269 const nonExistent = path.join(TEST_DIR, 'does-not-exist');
270 expect(() => cleanDir(nonExistent)).not.toThrow();
271 });
272
273 test('should remove nested directories', () => {
274 const nestedPath = path.join(TEST_DIR, 'level1', 'level2');
275 ensureDir(nestedPath);
276 fs.writeFileSync(path.join(nestedPath, 'file.txt'), 'content');
277
278 cleanDir(TEST_DIR);
279 expect(fs.existsSync(TEST_DIR)).toBe(false);
280 });
281});
282
283describe('writeFile', () => {
284 afterEach(() => {
285 if (fs.existsSync(TEST_DIR)) {
286 fs.rmSync(TEST_DIR, { recursive: true, force: true });
287 }
288 });
289
290 test('should write file with content', () => {
291 const filePath = path.join(TEST_DIR, 'test.txt');
292 const content = 'Hello, world!';
293
294 writeFile(filePath, content);
295
296 expect(fs.existsSync(filePath)).toBe(true);
297 expect(fs.readFileSync(filePath, 'utf-8')).toBe(content);
298 });
299
300 test('should create parent directories automatically', () => {
301 const filePath = path.join(TEST_DIR, 'nested', 'deep', 'file.txt');
302 writeFile(filePath, 'content');
303
304 expect(fs.existsSync(filePath)).toBe(true);
305 expect(fs.readFileSync(filePath, 'utf-8')).toBe('content');
306 });
307
308 test('should overwrite existing file', () => {
309 const filePath = path.join(TEST_DIR, 'file.txt');
310 writeFile(filePath, 'first');
311 writeFile(filePath, 'second');
312
313 expect(fs.readFileSync(filePath, 'utf-8')).toBe('second');
314 });
315});
316
317describe('readFilesRecursive', () => {
318 beforeEach(() => {
319 ensureDir(TEST_DIR);
320 });
321
322 afterEach(() => {
323 if (fs.existsSync(TEST_DIR)) {
324 fs.rmSync(TEST_DIR, { recursive: true, force: true });
325 }
326 });
327
328 test('should find all markdown files in directory', () => {
329 writeFile(path.join(TEST_DIR, 'file1.md'), 'content1');
330 writeFile(path.join(TEST_DIR, 'file2.md'), 'content2');
331 writeFile(path.join(TEST_DIR, 'file3.txt'), 'not markdown');
332
333 const files = readFilesRecursive(TEST_DIR);
334 expect(files).toHaveLength(2);
335 expect(files.some(f => f.endsWith('file1.md'))).toBe(true);
336 expect(files.some(f => f.endsWith('file2.md'))).toBe(true);
337 });
338
339 test('should find markdown files in nested directories', () => {
340 writeFile(path.join(TEST_DIR, 'root.md'), 'root');
341 writeFile(path.join(TEST_DIR, 'sub', 'nested.md'), 'nested');
342 writeFile(path.join(TEST_DIR, 'sub', 'deep', 'deeper.md'), 'deeper');
343
344 const files = readFilesRecursive(TEST_DIR);
345 expect(files).toHaveLength(3);
346 expect(files.some(f => f.endsWith('root.md'))).toBe(true);
347 expect(files.some(f => f.endsWith('nested.md'))).toBe(true);
348 expect(files.some(f => f.endsWith('deeper.md'))).toBe(true);
349 });
350
351 test('should return empty array for non-existent directory', () => {
352 const files = readFilesRecursive(path.join(TEST_DIR, 'does-not-exist'));
353 expect(files).toEqual([]);
354 });
355
356 test('should return empty array for directory with no markdown files', () => {
357 writeFile(path.join(TEST_DIR, 'file.txt'), 'text');
358 writeFile(path.join(TEST_DIR, 'file.js'), 'code');
359
360 const files = readFilesRecursive(TEST_DIR);
361 expect(files).toEqual([]);
362 });
363});
364
365describe('readSourceFiles', () => {
366 const testRootDir = TEST_DIR;
367
368 beforeEach(() => {
369 ensureDir(testRootDir);
370 });
371
372 afterEach(() => {
373 if (fs.existsSync(testRootDir)) {
374 fs.rmSync(testRootDir, { recursive: true, force: true });
375 }
376 });
377
378 test('should read and parse SKILL.md from skill/', () => {
379 const skillContent = `---
380name: test-skill
381description: A test skill
382license: MIT
383---
384
385Skill instructions here.`;
386
387 const skillDir = path.join(testRootDir, 'skill');
388 ensureDir(skillDir);
389 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
390
391 const { skills } = readSourceFiles(testRootDir);
392
393 expect(skills).toHaveLength(1);
394 expect(skills[0].name).toBe('test-skill');
395 expect(skills[0].description).toBe('A test skill');
396 expect(skills[0].license).toBe('MIT');
397 expect(skills[0].body).toBe('Skill instructions here.');
398 });
399
400 test('should read skill with user-invocable flag', () => {
401 const skillContent = `---
402name: audit
403description: Run technical quality checks
404user-invocable: true
405---
406
407Audit the code.`;
408
409 const skillDir = path.join(testRootDir, 'skill');
410 ensureDir(skillDir);
411 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
412
413 const { skills } = readSourceFiles(testRootDir);
414
415 expect(skills).toHaveLength(1);
416 expect(skills[0].userInvocable).toBe(true);
417 });
418
419 test('should read skill with quoted user-invocable flag', () => {
420 const skillContent = `---
421name: audit
422description: Run technical quality checks
423user-invocable: 'true'
424---
425
426Audit the code.`;
427
428 const skillDir = path.join(testRootDir, 'skill');
429 ensureDir(skillDir);
430 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
431
432 const { skills } = readSourceFiles(testRootDir);
433
434 expect(skills).toHaveLength(1);
435 expect(skills[0].userInvocable).toBe(true);
436 });
437
438 test('should read skill with reference files', () => {
439 const skillContent = `---
440name: impeccable
441description: Impeccable design skill
442---
443
444Impeccable design instructions.`;
445
446 const skillDir = path.join(testRootDir, 'skill');
447 ensureDir(skillDir);
448 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
449
450 const refDir = path.join(skillDir, 'reference');
451 ensureDir(refDir);
452 fs.writeFileSync(path.join(refDir, 'typography.md'), 'Typography reference content.');
453 fs.writeFileSync(path.join(refDir, 'color.md'), 'Color reference content.');
454
455 const { skills } = readSourceFiles(testRootDir);
456
457 expect(skills).toHaveLength(1);
458 expect(skills[0].references).toHaveLength(2);
459 // References may not be in a specific order due to fs.readdirSync
460 const refNames = skills[0].references.map(r => r.name).sort();
461 expect(refNames).toEqual(['color', 'typography']);
462 });
463
464 test('should fall back to "impeccable" when frontmatter has no name', () => {
465 const skillDir = path.join(testRootDir, 'skill');
466 ensureDir(skillDir);
467 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Just body, no frontmatter.');
468
469 const { skills } = readSourceFiles(testRootDir);
470
471 expect(skills).toHaveLength(1);
472 expect(skills[0].name).toBe('impeccable');
473 });
474
475 test('should ignore non-md files in skill/reference', () => {
476 const skillDir = path.join(testRootDir, 'skill');
477 ensureDir(skillDir);
478 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: test-skill\n---\nBody');
479
480 const refDir = path.join(skillDir, 'reference');
481 ensureDir(refDir);
482 fs.writeFileSync(path.join(refDir, 'readme.txt'), 'Not a markdown file');
483 fs.writeFileSync(path.join(refDir, 'typography.md'), 'Valid reference');
484
485 const { skills } = readSourceFiles(testRootDir);
486
487 expect(skills).toHaveLength(1);
488 expect(skills[0].references).toHaveLength(1);
489 expect(skills[0].references[0].name).toBe('typography');
490 });
491
492 test('should handle missing skill directory', () => {
493 const { skills } = readSourceFiles(testRootDir);
494 expect(skills).toEqual([]);
495 });
496
497 test('should parse all frontmatter fields correctly', () => {
498 const skillContent = `---
499name: test-skill
500description: A comprehensive test skill
501license: Apache-2.0
502compatibility: claude-code
503user-invocable: true
504allowed-tools: Bash,Edit
505---
506
507Body content.`;
508
509 const skillDir = path.join(testRootDir, 'skill');
510 ensureDir(skillDir);
511 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
512
513 const { skills } = readSourceFiles(testRootDir);
514
515 expect(skills[0].name).toBe('test-skill');
516 expect(skills[0].description).toBe('A comprehensive test skill');
517 expect(skills[0].license).toBe('Apache-2.0');
518 expect(skills[0].compatibility).toBe('claude-code');
519 expect(skills[0].userInvocable).toBe(true);
520 expect(skills[0].allowedTools).toBe('Bash,Edit');
521 });
522});
523
524describe('readPatterns', () => {
525 const testRootDir = TEST_DIR;
526
527 beforeEach(() => {
528 ensureDir(testRootDir);
529 });
530
531 afterEach(() => {
532 if (fs.existsSync(testRootDir)) {
533 fs.rmSync(testRootDir, { recursive: true, force: true });
534 }
535 });
536
537 test('should extract DO and DON\'T patterns from SKILL.md', () => {
538 const skillContent = `---
539name: impeccable
540---
541
542### Typography
543**DO**: Use variable fonts for flexibility.
544**DON'T**: Use system fonts like Arial.
545
546### Color & Contrast
547**DO**: Ensure WCAG AA compliance.
548**DON'T**: Use gray text on colored backgrounds.
549
550### Layout & Space
551**DO**: Use consistent spacing scale.
552**DON'T**: Nest cards inside cards.`;
553
554 const skillDir = path.join(testRootDir, 'skill');
555 ensureDir(skillDir);
556 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
557
558 const { patterns, antipatterns } = readPatterns(testRootDir);
559
560 expect(patterns).toHaveLength(3);
561 expect(antipatterns).toHaveLength(3);
562
563 expect(patterns[0].name).toBe('Typography');
564 expect(patterns[0].items).toContain('Use variable fonts for flexibility.');
565 expect(antipatterns[0].items).toContain('Use system fonts like Arial.');
566 });
567
568 test('should normalize "Color & Theme" to "Color & Contrast"', () => {
569 const skillContent = `---
570name: impeccable
571---
572
573### Color & Theme
574**DO**: Use OKLCH color space.
575**DON'T**: Use pure black.`;
576
577 const skillDir = path.join(testRootDir, 'skill');
578 ensureDir(skillDir);
579 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
580
581 const { patterns, antipatterns } = readPatterns(testRootDir);
582
583 expect(patterns[0].name).toBe('Color & Contrast');
584 });
585
586 test('should handle missing SKILL.md file', () => {
587 ensureDir(path.join(testRootDir, 'skill'));
588
589 const { patterns, antipatterns } = readPatterns(testRootDir);
590
591 expect(patterns).toEqual([]);
592 expect(antipatterns).toEqual([]);
593 });
594
595 test('should return patterns in consistent section order', () => {
596 const skillContent = `---
597name: impeccable
598---
599
600### Motion
601**DO**: Use ease-out for natural movement.
602
603### Typography
604**DO**: Use modular scale.
605
606### Color & Contrast
607**DO**: Use tinted neutrals.`;
608
609 const skillDir = path.join(testRootDir, 'skill');
610 ensureDir(skillDir);
611 fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
612
613 const { patterns } = readPatterns(testRootDir);
614
615 // Patterns are returned in predefined section order, not source order
616 // Only sections with content are included
617 expect(patterns[0].name).toBe('Typography');
618 expect(patterns[1].name).toBe('Color & Contrast');
619 expect(patterns[2].name).toBe('Motion');
620 expect(patterns.length).toBe(3);
621 });
622});
623
624describe('replacePlaceholders', () => {
625 test('should replace {{model}} with provider-specific value', () => {
626 expect(replacePlaceholders('Ask {{model}} for help.', 'claude-code')).toBe('Ask Claude for help.');
627 expect(replacePlaceholders('Ask {{model}} for help.', 'gemini')).toBe('Ask Gemini for help.');
628 expect(replacePlaceholders('Ask {{model}} for help.', 'codex')).toBe('Ask GPT for help.');
629 expect(replacePlaceholders('Ask {{model}} for help.', 'cursor')).toBe('Ask the model for help.');
630 expect(replacePlaceholders('Ask {{model}} for help.', 'agents')).toBe('Ask the model for help.');
631 expect(replacePlaceholders('Ask {{model}} for help.', 'kiro')).toBe('Ask Claude for help.');
632 });
633
634 test('should replace {{config_file}} with provider-specific value', () => {
635 expect(replacePlaceholders('See {{config_file}}.', 'claude-code')).toBe('See CLAUDE.md.');
636 expect(replacePlaceholders('See {{config_file}}.', 'cursor')).toBe('See .cursorrules.');
637 expect(replacePlaceholders('See {{config_file}}.', 'gemini')).toBe('See GEMINI.md.');
638 expect(replacePlaceholders('See {{config_file}}.', 'codex')).toBe('See AGENTS.md.');
639 expect(replacePlaceholders('See {{config_file}}.', 'agents')).toBe('See .github/copilot-instructions.md.');
640 expect(replacePlaceholders('See {{config_file}}.', 'kiro')).toBe('See .kiro/settings.json.');
641 });
642
643 test('should replace {{ask_instruction}} with provider-specific value', () => {
644 const result = replacePlaceholders('{{ask_instruction}}', 'claude-code');
645 expect(result).toBe('STOP and call the AskUserQuestion tool to clarify.');
646
647 const cursorResult = replacePlaceholders('{{ask_instruction}}', 'cursor');
648 expect(cursorResult).toBe('ask the user directly to clarify what you cannot infer.');
649 });
650
651 test('should replace {{available_commands}} with command list', () => {
652 const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['audit', 'polish', 'optimize']);
653 expect(result).toBe('Commands: /audit, /polish, /optimize');
654 });
655
656 test('should exclude impeccable from {{available_commands}}', () => {
657 const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['audit', 'impeccable', 'polish']);
658 expect(result).toBe('Commands: /audit, /polish');
659 });
660
661 test('should exclude i-impeccable from {{available_commands}}', () => {
662 const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['i-audit', 'i-impeccable', 'i-polish']);
663 expect(result).toBe('Commands: /i-audit, /i-polish');
664 });
665
666 test('should exclude legacy teach-impeccable from {{available_commands}}', () => {
667 const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['audit', 'teach-impeccable', 'polish']);
668 expect(result).toBe('Commands: /audit, /polish');
669 });
670
671 test('should exclude legacy i-teach-impeccable from {{available_commands}}', () => {
672 const result = replacePlaceholders('Commands: {{available_commands}}', 'claude-code', ['i-audit', 'i-teach-impeccable', 'i-polish']);
673 expect(result).toBe('Commands: /i-audit, /i-polish');
674 });
675
676 test('should produce empty string for {{available_commands}} with no commands', () => {
677 const result = replacePlaceholders('Commands: {{available_commands}}.', 'claude-code', []);
678 expect(result).toBe('Commands: .');
679 });
680
681 test('should replace multiple placeholders in the same string', () => {
682 const result = replacePlaceholders('{{model}} uses {{config_file}} and {{ask_instruction}}', 'claude-code');
683 expect(result).toBe('Claude uses CLAUDE.md and STOP and call the AskUserQuestion tool to clarify.');
684 });
685
686 test('should replace multiple occurrences of the same placeholder', () => {
687 const result = replacePlaceholders('{{model}} and {{model}} again.', 'gemini');
688 expect(result).toBe('Gemini and Gemini again.');
689 });
690
691 test('should fall back to cursor placeholders for unknown provider', () => {
692 const result = replacePlaceholders('{{model}} {{config_file}}', 'unknown-provider');
693 expect(result).toBe('the model .cursorrules');
694 });
695});
696