418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
/**
|
|
* Advanced Tests for ManifestConfigLoader
|
|
* Coverage: Edge cases, error scenarios, performance, complex nested structures
|
|
* File: test/unit/config-loader-advanced.test.js
|
|
*/
|
|
|
|
const fs = require('fs-extra');
|
|
const path = require('node:path');
|
|
const yaml = require('js-yaml');
|
|
const { ManifestConfigLoader } = require('../../tools/cli/lib/config-loader');
|
|
|
|
describe('ManifestConfigLoader - Advanced Scenarios', () => {
|
|
let tempDir;
|
|
let loader;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = path.join(__dirname, '../fixtures/temp', `loader-${Date.now()}`);
|
|
await fs.ensureDir(tempDir);
|
|
loader = new ManifestConfigLoader();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await fs.remove(tempDir);
|
|
}
|
|
});
|
|
|
|
describe('Complex Nested Structures', () => {
|
|
test('should handle deeply nested keys with multiple levels', async () => {
|
|
const manifestPath = path.join(tempDir, 'deep.yaml');
|
|
const manifest = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: {
|
|
level5: 'deep value',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('level1.level2.level3.level4.level5')).toBe('deep value');
|
|
});
|
|
|
|
test('should handle arrays in nested structures', async () => {
|
|
const manifestPath = path.join(tempDir, 'arrays.yaml');
|
|
const manifest = {
|
|
modules: ['bmb', 'bmm', 'cis'],
|
|
ides: {
|
|
configured: ['claude-code', 'github-copilot'],
|
|
available: ['roo', 'cline'],
|
|
},
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
const ides = loader.getConfig('ides');
|
|
expect(ides.configured).toContain('claude-code');
|
|
expect(ides.available).toContain('cline');
|
|
});
|
|
|
|
test('should handle mixed data types in nested structures', async () => {
|
|
const manifestPath = path.join(tempDir, 'mixed.yaml');
|
|
const manifest = {
|
|
config: {
|
|
string: 'value',
|
|
number: 42,
|
|
boolean: true,
|
|
null: null,
|
|
array: [1, 2, 3],
|
|
nested: {
|
|
date: '2025-10-26T12:00:00Z',
|
|
version: '1.0.0',
|
|
},
|
|
},
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('config.string')).toBe('value');
|
|
expect(loader.getConfig('config.number')).toBe(42);
|
|
expect(loader.getConfig('config.boolean')).toBe(true);
|
|
expect(loader.getConfig('config.null')).toBeNull();
|
|
expect(loader.getConfig('config.nested.version')).toBe('1.0.0');
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases - Empty and Null Values', () => {
|
|
test('should handle empty config objects', async () => {
|
|
const manifestPath = path.join(tempDir, 'empty.yaml');
|
|
await fs.writeFile(manifestPath, yaml.dump({}));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('any.key', 'default')).toBe('default');
|
|
});
|
|
|
|
test('should differentiate between null and undefined', async () => {
|
|
const manifestPath = path.join(tempDir, 'nulls.yaml');
|
|
const manifest = {
|
|
explicit_null: null,
|
|
explicit_value: 'value',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('explicit_null')).toBeNull();
|
|
expect(loader.getConfig('explicit_null', 'default')).toBeNull();
|
|
expect(loader.getConfig('missing_key', 'default')).toBe('default');
|
|
});
|
|
|
|
test('should handle empty arrays', async () => {
|
|
const manifestPath = path.join(tempDir, 'empty_arrays.yaml');
|
|
const manifest = {
|
|
ides: [],
|
|
modules: [],
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('ides')).toEqual([]);
|
|
expect(loader.getConfig('modules')).toEqual([]);
|
|
});
|
|
|
|
test('should handle empty strings', async () => {
|
|
const manifestPath = path.join(tempDir, 'empty_strings.yaml');
|
|
const manifest = {
|
|
empty: '',
|
|
normal: 'value',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('empty')).toBe('');
|
|
expect(loader.getConfig('empty', 'default')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('Caching Behavior - Advanced', () => {
|
|
test('should return cached config on subsequent calls with same path', async () => {
|
|
const manifestPath = path.join(tempDir, 'cache.yaml');
|
|
const manifest = { test: 'value', updated: '2025-10-26' };
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
const first = await loader.loadManifest(manifestPath);
|
|
const second = await loader.loadManifest(manifestPath);
|
|
|
|
expect(first).toEqual(second);
|
|
expect(first).toBe(second); // Same reference
|
|
});
|
|
|
|
test('should reload config when path changes', async () => {
|
|
const path1 = path.join(tempDir, 'manifest1.yaml');
|
|
const path2 = path.join(tempDir, 'manifest2.yaml');
|
|
|
|
const manifest1 = { source: 'manifest1' };
|
|
const manifest2 = { source: 'manifest2' };
|
|
|
|
await fs.writeFile(path1, yaml.dump(manifest1));
|
|
await fs.writeFile(path2, yaml.dump(manifest2));
|
|
|
|
await loader.loadManifest(path1);
|
|
expect(loader.getConfig('source')).toBe('manifest1');
|
|
|
|
await loader.loadManifest(path2);
|
|
expect(loader.getConfig('source')).toBe('manifest2');
|
|
});
|
|
|
|
test('should return cached config after clearCache and hasConfig check', async () => {
|
|
const manifestPath = path.join(tempDir, 'cache2.yaml');
|
|
const manifest = { key: 'value' };
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
loader.clearCache();
|
|
|
|
expect(loader.getConfig('key', 'default')).toBe('default');
|
|
expect(loader.hasConfig('key')).toBe(false);
|
|
});
|
|
|
|
test('should handle rapid sequential loads efficiently', async () => {
|
|
const manifestPath = path.join(tempDir, 'rapid.yaml');
|
|
const manifest = { data: 'value'.repeat(1000) };
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
const results = [];
|
|
for (let i = 0; i < 100; i++) {
|
|
const result = await loader.loadManifest(manifestPath);
|
|
results.push(result);
|
|
}
|
|
|
|
// All should be same reference (cached)
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i]).toBe(results[0]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Error Handling - Invalid Files', () => {
|
|
test('should handle non-existent manifest files', async () => {
|
|
const manifestPath = path.join(tempDir, 'nonexistent.yaml');
|
|
|
|
const result = await loader.loadManifest(manifestPath);
|
|
expect(result).toEqual({});
|
|
expect(loader.getConfig('any', 'default')).toBe('default');
|
|
});
|
|
|
|
test('should throw on invalid YAML syntax', async () => {
|
|
const manifestPath = path.join(tempDir, 'invalid.yaml');
|
|
await fs.writeFile(manifestPath, 'invalid: yaml: content: [');
|
|
|
|
await expect(loader.loadManifest(manifestPath)).rejects.toThrow('Invalid YAML in manifest');
|
|
});
|
|
|
|
test('should throw on malformed YAML structures', async () => {
|
|
const manifestPath = path.join(tempDir, 'malformed.yaml');
|
|
await fs.writeFile(manifestPath, 'key: value\n invalid indentation: here');
|
|
|
|
await expect(loader.loadManifest(manifestPath)).rejects.toThrow();
|
|
});
|
|
|
|
test('should handle binary/non-text files gracefully', async () => {
|
|
const manifestPath = path.join(tempDir, 'binary.yaml');
|
|
await fs.writeFile(manifestPath, Buffer.from([0xff, 0xfe, 0x00, 0x00]));
|
|
|
|
// YAML parser will fail on binary data
|
|
await expect(loader.loadManifest(manifestPath)).rejects.toThrow();
|
|
});
|
|
|
|
test('should handle permission errors', async () => {
|
|
if (process.platform === 'win32') {
|
|
// Skip on Windows as permissions work differently
|
|
expect(true).toBe(true);
|
|
return;
|
|
}
|
|
|
|
const manifestPath = path.join(tempDir, 'noperms.yaml');
|
|
await fs.writeFile(manifestPath, yaml.dump({ test: 'value' }));
|
|
await fs.chmod(manifestPath, 0o000);
|
|
|
|
try {
|
|
await expect(loader.loadManifest(manifestPath)).rejects.toThrow();
|
|
} finally {
|
|
// Restore permissions for cleanup
|
|
await fs.chmod(manifestPath, 0o644);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('hasConfig Method - Advanced', () => {
|
|
test('should correctly identify nested keys existence', async () => {
|
|
const manifestPath = path.join(tempDir, 'hasconfig.yaml');
|
|
const manifest = {
|
|
installation: {
|
|
version: '1.0.0',
|
|
date: '2025-10-26',
|
|
},
|
|
modules: ['bmb', 'bmm'],
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.hasConfig('installation.version')).toBe(true);
|
|
expect(loader.hasConfig('installation.missing')).toBe(false);
|
|
expect(loader.hasConfig('modules')).toBe(true);
|
|
expect(loader.hasConfig('missing')).toBe(false);
|
|
});
|
|
|
|
test('should handle hasConfig on null values', async () => {
|
|
const manifestPath = path.join(tempDir, 'hasnull.yaml');
|
|
const manifest = {
|
|
explicit_null: null,
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.hasConfig('explicit_null')).toBe(true);
|
|
expect(loader.getConfig('explicit_null')).toBeNull();
|
|
});
|
|
|
|
test('should handle hasConfig before loadManifest', () => {
|
|
expect(loader.hasConfig('any.key')).toBe(false);
|
|
});
|
|
|
|
test('should return false for paths through non-objects', async () => {
|
|
const manifestPath = path.join(tempDir, 'paththrough.yaml');
|
|
const manifest = {
|
|
scalar: 'value',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.hasConfig('scalar.nested')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Special Characters and Encoding', () => {
|
|
test('should handle unicode characters in values', async () => {
|
|
const manifestPath = path.join(tempDir, 'unicode.yaml');
|
|
const manifest = {
|
|
emoji: '🎯 BMAD ✨',
|
|
chinese: '中文测试',
|
|
arabic: 'اختبار عربي',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest, { lineWidth: -1 }));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('emoji')).toBe('🎯 BMAD ✨');
|
|
expect(loader.getConfig('chinese')).toBe('中文测试');
|
|
expect(loader.getConfig('arabic')).toBe('اختبار عربي');
|
|
});
|
|
|
|
test('should handle paths with special characters', async () => {
|
|
const manifestPath = path.join(tempDir, 'special_chars.yaml');
|
|
const manifest = {
|
|
'installation-date': '2025-10-26',
|
|
last_updated: '2025-10-26T12:00:00Z',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('installation-date')).toBe('2025-10-26');
|
|
expect(loader.getConfig('last_updated')).toBe('2025-10-26T12:00:00Z');
|
|
});
|
|
|
|
test('should handle multiline strings', async () => {
|
|
const manifestPath = path.join(tempDir, 'multiline.yaml');
|
|
const manifest = {
|
|
description: 'This is a\nmultiline\ndescription',
|
|
config: 'Line 1\nLine 2\nLine 3',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.getConfig('description')).toContain('\n');
|
|
expect(loader.getConfig('description')).toContain('multiline');
|
|
});
|
|
});
|
|
|
|
describe('Performance and Scale', () => {
|
|
test('should handle large manifest files', async () => {
|
|
const manifestPath = path.join(tempDir, 'large.yaml');
|
|
const manifest = {
|
|
modules: Array.from({ length: 1000 }, (_, i) => `module-${i}`),
|
|
configs: {},
|
|
};
|
|
|
|
// Add 500 config entries
|
|
for (let i = 0; i < 500; i++) {
|
|
manifest.configs[`config-${i}`] = `value-${i}`;
|
|
}
|
|
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
const start = Date.now();
|
|
await loader.loadManifest(manifestPath);
|
|
const loadTime = Date.now() - start;
|
|
|
|
expect(loader.getConfig('modules.0')).toBe('module-0');
|
|
expect(loader.getConfig('modules.999')).toBe('module-999');
|
|
expect(loader.getConfig('configs.config-250')).toBe('value-250');
|
|
expect(loadTime).toBeLessThan(1000); // Should load in under 1 second
|
|
});
|
|
|
|
test('should handle many sequential getConfig calls efficiently', async () => {
|
|
const manifestPath = path.join(tempDir, 'perf.yaml');
|
|
const manifest = {
|
|
a: { b: { c: { d: 'value' } } },
|
|
x: 'test',
|
|
};
|
|
await fs.writeFile(manifestPath, yaml.dump(manifest));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
|
|
const start = Date.now();
|
|
for (let i = 0; i < 10_000; i++) {
|
|
loader.getConfig('a.b.c.d');
|
|
}
|
|
const time = Date.now() - start;
|
|
|
|
expect(time).toBeLessThan(100); // Should be very fast (cached)
|
|
});
|
|
});
|
|
|
|
describe('State Management', () => {
|
|
test('should maintain separate state for multiple loaders', async () => {
|
|
const loader1 = new ManifestConfigLoader();
|
|
const loader2 = new ManifestConfigLoader();
|
|
|
|
const path1 = path.join(tempDir, 'loader1.yaml');
|
|
const path2 = path.join(tempDir, 'loader2.yaml');
|
|
|
|
await fs.writeFile(path1, yaml.dump({ source: 'loader1' }));
|
|
await fs.writeFile(path2, yaml.dump({ source: 'loader2' }));
|
|
|
|
await loader1.loadManifest(path1);
|
|
await loader2.loadManifest(path2);
|
|
|
|
expect(loader1.getConfig('source')).toBe('loader1');
|
|
expect(loader2.getConfig('source')).toBe('loader2');
|
|
});
|
|
|
|
test('should clear cache properly', async () => {
|
|
const manifestPath = path.join(tempDir, 'clear.yaml');
|
|
await fs.writeFile(manifestPath, yaml.dump({ test: 'value' }));
|
|
|
|
await loader.loadManifest(manifestPath);
|
|
expect(loader.hasConfig('test')).toBe(true);
|
|
|
|
loader.clearCache();
|
|
expect(loader.hasConfig('test')).toBe(false);
|
|
expect(loader.getConfig('test', 'default')).toBe('default');
|
|
});
|
|
});
|
|
});
|