BMAD-METHOD/test/unit/config/config.test.js

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