429 lines
13 KiB
JavaScript
429 lines
13 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { Config } from '../../../tools/cli/lib/config.js';
|
|
import { createTempDir, cleanupTempDir, createTestFile } from '../../helpers/temp-dir.js';
|
|
import fs from 'fs-extra';
|
|
import path from 'node:path';
|
|
import yaml from 'yaml';
|
|
|
|
describe('Config', () => {
|
|
let tmpDir;
|
|
let config;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTempDir();
|
|
config = new Config();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await cleanupTempDir(tmpDir);
|
|
});
|
|
|
|
describe('loadYaml()', () => {
|
|
it('should load and parse YAML file', async () => {
|
|
const yamlContent = {
|
|
key1: 'value1',
|
|
key2: { nested: 'value2' },
|
|
array: [1, 2, 3],
|
|
};
|
|
|
|
const configPath = path.join(tmpDir, 'config.yaml');
|
|
await fs.writeFile(configPath, yaml.stringify(yamlContent));
|
|
|
|
const result = await config.loadYaml(configPath);
|
|
|
|
expect(result).toEqual(yamlContent);
|
|
});
|
|
|
|
it('should throw error for non-existent file', async () => {
|
|
const nonExistent = path.join(tmpDir, 'missing.yaml');
|
|
|
|
await expect(config.loadYaml(nonExistent)).rejects.toThrow('Configuration file not found');
|
|
});
|
|
|
|
it('should handle Unicode content', async () => {
|
|
const yamlContent = {
|
|
chinese: '测试',
|
|
russian: 'Тест',
|
|
japanese: 'テスト',
|
|
};
|
|
|
|
const configPath = path.join(tmpDir, 'unicode.yaml');
|
|
await fs.writeFile(configPath, yaml.stringify(yamlContent));
|
|
|
|
const result = await config.loadYaml(configPath);
|
|
|
|
expect(result.chinese).toBe('测试');
|
|
expect(result.russian).toBe('Тест');
|
|
expect(result.japanese).toBe('テスト');
|
|
});
|
|
});
|
|
|
|
// Note: saveYaml() is not tested because it uses yaml.dump() which doesn't exist
|
|
// in yaml 2.7.0 (should use yaml.stringify). This method is never called in production
|
|
// and represents dead code with a latent bug.
|
|
|
|
describe('processConfig()', () => {
|
|
it('should replace {project-root} placeholder', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Root is {project-root}/bmad');
|
|
|
|
await config.processConfig(configPath, { root: '/home/user/project' });
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe('Root is /home/user/project/bmad');
|
|
});
|
|
|
|
it('should replace {module} placeholder', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Module: {module}');
|
|
|
|
await config.processConfig(configPath, { module: 'bmm' });
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe('Module: bmm');
|
|
});
|
|
|
|
it('should replace {version} placeholder with package version', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Version: {version}');
|
|
|
|
await config.processConfig(configPath);
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toMatch(/Version: \d+\.\d+\.\d+/); // Semver format
|
|
});
|
|
|
|
it('should replace {date} placeholder with current date', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Date: {date}');
|
|
|
|
await config.processConfig(configPath);
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toMatch(/Date: \d{4}-\d{2}-\d{2}/); // YYYY-MM-DD
|
|
});
|
|
|
|
it('should replace multiple placeholders', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Root: {project-root}, Module: {module}, Version: {version}');
|
|
|
|
await config.processConfig(configPath, {
|
|
root: '/project',
|
|
module: 'test',
|
|
});
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toContain('Root: /project');
|
|
expect(content).toContain('Module: test');
|
|
expect(content).toMatch(/Version: \d+\.\d+/);
|
|
});
|
|
|
|
it('should replace custom placeholders', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Custom: {custom-placeholder}');
|
|
|
|
await config.processConfig(configPath, { '{custom-placeholder}': 'custom-value' });
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe('Custom: custom-value');
|
|
});
|
|
|
|
it('should escape regex special characters in placeholders', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Path: {project-root}/test');
|
|
|
|
// Test that {project-root} doesn't get interpreted as regex
|
|
await config.processConfig(configPath, {
|
|
root: '/path/with/special$chars^',
|
|
});
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe('Path: /path/with/special$chars^/test');
|
|
});
|
|
|
|
it('should handle placeholders with regex metacharacters in values', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, 'Value: {placeholder}');
|
|
|
|
await config.processConfig(configPath, {
|
|
'{placeholder}': String.raw`value with $1 and \backslash`,
|
|
});
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe(String.raw`Value: value with $1 and \backslash`);
|
|
});
|
|
|
|
it('should replace all occurrences of placeholder', async () => {
|
|
const configPath = path.join(tmpDir, 'config.txt');
|
|
await fs.writeFile(configPath, '{module} is here and {module} is there and {module} everywhere');
|
|
|
|
await config.processConfig(configPath, { module: 'BMM' });
|
|
|
|
const content = await fs.readFile(configPath, 'utf8');
|
|
expect(content).toBe('BMM is here and BMM is there and BMM everywhere');
|
|
});
|
|
});
|
|
|
|
describe('deepMerge()', () => {
|
|
it('should merge shallow objects', () => {
|
|
const target = { a: 1, b: 2 };
|
|
const source = { b: 3, c: 4 };
|
|
|
|
const result = config.deepMerge(target, source);
|
|
|
|
expect(result).toEqual({ a: 1, b: 3, c: 4 });
|
|
});
|
|
|
|
it('should merge nested objects', () => {
|
|
const target = { level1: { a: 1, b: 2 } };
|
|
const source = { level1: { b: 3, c: 4 } };
|
|
|
|
const result = config.deepMerge(target, source);
|
|
|
|
expect(result.level1).toEqual({ a: 1, b: 3, c: 4 });
|
|
});
|
|
|
|
it('should not merge arrays (just replace)', () => {
|
|
const target = { items: [1, 2, 3] };
|
|
const source = { items: [4, 5] };
|
|
|
|
const result = config.deepMerge(target, source);
|
|
|
|
expect(result.items).toEqual([4, 5]); // Replaced, not merged
|
|
});
|
|
|
|
it('should handle null values', () => {
|
|
const target = { a: 'value', b: null };
|
|
const source = { a: null, c: 'new' };
|
|
|
|
const result = config.deepMerge(target, source);
|
|
|
|
expect(result).toEqual({ a: null, b: null, c: 'new' });
|
|
});
|
|
|
|
it('should not mutate original objects', () => {
|
|
const target = { a: 1 };
|
|
const source = { b: 2 };
|
|
|
|
config.deepMerge(target, source);
|
|
|
|
expect(target).toEqual({ a: 1 });
|
|
expect(source).toEqual({ b: 2 });
|
|
});
|
|
});
|
|
|
|
describe('mergeConfigs()', () => {
|
|
it('should delegate to deepMerge', () => {
|
|
const base = { setting1: 'base' };
|
|
const override = { setting2: 'override' };
|
|
|
|
const result = config.mergeConfigs(base, override);
|
|
|
|
expect(result).toEqual({ setting1: 'base', setting2: 'override' });
|
|
});
|
|
});
|
|
|
|
describe('isObject()', () => {
|
|
it('should return true for plain objects', () => {
|
|
expect(config.isObject({})).toBe(true);
|
|
expect(config.isObject({ key: 'value' })).toBe(true);
|
|
});
|
|
|
|
it('should return false for arrays', () => {
|
|
expect(config.isObject([])).toBe(false);
|
|
});
|
|
|
|
it('should return false for null', () => {
|
|
expect(config.isObject(null)).toBeFalsy();
|
|
});
|
|
|
|
it('should return false for primitives', () => {
|
|
expect(config.isObject('string')).toBe(false);
|
|
expect(config.isObject(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getValue() and setValue()', () => {
|
|
it('should get value by dot notation path', () => {
|
|
const obj = {
|
|
level1: {
|
|
level2: {
|
|
value: 'test',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = config.getValue(obj, 'level1.level2.value');
|
|
|
|
expect(result).toBe('test');
|
|
});
|
|
|
|
it('should set value by dot notation path', () => {
|
|
const obj = {
|
|
level1: {
|
|
level2: {},
|
|
},
|
|
};
|
|
|
|
config.setValue(obj, 'level1.level2.value', 'new value');
|
|
|
|
expect(obj.level1.level2.value).toBe('new value');
|
|
});
|
|
|
|
it('should return default value for non-existent path', () => {
|
|
const obj = { a: { b: 'value' } };
|
|
|
|
const result = config.getValue(obj, 'a.c.d', 'default');
|
|
|
|
expect(result).toBe('default');
|
|
});
|
|
|
|
it('should return null default when path not found', () => {
|
|
const obj = { a: { b: 'value' } };
|
|
|
|
const result = config.getValue(obj, 'a.c.d');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle simple (non-nested) paths', () => {
|
|
const obj = { key: 'value' };
|
|
|
|
expect(config.getValue(obj, 'key')).toBe('value');
|
|
|
|
config.setValue(obj, 'newKey', 'newValue');
|
|
expect(obj.newKey).toBe('newValue');
|
|
});
|
|
|
|
it('should create intermediate objects when setting deep paths', () => {
|
|
const obj = {};
|
|
|
|
config.setValue(obj, 'a.b.c.d', 'deep value');
|
|
|
|
expect(obj.a.b.c.d).toBe('deep value');
|
|
});
|
|
});
|
|
|
|
describe('validateConfig()', () => {
|
|
it('should validate required fields', () => {
|
|
const cfg = { field1: 'value1' };
|
|
const schema = {
|
|
required: ['field1', 'field2'],
|
|
};
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toContain('Missing required field: field2');
|
|
});
|
|
|
|
it('should pass when all required fields present', () => {
|
|
const cfg = { field1: 'value1', field2: 'value2' };
|
|
const schema = {
|
|
required: ['field1', 'field2'],
|
|
};
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should validate field types', () => {
|
|
const cfg = {
|
|
stringField: 'text',
|
|
numberField: '42', // Wrong type
|
|
arrayField: [1, 2, 3],
|
|
objectField: 'not-object', // Wrong type
|
|
boolField: true,
|
|
};
|
|
|
|
const schema = {
|
|
properties: {
|
|
stringField: { type: 'string' },
|
|
numberField: { type: 'number' },
|
|
arrayField: { type: 'array' },
|
|
objectField: { type: 'object' },
|
|
boolField: { type: 'boolean' },
|
|
},
|
|
};
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('numberField'))).toBe(true);
|
|
expect(result.errors.some((e) => e.includes('objectField'))).toBe(true);
|
|
});
|
|
|
|
it('should validate enum values', () => {
|
|
const cfg = { level: 'expert' };
|
|
const schema = {
|
|
properties: {
|
|
level: { type: 'string', enum: ['beginner', 'intermediate', 'advanced'] },
|
|
},
|
|
};
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.includes('must be one of'))).toBe(true);
|
|
});
|
|
|
|
it('should pass validation for valid enum value', () => {
|
|
const cfg = { level: 'intermediate' };
|
|
const schema = {
|
|
properties: {
|
|
level: { type: 'string', enum: ['beginner', 'intermediate', 'advanced'] },
|
|
},
|
|
};
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('should return warnings array', () => {
|
|
const cfg = { field: 'value' };
|
|
const schema = { required: ['field'] };
|
|
|
|
const result = config.validateConfig(cfg, schema);
|
|
|
|
expect(result.warnings).toBeDefined();
|
|
expect(Array.isArray(result.warnings)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty YAML file', async () => {
|
|
const configPath = path.join(tmpDir, 'empty.yaml');
|
|
await fs.writeFile(configPath, '');
|
|
|
|
const result = await config.loadYaml(configPath);
|
|
|
|
expect(result).toBeNull(); // Empty YAML parses to null
|
|
});
|
|
|
|
it('should handle YAML with only comments', async () => {
|
|
const configPath = path.join(tmpDir, 'comments.yaml');
|
|
await fs.writeFile(configPath, '# Just a comment\n# Another comment\n');
|
|
|
|
const result = await config.loadYaml(configPath);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should handle very deep object nesting', () => {
|
|
const deep = {
|
|
l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { value: 'deep' } } } } } } } },
|
|
};
|
|
const override = {
|
|
l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { value: 'updated' } } } } } } } },
|
|
};
|
|
|
|
const result = config.deepMerge(deep, override);
|
|
|
|
expect(result.l1.l2.l3.l4.l5.l6.l7.l8.value).toBe('updated');
|
|
});
|
|
});
|
|
});
|