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});