factory.test.js

  1import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
  2import fs from 'fs';
  3import path from 'path';
  4import { createTransformer } from '../../../scripts/lib/transformers/factory.js';
  5import { parseFrontmatter } from '../../../scripts/lib/utils.js';
  6
  7const TEST_DIR = path.join(process.cwd(), 'test-tmp-factory');
  8
  9// Minimal config using 'cursor' as provider (has existing PROVIDER_PLACEHOLDERS)
 10const baseConfig = {
 11  provider: 'cursor',
 12  configDir: '.test',
 13  displayName: 'Test Provider',
 14  frontmatterFields: [],
 15};
 16
 17describe('createTransformer factory', () => {
 18  beforeEach(() => {
 19    if (fs.existsSync(TEST_DIR)) {
 20      fs.rmSync(TEST_DIR, { recursive: true, force: true });
 21    }
 22  });
 23
 24  afterEach(() => {
 25    if (fs.existsSync(TEST_DIR)) {
 26      fs.rmSync(TEST_DIR, { recursive: true, force: true });
 27    }
 28  });
 29
 30  test('should create correct directory structure', () => {
 31    const transform = createTransformer(baseConfig);
 32    transform([], TEST_DIR);
 33    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills'))).toBe(true);
 34  });
 35
 36  test('should always emit name and description', () => {
 37    const transform = createTransformer(baseConfig);
 38    const skills = [{ name: 'test', description: 'A test skill', body: 'Body.' }];
 39    transform(skills, TEST_DIR);
 40
 41    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
 42    const parsed = parseFrontmatter(content);
 43    expect(parsed.frontmatter.name).toBe('test');
 44    expect(parsed.frontmatter.description).toBe('A test skill');
 45    expect(parsed.body).toBe('Body.');
 46  });
 47
 48  test('should only emit allowlisted fields', () => {
 49    const config = { ...baseConfig, frontmatterFields: ['license'] };
 50    const transform = createTransformer(config);
 51    const skills = [{
 52      name: 'test',
 53      description: 'Test',
 54      license: 'MIT',
 55      compatibility: 'all',
 56      metadata: 'meta',
 57      body: 'Body'
 58    }];
 59    transform(skills, TEST_DIR);
 60
 61    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
 62    const parsed = parseFrontmatter(content);
 63    expect(parsed.frontmatter.license).toBe('MIT');
 64    expect(parsed.frontmatter.compatibility).toBeUndefined();
 65    expect(parsed.frontmatter.metadata).toBeUndefined();
 66  });
 67
 68  test('should skip empty optional fields', () => {
 69    const config = { ...baseConfig, frontmatterFields: ['license'] };
 70    const transform = createTransformer(config);
 71    const skills = [{ name: 'test', description: 'Test', license: '', body: 'Body' }];
 72    transform(skills, TEST_DIR);
 73
 74    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
 75    const parsed = parseFrontmatter(content);
 76    expect(parsed.frontmatter.license).toBeUndefined();
 77  });
 78
 79  test('should emit user-invocable as true when skill is user-invocable', () => {
 80    const config = { ...baseConfig, frontmatterFields: ['user-invocable'] };
 81    const transform = createTransformer(config);
 82    const skills = [{ name: 'test', description: 'Test', userInvocable: true, body: 'Body' }];
 83    transform(skills, TEST_DIR);
 84
 85    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
 86    const parsed = parseFrontmatter(content);
 87    expect(parsed.frontmatter['user-invocable']).toBe(true);
 88  });
 89
 90  test('should not emit user-invocable when skill is not user-invocable', () => {
 91    const config = { ...baseConfig, frontmatterFields: ['user-invocable'] };
 92    const transform = createTransformer(config);
 93    const skills = [{ name: 'test', description: 'Test', userInvocable: false, body: 'Body' }];
 94    transform(skills, TEST_DIR);
 95
 96    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
 97    const parsed = parseFrontmatter(content);
 98    expect(parsed.frontmatter['user-invocable']).toBeUndefined();
 99  });
100
101  test('should emit argument-hint only when user-invocable', () => {
102    const config = { ...baseConfig, frontmatterFields: ['argument-hint'] };
103    const transform = createTransformer(config);
104
105    // User-invocable with hint
106    const skills1 = [{ name: 'test', description: 'Test', userInvocable: true, argumentHint: '[target]', body: 'Body' }];
107    transform(skills1, TEST_DIR);
108    let content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
109    let parsed = parseFrontmatter(content);
110    expect(parsed.frontmatter['argument-hint']).toBe('[target]');
111
112    // Non-user-invocable with hint
113    fs.rmSync(TEST_DIR, { recursive: true, force: true });
114    const skills2 = [{ name: 'test', description: 'Test', userInvocable: false, argumentHint: '[target]', body: 'Body' }];
115    transform(skills2, TEST_DIR);
116    content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
117    parsed = parseFrontmatter(content);
118    expect(parsed.frontmatter['argument-hint']).toBeUndefined();
119  });
120
121  test('should apply bodyTransform after placeholder replacement', () => {
122    const config = {
123      ...baseConfig,
124      bodyTransform: (body) => body.replace(/PLACEHOLDER/, 'TRANSFORMED'),
125    };
126    const transform = createTransformer(config);
127    const skills = [{ name: 'test', description: 'Test', body: 'PLACEHOLDER content' }];
128    transform(skills, TEST_DIR);
129
130    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
131    expect(content).toContain('TRANSFORMED content');
132  });
133
134  test('should support prefix option', () => {
135    const transform = createTransformer(baseConfig);
136    const skills = [{ name: 'audit', description: 'Audit', userInvocable: true, body: 'Body' }];
137    transform(skills, TEST_DIR, { prefix: 'i-', outputSuffix: '-prefixed' });
138
139    const outputPath = path.join(TEST_DIR, 'cursor-prefixed/.test/skills/i-audit/SKILL.md');
140    expect(fs.existsSync(outputPath)).toBe(true);
141    const content = fs.readFileSync(outputPath, 'utf-8');
142    expect(content).toContain('name: i-audit');
143  });
144
145  test('should copy reference files', () => {
146    const transform = createTransformer(baseConfig);
147    const skills = [{
148      name: 'test',
149      description: 'Test',
150      body: 'Body',
151      references: [
152        { name: 'ref1', content: 'Reference 1 content', filePath: '/fake/ref1.md' },
153        { name: 'ref2', content: 'Reference 2 content', filePath: '/fake/ref2.md' },
154      ]
155    }];
156    transform(skills, TEST_DIR);
157
158    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref1.md'))).toBe(true);
159    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref2.md'))).toBe(true);
160    const ref1 = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/reference/ref1.md'), 'utf-8');
161    expect(ref1).toBe('Reference 1 content');
162  });
163
164  test('should clean existing directory before writing', () => {
165    const transform = createTransformer(baseConfig);
166    const existingDir = path.join(TEST_DIR, 'cursor/.test/skills/old');
167    fs.mkdirSync(existingDir, { recursive: true });
168    fs.writeFileSync(path.join(existingDir, 'SKILL.md'), 'old');
169
170    const skills = [{ name: 'new', description: 'New', body: 'New' }];
171    transform(skills, TEST_DIR);
172
173    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/old/SKILL.md'))).toBe(false);
174    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/new/SKILL.md'))).toBe(true);
175  });
176
177  test('should log correct summary', () => {
178    const consoleMock = mock(() => {});
179    const originalLog = console.log;
180    console.log = consoleMock;
181
182    const transform = createTransformer(baseConfig);
183    const skills = [
184      { name: 's1', description: 'Test', userInvocable: true, body: 'body' },
185      { name: 's2', description: 'Test', userInvocable: false, body: 'body' }
186    ];
187    transform(skills, TEST_DIR);
188
189    console.log = originalLog;
190    expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('✓ Test Provider:'));
191    expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('2 skills'));
192    expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('1 user-invocable'));
193  });
194
195  test('should handle empty skills array', () => {
196    const transform = createTransformer(baseConfig);
197    transform([], TEST_DIR);
198
199    const skillDirs = fs.readdirSync(path.join(TEST_DIR, 'cursor/.test/skills'));
200    expect(skillDirs).toHaveLength(0);
201  });
202
203  test('should replace {{model}} placeholder', () => {
204    const transform = createTransformer(baseConfig);
205    const skills = [{ name: 'test', description: 'Test', body: 'Ask {{model}} for help.' }];
206    transform(skills, TEST_DIR);
207
208    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
209    expect(content).toContain('Ask the model for help.');
210  });
211
212  test('should replace {{config_file}} placeholder', () => {
213    const transform = createTransformer(baseConfig);
214    const skills = [{ name: 'test', description: 'Test', body: 'See {{config_file}}.' }];
215    transform(skills, TEST_DIR);
216
217    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
218    expect(content).toContain('See .cursorrules.');
219  });
220
221  test('should handle multiple skills', () => {
222    const transform = createTransformer(baseConfig);
223    const skills = [
224      { name: 'skill1', description: 'Skill 1', body: 'Body 1' },
225      { name: 'skill2', description: 'Skill 2', body: 'Body 2' },
226    ];
227    transform(skills, TEST_DIR);
228
229    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/skill1/SKILL.md'))).toBe(true);
230    expect(fs.existsSync(path.join(TEST_DIR, 'cursor/.test/skills/skill2/SKILL.md'))).toBe(true);
231  });
232
233  test('should preserve multiline body content', () => {
234    const transform = createTransformer(baseConfig);
235    const skills = [{
236      name: 'test',
237      description: 'Test',
238      body: `First paragraph.\n\nSecond paragraph.\n\n- List item 1\n- List item 2`
239    }];
240    transform(skills, TEST_DIR);
241
242    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
243    const parsed = parseFrontmatter(content);
244    expect(parsed.body).toContain('First paragraph.');
245    expect(parsed.body).toContain('Second paragraph.');
246    expect(parsed.body).toContain('- List item 1');
247  });
248
249  test('should emit all spec fields when configured', () => {
250    const config = {
251      ...baseConfig,
252      frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'],
253    };
254    const transform = createTransformer(config);
255    const skills = [{
256      name: 'test',
257      description: 'Test',
258      userInvocable: true,
259      argumentHint: '[target]',
260      license: 'MIT',
261      compatibility: 'claude-code',
262      metadata: 'v1',
263      allowedTools: 'Bash,Edit',
264      body: 'Body'
265    }];
266    transform(skills, TEST_DIR);
267
268    const content = fs.readFileSync(path.join(TEST_DIR, 'cursor/.test/skills/test/SKILL.md'), 'utf-8');
269    expect(content).toContain('user-invocable: true');
270    expect(content).toContain('argument-hint:');
271    expect(content).toContain('license: MIT');
272    expect(content).toContain('compatibility: claude-code');
273    expect(content).toContain('metadata: v1');
274    expect(content).toContain('allowed-tools: Bash,Edit');
275  });
276});