1import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2import fs from 'fs';
3import path from 'path';
4import { PROVIDERS } from '../../../scripts/lib/transformers/providers.js';
5import { createTransformer } from '../../../scripts/lib/transformers/factory.js';
6import { parseFrontmatter, PROVIDER_PLACEHOLDERS } from '../../../scripts/lib/utils.js';
7
8const TEST_DIR = path.join(process.cwd(), 'test-tmp-providers');
9
10function providerTestDir(provider, suffix = '') {
11 return path.join(TEST_DIR, `${provider}${suffix}`);
12}
13
14function skillPath(config, skillName, suffix = '') {
15 return path.join(TEST_DIR, `${config.provider}${suffix}/${config.configDir}/skills/${skillName}/SKILL.md`);
16}
17
18// Test every provider config
19for (const [key, config] of Object.entries(PROVIDERS)) {
20 describe(`Provider: ${config.displayName} (${key})`, () => {
21 const transform = createTransformer(config);
22
23 beforeEach(() => {
24 if (fs.existsSync(TEST_DIR)) {
25 fs.rmSync(TEST_DIR, { recursive: true, force: true });
26 }
27 });
28
29 afterEach(() => {
30 if (fs.existsSync(TEST_DIR)) {
31 fs.rmSync(TEST_DIR, { recursive: true, force: true });
32 }
33 });
34
35 test('should create correct directory structure', () => {
36 transform([], TEST_DIR);
37 expect(fs.existsSync(path.join(TEST_DIR, `${config.provider}/${config.configDir}/skills`))).toBe(true);
38 });
39
40 test('should replace {{model}} placeholder correctly', () => {
41 const skills = [{ name: 'test', description: 'Test', body: 'Ask {{model}} for help.' }];
42 transform(skills, TEST_DIR);
43 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
44 const expected = PROVIDER_PLACEHOLDERS[config.placeholderProvider || config.provider].model;
45 expect(content).toContain(`Ask ${expected} for help.`);
46 });
47
48 test('should replace {{config_file}} placeholder correctly', () => {
49 const skills = [{ name: 'test', description: 'Test', body: 'See {{config_file}}.' }];
50 transform(skills, TEST_DIR);
51 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
52 const expected = PROVIDER_PLACEHOLDERS[config.placeholderProvider || config.provider].config_file;
53 expect(content).toContain(`See ${expected}.`);
54 });
55
56 test('should support prefix option', () => {
57 const skills = [{ name: 'audit', description: 'Audit', userInvocable: true, body: 'Body' }];
58 transform(skills, TEST_DIR, { prefix: 'i-', outputSuffix: '-prefixed' });
59 expect(fs.existsSync(skillPath(config, 'i-audit', '-prefixed'))).toBe(true);
60 const content = fs.readFileSync(skillPath(config, 'i-audit', '-prefixed'), 'utf-8');
61 expect(content).toContain('name: i-audit');
62 });
63
64 test('should copy reference files', () => {
65 const skills = [{
66 name: 'test',
67 description: 'Test',
68 body: 'Body',
69 references: [{ name: 'ref', content: 'Ref content', filePath: '/fake/ref.md' }]
70 }];
71 transform(skills, TEST_DIR);
72 const refPath = path.join(TEST_DIR, `${config.provider}/${config.configDir}/skills/test/reference/ref.md`);
73 expect(fs.existsSync(refPath)).toBe(true);
74 });
75
76 // Field-specific tests based on provider config
77 if (config.frontmatterFields.includes('user-invocable')) {
78 test('should emit user-invocable for user-invocable skills', () => {
79 const skills = [{ name: 'test', description: 'Test', userInvocable: true, body: 'Body' }];
80 transform(skills, TEST_DIR);
81 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
82 expect(parsed.frontmatter['user-invocable']).toBe(true);
83 });
84
85 test('should omit user-invocable for non-user-invocable skills', () => {
86 const skills = [{ name: 'test', description: 'Test', userInvocable: false, body: 'Body' }];
87 transform(skills, TEST_DIR);
88 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
89 expect(parsed.frontmatter['user-invocable']).toBeUndefined();
90 });
91 }
92
93 if (config.frontmatterFields.includes('argument-hint')) {
94 test('should emit argument-hint for user-invocable skills', () => {
95 const skills = [{ name: 'test', description: 'Test', userInvocable: true, argumentHint: '[target]', body: 'Body' }];
96 transform(skills, TEST_DIR);
97 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
98 expect(parsed.frontmatter['argument-hint']).toBe('[target]');
99 });
100
101 test('should not emit argument-hint for non-user-invocable skills', () => {
102 const skills = [{ name: 'test', description: 'Test', userInvocable: false, argumentHint: '[target]', body: 'Body' }];
103 transform(skills, TEST_DIR);
104 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
105 expect(parsed.frontmatter['argument-hint']).toBeUndefined();
106 });
107 }
108
109 if (config.frontmatterFields.includes('license')) {
110 test('should emit license when present', () => {
111 const skills = [{ name: 'test', description: 'Test', license: 'MIT', body: 'Body' }];
112 transform(skills, TEST_DIR);
113 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
114 expect(parsed.frontmatter.license).toBe('MIT');
115 });
116
117 test('should omit license when empty', () => {
118 const skills = [{ name: 'test', description: 'Test', license: '', body: 'Body' }];
119 transform(skills, TEST_DIR);
120 const parsed = parseFrontmatter(fs.readFileSync(skillPath(config, 'test'), 'utf-8'));
121 expect(parsed.frontmatter.license).toBeUndefined();
122 });
123 }
124
125 if (config.frontmatterFields.includes('compatibility')) {
126 test('should emit compatibility when present', () => {
127 const skills = [{ name: 'test', description: 'Test', compatibility: config.provider, body: 'Body' }];
128 transform(skills, TEST_DIR);
129 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
130 expect(content).toContain(`compatibility: ${config.provider}`);
131 });
132 }
133
134 if (config.frontmatterFields.includes('metadata')) {
135 test('should emit metadata when present', () => {
136 const skills = [{ name: 'test', description: 'Test', metadata: 'some-metadata', body: 'Body' }];
137 transform(skills, TEST_DIR);
138 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
139 expect(content).toContain('metadata: some-metadata');
140 });
141 }
142
143 if (config.frontmatterFields.includes('allowed-tools')) {
144 test('should emit allowed-tools when present', () => {
145 const skills = [{ name: 'test', description: 'Test', allowedTools: 'Bash,Edit', body: 'Body' }];
146 transform(skills, TEST_DIR);
147 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
148 expect(content).toContain('allowed-tools: Bash,Edit');
149 });
150 }
151
152 // Fields NOT in this provider's allowlist should not appear
153 const allOptionalFields = ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata', 'allowed-tools'];
154 const excludedFields = allOptionalFields.filter(f => !config.frontmatterFields.includes(f));
155
156 if (excludedFields.length > 0) {
157 test('should not emit fields outside allowlist', () => {
158 const skills = [{
159 name: 'test',
160 description: 'Test',
161 userInvocable: true,
162 argumentHint: '[target]',
163 license: 'MIT',
164 compatibility: 'all',
165 metadata: 'meta',
166 allowedTools: 'Bash',
167 body: 'Body'
168 }];
169 transform(skills, TEST_DIR);
170 const content = fs.readFileSync(skillPath(config, 'test'), 'utf-8');
171
172 for (const field of excludedFields) {
173 const yamlKey = field; // field names match yaml keys
174 // Check the raw content doesn't contain the yaml key
175 const lines = content.split('\n');
176 const frontmatterLines = [];
177 let inFrontmatter = false;
178 for (const line of lines) {
179 if (line === '---') {
180 if (inFrontmatter) break;
181 inFrontmatter = true;
182 continue;
183 }
184 if (inFrontmatter) frontmatterLines.push(line);
185 }
186 const hasForbiddenField = frontmatterLines.some(l => l.startsWith(`${yamlKey}:`));
187 expect(hasForbiddenField).toBe(false);
188 }
189 });
190 }
191 });
192}