utils.test.js

  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