providers.test.js

  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}