diff --git a/tests/core/ids/circuit-breaker.test.js b/tests/core/ids/circuit-breaker.test.js new file mode 100644 index 0000000000..938f76e21a --- /dev/null +++ b/tests/core/ids/circuit-breaker.test.js @@ -0,0 +1,254 @@ +/** + * Unit tests for circuit-breaker module + * + * Tests the CircuitBreaker pattern implementation with + * CLOSED, OPEN, and HALF_OPEN states. + */ + +const { + CircuitBreaker, + STATE_CLOSED, + STATE_OPEN, + STATE_HALF_OPEN, + DEFAULT_FAILURE_THRESHOLD, + DEFAULT_SUCCESS_THRESHOLD, + DEFAULT_RESET_TIMEOUT_MS, +} = require('../../../.aiox-core/core/ids/circuit-breaker'); + +describe('CircuitBreaker', () => { + let breaker; + const RESET_TIMEOUT_DELTA_MS = 1000; + + beforeEach(() => { + breaker = new CircuitBreaker(); + }); + + // ============================================================ + // Constants + // ============================================================ + describe('constants', () => { + test('state constants are defined', () => { + expect(STATE_CLOSED).toBe('CLOSED'); + expect(STATE_OPEN).toBe('OPEN'); + expect(STATE_HALF_OPEN).toBe('HALF_OPEN'); + }); + + test('default thresholds are defined', () => { + expect(DEFAULT_FAILURE_THRESHOLD).toBe(5); + expect(DEFAULT_SUCCESS_THRESHOLD).toBe(3); + expect(DEFAULT_RESET_TIMEOUT_MS).toBe(60000); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('starts in CLOSED state', () => { + expect(breaker.getState()).toBe(STATE_CLOSED); + }); + + test('uses default thresholds', () => { + const stats = breaker.getStats(); + expect(stats.failureCount).toBe(0); + expect(stats.successCount).toBe(0); + expect(stats.totalTrips).toBe(0); + }); + + test('accepts custom options', () => { + const custom = new CircuitBreaker({ + failureThreshold: 3, + successThreshold: 2, + resetTimeoutMs: 30000, + }); + // Trip it to verify custom threshold + for (let i = 0; i < 3; i++) custom.recordFailure(); + expect(custom.getState()).toBe(STATE_OPEN); + }); + }); + + // ============================================================ + // isAllowed + // ============================================================ + describe('isAllowed', () => { + test('allows requests when CLOSED', () => { + expect(breaker.isAllowed()).toBe(true); + }); + + test('blocks requests when OPEN', () => { + // Trip the breaker + for (let i = 0; i < 5; i++) breaker.recordFailure(); + expect(breaker.getState()).toBe(STATE_OPEN); + expect(breaker.isAllowed()).toBe(false); + }); + + test('transitions to HALF_OPEN after reset timeout', () => { + for (let i = 0; i < 5; i++) breaker.recordFailure(); + expect(breaker.getState()).toBe(STATE_OPEN); + + // Simulate timeout passing + breaker._lastFailureTime = Date.now() - (DEFAULT_RESET_TIMEOUT_MS + RESET_TIMEOUT_DELTA_MS); + + expect(breaker.isAllowed()).toBe(true); + expect(breaker.getState()).toBe(STATE_HALF_OPEN); + }); + + test('allows one probe in HALF_OPEN', () => { + breaker._state = STATE_HALF_OPEN; + breaker._halfOpenProbeInFlight = false; + + expect(breaker.isAllowed()).toBe(true); + expect(breaker.isAllowed()).toBe(false); // second request blocked + }); + }); + + // ============================================================ + // recordSuccess + // ============================================================ + describe('recordSuccess', () => { + test('resets failure count in CLOSED state', () => { + breaker.recordFailure(); + breaker.recordFailure(); + expect(breaker.getStats().failureCount).toBe(2); + + breaker.recordSuccess(); + expect(breaker.getStats().failureCount).toBe(0); + }); + + test('counts successes in HALF_OPEN', () => { + breaker._state = STATE_HALF_OPEN; + breaker._halfOpenProbeInFlight = true; + + breaker.recordSuccess(); + expect(breaker.getStats().successCount).toBe(1); + }); + + test('closes circuit after success threshold in HALF_OPEN', () => { + breaker._state = STATE_HALF_OPEN; + + for (let i = 0; i < 3; i++) { + breaker._halfOpenProbeInFlight = true; + breaker.recordSuccess(); + } + + expect(breaker.getState()).toBe(STATE_CLOSED); + expect(breaker.getStats().failureCount).toBe(0); + expect(breaker.getStats().successCount).toBe(0); + }); + }); + + // ============================================================ + // recordFailure + // ============================================================ + describe('recordFailure', () => { + test('increments failure count', () => { + breaker.recordFailure(); + expect(breaker.getStats().failureCount).toBe(1); + }); + + test('opens circuit at threshold', () => { + for (let i = 0; i < 4; i++) breaker.recordFailure(); + expect(breaker.getState()).toBe(STATE_CLOSED); + + breaker.recordFailure(); // 5th failure + expect(breaker.getState()).toBe(STATE_OPEN); + }); + + test('increments totalTrips when opening', () => { + for (let i = 0; i < 5; i++) breaker.recordFailure(); + expect(breaker.getStats().totalTrips).toBe(1); + }); + + test('re-opens circuit from HALF_OPEN on failure', () => { + breaker._state = STATE_HALF_OPEN; + breaker._halfOpenProbeInFlight = true; + + breaker.recordFailure(); + + expect(breaker.getState()).toBe(STATE_OPEN); + expect(breaker.getStats().totalTrips).toBe(1); + }); + + test('records lastFailureTime', () => { + const before = Date.now(); + breaker.recordFailure(); + const after = Date.now(); + + expect(breaker.getStats().lastFailureTime).toBeGreaterThanOrEqual(before); + expect(breaker.getStats().lastFailureTime).toBeLessThanOrEqual(after); + }); + }); + + // ============================================================ + // getStats + // ============================================================ + describe('getStats', () => { + test('returns complete stats object', () => { + const stats = breaker.getStats(); + + expect(stats).toHaveProperty('state'); + expect(stats).toHaveProperty('failureCount'); + expect(stats).toHaveProperty('successCount'); + expect(stats).toHaveProperty('totalTrips'); + expect(stats).toHaveProperty('lastFailureTime'); + }); + + test('lastFailureTime is null initially', () => { + expect(breaker.getStats().lastFailureTime).toBeNull(); + }); + }); + + // ============================================================ + // reset + // ============================================================ + describe('reset', () => { + test('resets to CLOSED state', () => { + for (let i = 0; i < 5; i++) breaker.recordFailure(); + expect(breaker.getState()).toBe(STATE_OPEN); + + breaker.reset(); + + expect(breaker.getState()).toBe(STATE_CLOSED); + expect(breaker.getStats().failureCount).toBe(0); + expect(breaker.getStats().successCount).toBe(0); + }); + }); + + // ============================================================ + // Full lifecycle + // ============================================================ + describe('full lifecycle', () => { + test('CLOSED -> OPEN -> HALF_OPEN -> CLOSED', () => { + // 1. Start CLOSED + expect(breaker.getState()).toBe(STATE_CLOSED); + + // 2. Trip to OPEN + for (let i = 0; i < 5; i++) breaker.recordFailure(); + expect(breaker.getState()).toBe(STATE_OPEN); + + // 3. Wait and transition to HALF_OPEN + breaker._lastFailureTime = Date.now() - (DEFAULT_RESET_TIMEOUT_MS + RESET_TIMEOUT_DELTA_MS); + breaker.isAllowed(); + expect(breaker.getState()).toBe(STATE_HALF_OPEN); + + // 4. Simulate 3 consecutive successful probes by resetting the + // half-open probe flag between successes + breaker.recordSuccess(); + breaker._halfOpenProbeInFlight = true; + breaker.recordSuccess(); + breaker._halfOpenProbeInFlight = true; + breaker.recordSuccess(); + expect(breaker.getState()).toBe(STATE_CLOSED); + }); + + test('CLOSED -> OPEN -> HALF_OPEN -> OPEN (failure in half-open)', () => { + for (let i = 0; i < 5; i++) breaker.recordFailure(); + breaker._lastFailureTime = Date.now() - (DEFAULT_RESET_TIMEOUT_MS + RESET_TIMEOUT_DELTA_MS); + breaker.isAllowed(); // HALF_OPEN + + breaker.recordFailure(); // re-opens + expect(breaker.getState()).toBe(STATE_OPEN); + expect(breaker.getStats().totalTrips).toBe(2); + }); + }); +}); diff --git a/tests/core/orchestration/agent-invoker.test.js b/tests/core/orchestration/agent-invoker.test.js new file mode 100644 index 0000000000..b1bdfc9e99 --- /dev/null +++ b/tests/core/orchestration/agent-invoker.test.js @@ -0,0 +1,446 @@ +/** + * Unit tests for agent-invoker module + * + * Tests the AgentInvoker class that provides an interface to invoke + * agents for tasks during orchestration, with retry logic and audit logging. + */ + +jest.mock('fs-extra'); +jest.mock('js-yaml'); + +const fs = require('fs-extra'); +const yaml = require('js-yaml'); + +const { + AgentInvoker, + SUPPORTED_AGENTS, + InvocationStatus, +} = require('../../../.aiox-core/core/orchestration/agent-invoker'); + +describe('AgentInvoker', () => { + let invoker; + + beforeEach(() => { + jest.resetAllMocks(); + invoker = new AgentInvoker({ projectRoot: '/project' }); + }); + + // ============================================================ + // Constants + // ============================================================ + describe('constants', () => { + test('SUPPORTED_AGENTS has all expected agents', () => { + expect(SUPPORTED_AGENTS.pm).toBeDefined(); + expect(SUPPORTED_AGENTS.architect).toBeDefined(); + expect(SUPPORTED_AGENTS.analyst).toBeDefined(); + expect(SUPPORTED_AGENTS.dev).toBeDefined(); + expect(SUPPORTED_AGENTS.qa).toBeDefined(); + expect(SUPPORTED_AGENTS.devops).toBeDefined(); + expect(SUPPORTED_AGENTS.po).toBeDefined(); + }); + + test('each agent has required fields', () => { + for (const [, agent] of Object.entries(SUPPORTED_AGENTS)) { + expect(agent.name).toBeDefined(); + expect(agent.displayName).toBeDefined(); + expect(agent.file).toBeDefined(); + expect(Array.isArray(agent.capabilities)).toBe(true); + } + }); + + test('InvocationStatus has all statuses', () => { + expect(InvocationStatus.SUCCESS).toBe('success'); + expect(InvocationStatus.FAILED).toBe('failed'); + expect(InvocationStatus.TIMEOUT).toBe('timeout'); + expect(InvocationStatus.SKIPPED).toBe('skipped'); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets project root', () => { + expect(invoker.projectRoot).toBe('/project'); + }); + + test('sets default timeout to 300000', () => { + expect(invoker.defaultTimeout).toBe(300000); + }); + + test('sets default maxRetries to 3', () => { + expect(invoker.maxRetries).toBe(3); + }); + + test('initializes empty invocations and logs', () => { + expect(invoker.invocations).toEqual([]); + expect(invoker.logs).toEqual([]); + }); + + test('accepts custom options', () => { + const custom = new AgentInvoker({ + projectRoot: '/custom', + defaultTimeout: 60000, + maxRetries: 5, + validateOutput: false, + }); + expect(custom.projectRoot).toBe('/custom'); + expect(custom.defaultTimeout).toBe(60000); + expect(custom.maxRetries).toBe(5); + expect(custom.validateOutput).toBe(false); + }); + }); + + // ============================================================ + // invokeAgent + // ============================================================ + describe('invokeAgent', () => { + beforeEach(() => { + // Default: agent file exists, task file exists + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('# Task content'); + }); + + test('returns success for known agent and existing task', async () => { + const result = await invoker.invokeAgent('dev', 'my-task'); + + expect(result.success).toBe(true); + expect(result.agentName).toBe('dev'); + expect(result.taskPath).toBe('my-task'); + expect(result.invocationId).toBeDefined(); + expect(result.duration).toBeDefined(); + }); + + test('strips @ prefix from agent name', async () => { + const result = await invoker.invokeAgent('@dev', 'my-task'); + expect(result.success).toBe(true); + }); + + test('returns failure for unknown agent', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await invoker.invokeAgent('unknown-agent', 'my-task'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unknown agent'); + }); + + test('returns failure when task not found', async () => { + fs.pathExists.mockImplementation((p) => { + if (p.includes('agents')) return Promise.resolve(true); + return Promise.resolve(false); + }); + fs.readFile.mockResolvedValue('# Agent definition'); + + const result = await invoker.invokeAgent('dev', 'nonexistent-task'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Task not found'); + }); + + test('records invocation in history', async () => { + await invoker.invokeAgent('dev', 'my-task'); + + expect(invoker.invocations).toHaveLength(1); + expect(invoker.invocations[0].agentName).toBe('dev'); + expect(invoker.invocations[0].status).toBe('success'); + }); + + test('emits invocationComplete on success', async () => { + const handler = jest.fn(); + invoker.on('invocationComplete', handler); + + await invoker.invokeAgent('dev', 'my-task'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + agentName: 'dev', + status: 'success', + })); + }); + + test('emits invocationFailed on failure', async () => { + fs.pathExists.mockResolvedValue(false); + const handler = jest.fn(); + invoker.on('invocationFailed', handler); + + await invoker.invokeAgent('unknown-agent', 'task'); + + expect(handler).toHaveBeenCalled(); + }); + + test('uses custom executor when provided', async () => { + const executor = jest.fn().mockResolvedValue({ custom: true }); + invoker.executor = executor; + + const result = await invoker.invokeAgent('dev', 'my-task'); + + expect(result.success).toBe(true); + expect(executor).toHaveBeenCalled(); + }); + + test('logs invocation messages', async () => { + await invoker.invokeAgent('dev', 'my-task'); + + const logs = invoker.getLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs.some(l => l.message.includes('@dev'))).toBe(true); + }); + }); + + // ============================================================ + // _loadAgent + // ============================================================ + describe('_loadAgent', () => { + test('loads known agent with file', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('# Agent definition'); + + const agent = await invoker._loadAgent('dev'); + + expect(agent.name).toBe('dev'); + expect(agent.loaded).toBe(true); + expect(agent.content).toBe('# Agent definition'); + }); + + test('returns agent config without file when file missing', async () => { + fs.pathExists.mockResolvedValue(false); + + const agent = await invoker._loadAgent('dev'); + + expect(agent.name).toBe('dev'); + expect(agent.loaded).toBe(false); + expect(agent.content).toBeNull(); + }); + + test('returns null for unknown agent', async () => { + const agent = await invoker._loadAgent('nobody'); + expect(agent).toBeNull(); + }); + + test('handles @ prefix', async () => { + fs.pathExists.mockResolvedValue(false); + + const agent = await invoker._loadAgent('@architect'); + expect(agent.name).toBe('architect'); + }); + }); + + // ============================================================ + // _parseTaskMetadata + // ============================================================ + describe('_parseTaskMetadata', () => { + test('parses YAML frontmatter', () => { + yaml.load.mockReturnValue({ title: 'My Task', agent: 'dev' }); + const content = '---\ntitle: My Task\nagent: dev\n---\n# Content'; + + const meta = invoker._parseTaskMetadata(content); + expect(meta.title).toBe('My Task'); + expect(meta.agent).toBe('dev'); + }); + + test('extracts title from heading when no frontmatter title', () => { + const content = '# My Task Title\nSome content'; + + const meta = invoker._parseTaskMetadata(content); + expect(meta.title).toBe('My Task Title'); + }); + + test('handles missing frontmatter', () => { + const content = '# Simple Task\nJust content'; + + const meta = invoker._parseTaskMetadata(content); + expect(meta.title).toBe('Simple Task'); + expect(meta.inputs).toEqual([]); + }); + + test('handles invalid YAML gracefully', () => { + yaml.load.mockImplementation(() => { throw new Error('parse error'); }); + const content = '---\n{invalid\n---\n# Task'; + + const meta = invoker._parseTaskMetadata(content); + expect(meta.title).toBe('Task'); + }); + }); + + // ============================================================ + // _buildContext + // ============================================================ + describe('_buildContext', () => { + test('builds structured context', () => { + const agent = { name: 'dev', displayName: 'Developer', capabilities: ['coding'] }; + const task = { name: 'impl', path: '/task.md', title: 'Implement' }; + const inputs = { feature: 'auth' }; + + const ctx = invoker._buildContext(agent, task, inputs); + + expect(ctx.agent.name).toBe('dev'); + expect(ctx.task.name).toBe('impl'); + expect(ctx.inputs.feature).toBe('auth'); + expect(ctx.projectRoot).toBe('/project'); + expect(ctx.orchestration.timeout).toBe(300000); + }); + }); + + // ============================================================ + // _isTransientError + // ============================================================ + describe('_isTransientError', () => { + test('timeout is transient', () => { + expect(invoker._isTransientError(new Error('Connection timeout'))).toBe(true); + }); + + test('ECONNRESET is transient', () => { + expect(invoker._isTransientError(new Error('ECONNRESET'))).toBe(true); + }); + + test('rate limit is transient', () => { + expect(invoker._isTransientError(new Error('rate limit exceeded'))).toBe(true); + }); + + test('503 is transient', () => { + expect(invoker._isTransientError(new Error('HTTP 503'))).toBe(true); + }); + + test('unknown error is not transient', () => { + expect(invoker._isTransientError(new Error('null pointer'))).toBe(false); + }); + + test('syntax error is not transient', () => { + expect(invoker._isTransientError(new Error('SyntaxError: unexpected'))).toBe(false); + }); + }); + + // ============================================================ + // _validateTaskOutput + // ============================================================ + describe('_validateTaskOutput', () => { + test('passes valid output', () => { + const schema = { + required: ['status'], + properties: { status: { type: 'string' } }, + }; + + expect(() => invoker._validateTaskOutput({ status: 'ok' }, schema)).not.toThrow(); + }); + + test('throws on missing required field', () => { + const schema = { required: ['status'] }; + + expect(() => invoker._validateTaskOutput({}, schema)).toThrow('Missing required field'); + }); + + test('throws on wrong type', () => { + const schema = { + properties: { count: { type: 'number' } }, + }; + + expect(() => invoker._validateTaskOutput({ count: 'not-a-number' }, schema)).toThrow('expected number'); + }); + + test('handles array type check', () => { + const schema = { + properties: { items: { type: 'array' } }, + }; + + expect(() => invoker._validateTaskOutput({ items: [1, 2] }, schema)).not.toThrow(); + expect(() => invoker._validateTaskOutput({ items: 'not-array' }, schema)).toThrow('expected array'); + }); + + test('skips when no schema', () => { + expect(() => invoker._validateTaskOutput({}, null)).not.toThrow(); + }); + }); + + // ============================================================ + // isAgentSupported / getSupportedAgents + // ============================================================ + describe('agent queries', () => { + test('isAgentSupported returns true for known agents', () => { + expect(invoker.isAgentSupported('dev')).toBe(true); + expect(invoker.isAgentSupported('@qa')).toBe(true); + expect(invoker.isAgentSupported('ARCHITECT')).toBe(true); + }); + + test('isAgentSupported returns false for unknown agents', () => { + expect(invoker.isAgentSupported('nobody')).toBe(false); + }); + + test('getSupportedAgents returns copy', () => { + const agents = invoker.getSupportedAgents(); + agents.custom = { name: 'custom' }; + expect(SUPPORTED_AGENTS.custom).toBeUndefined(); + }); + }); + + // ============================================================ + // Invocation audit (AC7) + // ============================================================ + describe('invocation audit', () => { + beforeEach(() => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('# Task'); + }); + + test('getInvocations returns copy', async () => { + await invoker.invokeAgent('dev', 'task'); + + const invocations = invoker.getInvocations(); + invocations.push({ fake: true }); + + expect(invoker.invocations).toHaveLength(1); + }); + + test('getInvocation finds by id', async () => { + await invoker.invokeAgent('dev', 'task'); + + const id = invoker.invocations[0].id; + expect(invoker.getInvocation(id)).toBeDefined(); + expect(invoker.getInvocation('nonexistent')).toBeNull(); + }); + + test('getInvocationsForAgent filters by agent', async () => { + await invoker.invokeAgent('dev', 'task1'); + await invoker.invokeAgent('qa', 'task2'); + await invoker.invokeAgent('dev', 'task3'); + + expect(invoker.getInvocationsForAgent('dev')).toHaveLength(2); + expect(invoker.getInvocationsForAgent('@qa')).toHaveLength(1); + }); + + test('getInvocationSummary returns stats', async () => { + await invoker.invokeAgent('dev', 'task1'); + await invoker.invokeAgent('qa', 'task2'); + + const summary = invoker.getInvocationSummary(); + + expect(summary.total).toBe(2); + expect(summary.byAgent.dev).toBe(1); + expect(summary.byAgent.qa).toBe(1); + expect(summary.totalDuration).toBeGreaterThanOrEqual(0); + }); + + test('getInvocationSummary handles empty', () => { + const summary = invoker.getInvocationSummary(); + + expect(summary.total).toBe(0); + expect(summary.averageDuration).toBe(0); + }); + + test('clearInvocations removes all', async () => { + await invoker.invokeAgent('dev', 'task'); + invoker.clearInvocations(); + + expect(invoker.invocations).toHaveLength(0); + expect(invoker.logs).toHaveLength(0); + }); + + test('getLogs returns copy', async () => { + await invoker.invokeAgent('dev', 'task'); + + const logs = invoker.getLogs(); + logs.push({ fake: true }); + + expect(invoker.logs).not.toContainEqual({ fake: true }); + }); + }); +}); diff --git a/tests/core/orchestration/checklist-runner.test.js b/tests/core/orchestration/checklist-runner.test.js new file mode 100644 index 0000000000..9c93582bf4 --- /dev/null +++ b/tests/core/orchestration/checklist-runner.test.js @@ -0,0 +1,372 @@ +/** + * Unit tests for checklist-runner module + * + * Tests the ChecklistRunner class that executes validation checklists + * programmatically using code-based rules. + */ + +jest.mock('fs-extra'); +jest.mock('js-yaml'); + +const fs = require('fs-extra'); +const path = require('path'); +const yaml = require('js-yaml'); + +const ChecklistRunner = require('../../../.aiox-core/core/orchestration/checklist-runner'); + +describe('ChecklistRunner', () => { + let runner; + + beforeEach(() => { + jest.resetAllMocks(); + runner = new ChecklistRunner('/project'); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets project root and checklists path', () => { + expect(runner.projectRoot).toBe('/project'); + expect(runner.checklistsPath).toContain(path.join('product', 'checklists')); + }); + }); + + // ============================================================ + // loadChecklist + // ============================================================ + describe('loadChecklist', () => { + test('loads checklist file', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('- [ ] Check 1'); + + const content = await runner.loadChecklist('quality-gate'); + expect(content).toBe('- [ ] Check 1'); + }); + + test('appends .md extension', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('content'); + + await runner.loadChecklist('quality-gate'); + expect(fs.pathExists).toHaveBeenCalledWith(expect.stringContaining('quality-gate.md')); + }); + + test('does not double append .md', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('content'); + + await runner.loadChecklist('quality-gate.md'); + expect(fs.pathExists).toHaveBeenCalledWith(expect.stringContaining('quality-gate.md')); + expect(fs.pathExists).not.toHaveBeenCalledWith(expect.stringContaining('.md.md')); + }); + + test('returns null when not found', async () => { + fs.pathExists.mockResolvedValue(false); + expect(await runner.loadChecklist('missing')).toBeNull(); + }); + }); + + // ============================================================ + // parseChecklistItems + // ============================================================ + describe('parseChecklistItems', () => { + test('parses markdown checkboxes', () => { + const content = '- [ ] Check files exist\n- [x] Validate schema\n* [ ] Review code'; + const items = runner.parseChecklistItems(content); + + expect(items).toHaveLength(3); + expect(items[0].description).toBe('Check files exist'); + expect(items[0].tipo).toBe('manual'); + expect(items[0].blocker).toBe(false); + }); + + test('parses YAML blocks with pre-conditions', () => { + const content = '```yaml\npre-conditions:\n - Check requirements\n```'; + yaml.load.mockReturnValue({ + 'pre-conditions': ['Check requirements'], + }); + + const items = runner.parseChecklistItems(content); + + expect(items).toHaveLength(1); + expect(items[0].tipo).toBe('pre-conditions'); + expect(items[0].blocker).toBe(true); + }); + + test('parses YAML blocks with post-conditions and acceptance-criteria', () => { + const content = '```yml\npost-conditions:\n - Verify output\nacceptance-criteria:\n - Meets spec\n```'; + yaml.load.mockReturnValue({ + 'post-conditions': ['Verify output'], + 'acceptance-criteria': ['Meets spec'], + }); + + const items = runner.parseChecklistItems(content); + expect(items).toHaveLength(2); + }); + + test('handles invalid YAML gracefully', () => { + const content = '```yaml\n{invalid\n```\n- [ ] Manual item'; + yaml.load.mockImplementation(() => { throw new Error('parse error'); }); + + const items = runner.parseChecklistItems(content); + expect(items).toHaveLength(1); + expect(items[0].description).toBe('Manual item'); + }); + + test('skips duplicate items from YAML and markdown', () => { + const item = 'Check files exist for deployment'; + const content = '```yaml\npre-conditions:\n - ' + item + '\n```\n- [ ] ' + item; + yaml.load.mockReturnValue({ + 'pre-conditions': [item], + }); + + const items = runner.parseChecklistItems(content); + expect(items).toHaveLength(1); + }); + }); + + // ============================================================ + // normalizeItem + // ============================================================ + describe('normalizeItem', () => { + test('normalizes string item', () => { + const item = runner.normalizeItem('Check files', 'pre-conditions'); + + expect(item.description).toBe('Check files'); + expect(item.tipo).toBe('pre-conditions'); + expect(item.blocker).toBe(true); + }); + + test('normalizes string item for post-conditions (not blocker)', () => { + const item = runner.normalizeItem('Verify', 'post-conditions'); + expect(item.blocker).toBe(false); + }); + + test('normalizes object item with validation', () => { + const raw = { + '[ ] Output file exists': null, + tipo: 'acceptance-criteria', + blocker: true, + validação: 'file exists', + error_message: 'Output missing', + }; + + const item = runner.normalizeItem(raw, 'acceptance-criteria'); + + expect(item.description).toBe('Output file exists'); + expect(item.validation).toBe('file exists'); + expect(item.errorMessage).toBe('Output missing'); + }); + + test('normalizes object with validation key', () => { + const raw = { + 'Check structure': null, + validation: 'not empty', + }; + + const item = runner.normalizeItem(raw, 'post-conditions'); + expect(item.validation).toBe('not empty'); + }); + }); + + // ============================================================ + // evaluateItem + // ============================================================ + describe('evaluateItem', () => { + test('manual items pass with message', async () => { + const item = { description: 'Review code', tipo: 'manual', blocker: false, validation: null }; + const result = await runner.evaluateItem(item, '/file.js'); + + expect(result.passed).toBe(true); + expect(result.message).toContain('Manual verification'); + }); + + test('items with validation execute it', async () => { + fs.pathExists.mockResolvedValue(true); + const item = { + description: 'File exists', + tipo: 'pre-conditions', + blocker: true, + validation: 'file exists', + }; + + const result = await runner.evaluateItem(item, '/src/index.js'); + expect(result.passed).toBe(true); + }); + + test('handles validation errors', async () => { + fs.pathExists.mockRejectedValue(new Error('permission denied')); + const item = { + description: 'Check file', + tipo: 'pre-conditions', + blocker: true, + validation: 'file exists', + }; + + const result = await runner.evaluateItem(item, '/secret'); + expect(result.passed).toBe(false); + expect(result.message).toContain('Validation error'); + }); + + test('uses custom error message', async () => { + fs.pathExists.mockResolvedValue(false); + const item = { + description: 'Output exists', + blocker: true, + validation: 'file exists', + errorMessage: 'Output file was not generated', + }; + + const result = await runner.evaluateItem(item, '/output.md'); + expect(result.passed).toBe(false); + expect(result.message).toBe('Output file was not generated'); + }); + }); + + // ============================================================ + // executeValidation + // ============================================================ + describe('executeValidation', () => { + test('file exists - passes', async () => { + fs.pathExists.mockResolvedValue(true); + expect(await runner.executeValidation('file exists', '/src/app.js')).toBe(true); + }); + + test('file exists - fails', async () => { + fs.pathExists.mockResolvedValue(false); + expect(await runner.executeValidation('file exists', '/missing.js')).toBe(false); + }); + + test('file exists with array paths', async () => { + fs.pathExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + expect(await runner.executeValidation('file exists', ['/a.js', '/b.js'])).toBe(true); + }); + + test('directory exists - passes', async () => { + fs.stat.mockResolvedValue({ isDirectory: () => true }); + expect(await runner.executeValidation('directory exists', '/src')).toBe(true); + }); + + test('directory exists - fails for file', async () => { + fs.stat.mockResolvedValue({ isDirectory: () => false }); + expect(await runner.executeValidation('directory exists', '/file.txt')).toBe(false); + }); + + test('not empty - passes for non-empty file', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('content here'); + expect(await runner.executeValidation('not empty', '/file.md')).toBe(true); + }); + + test('not empty - fails for empty file', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue(' '); + expect(await runner.executeValidation('not empty', '/file.md')).toBe(false); + }); + + test('contains check - passes', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('# architecture\n## components'); + expect(await runner.executeValidation("contains 'architecture'", '/doc.md')).toBe(true); + }); + + test('contains check - fails', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('# empty doc'); + expect(await runner.executeValidation("contains 'architecture'", '/doc.md')).toBe(false); + }); + + test('contains check - fails for missing file', async () => { + fs.pathExists.mockResolvedValue(false); + expect(await runner.executeValidation("contains 'something'", '/missing.md')).toBe(false); + }); + + test('minimum size check - passes', async () => { + fs.pathExists.mockResolvedValue(true); + fs.stat.mockResolvedValue({ size: 500 }); + expect(await runner.executeValidation('minimum size: 100', '/file.md')).toBe(true); + }); + + test('minimum size check - fails', async () => { + fs.pathExists.mockResolvedValue(true); + fs.stat.mockResolvedValue({ size: 50 }); + expect(await runner.executeValidation('min size: 100', '/file.md')).toBe(false); + }); + + test('minimum size - fails for missing file', async () => { + fs.pathExists.mockResolvedValue(false); + expect(await runner.executeValidation('min size: 100', '/missing.md')).toBe(false); + }); + + test('unknown validation defaults to true', async () => { + expect(await runner.executeValidation('some human description', '/file')).toBe(true); + }); + }); + + // ============================================================ + // run (integration) + // ============================================================ + describe('run', () => { + test('returns error when checklist not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await runner.run('missing-checklist', '/file.js'); + + expect(result.passed).toBe(false); + expect(result.errors).toContain('Checklist not found: missing-checklist'); + }); + + test('runs checklist and passes', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('- [ ] Review code\n- [ ] Check formatting'); + + const result = await runner.run('quality', '/src/app.js'); + + expect(result.passed).toBe(true); + expect(result.items).toHaveLength(2); + expect(result.timestamp).toBeDefined(); + }); + + test('fails when blocker item fails', async () => { + fs.pathExists.mockImplementation((p) => { + if (p.includes('checklists')) return true; + return false; + }); + fs.readFile.mockResolvedValue('```yaml\npre-conditions:\n - Output exists\n```'); + yaml.load.mockReturnValue({ + 'pre-conditions': [{ + 'Output exists': null, + validation: 'file exists', + blocker: true, + }], + }); + + const result = await runner.run('pre-check', '/output.md'); + + expect(result.passed).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + // ============================================================ + // getSummary + // ============================================================ + describe('getSummary', () => { + test('returns null when checklist not found', async () => { + fs.pathExists.mockResolvedValue(false); + expect(await runner.getSummary('missing')).toBeNull(); + }); + + test('returns summary with categories', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('- [ ] Item 1\n- [ ] Item 2\n- [x] Item 3'); + + const summary = await runner.getSummary('quality'); + + expect(summary.name).toBe('quality'); + expect(summary.totalItems).toBe(3); + expect(summary.categories.manual).toBe(3); + }); + }); +}); diff --git a/tests/core/orchestration/cli-commands.test.js b/tests/core/orchestration/cli-commands.test.js new file mode 100644 index 0000000000..84f353a812 --- /dev/null +++ b/tests/core/orchestration/cli-commands.test.js @@ -0,0 +1,389 @@ +/** + * Unit tests for cli-commands module + * + * Tests the CLI command handlers for orchestrator control: + * orchestrate, orchestrate-status, orchestrate-stop, orchestrate-resume. + */ + +jest.mock('fs-extra'); +jest.mock('../../../.aiox-core/core/orchestration/master-orchestrator'); + +const fs = require('fs-extra'); +const MasterOrchestrator = require('../../../.aiox-core/core/orchestration/master-orchestrator'); + +const { + orchestrate, + orchestrateStatus, + orchestrateStop, + orchestrateResume, + commands, +} = require('../../../.aiox-core/core/orchestration/cli-commands'); + +describe('cli-commands', () => { + let consoleSpy; + + beforeEach(() => { + jest.resetAllMocks(); + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + fs.ensureDir.mockResolvedValue(undefined); + fs.writeJson.mockResolvedValue(undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + // ============================================================ + // exports + // ============================================================ + describe('exports', () => { + test('exports all command functions', () => { + expect(typeof orchestrate).toBe('function'); + expect(typeof orchestrateStatus).toBe('function'); + expect(typeof orchestrateStop).toBe('function'); + expect(typeof orchestrateResume).toBe('function'); + }); + + test('exports commands map', () => { + expect(commands.orchestrate).toBe(orchestrate); + expect(commands['orchestrate-status']).toBe(orchestrateStatus); + expect(commands['orchestrate-stop']).toBe(orchestrateStop); + expect(commands['orchestrate-resume']).toBe(orchestrateResume); + }); + }); + + // ============================================================ + // orchestrate + // ============================================================ + describe('orchestrate', () => { + test('returns error when no storyId', async () => { + const result = await orchestrate(null); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + expect(result.error).toContain('Story ID'); + }); + + test('returns error for empty storyId', async () => { + const result = await orchestrate(''); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + }); + + test('executes full pipeline successfully', async () => { + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + executeFullPipeline: jest.fn().mockResolvedValue({ + success: true, + epics: { executed: [3, 4, 6, 7] }, + }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrate('story-1', { projectRoot: '/project' }); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(mockOrchestrator.executeFullPipeline).toHaveBeenCalled(); + }); + + test('resumes from specific epic', async () => { + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + resumeFromEpic: jest.fn().mockResolvedValue({ + success: true, + epics: { executed: [4, 6, 7] }, + }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrate('story-1', { epic: 4, projectRoot: '/p' }); + + expect(result.success).toBe(true); + expect(mockOrchestrator.resumeFromEpic).toHaveBeenCalledWith(4); + }); + + test('handles blocked result', async () => { + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + executeFullPipeline: jest.fn().mockResolvedValue({ + success: false, + blocked: true, + }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrate('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(2); + }); + + test('handles failed result', async () => { + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + executeFullPipeline: jest.fn().mockResolvedValue({ + success: false, + blocked: false, + }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrate('story-1', { projectRoot: '/p' }); + + expect(result.exitCode).toBe(1); + }); + + test('catches exceptions', async () => { + MasterOrchestrator.mockImplementation(() => { + throw new Error('init failed'); + }); + + const result = await orchestrate('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + expect(result.error).toBe('init failed'); + }); + + test('delegates to dry run when flag set', async () => { + const mockOrchestrator = { + initialize: jest.fn().mockResolvedValue(undefined), + executionState: {}, + constructor: { + EPIC_CONFIG: { + 3: { name: 'Spec' }, + 4: { name: 'Execute' }, + 5: { name: 'Recovery', onDemand: true }, + 6: { name: 'QA' }, + 7: { name: 'Memory' }, + }, + }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrate('story-1', { dryRun: true, projectRoot: '/p' }); + + expect(result.success).toBe(true); + expect(result.dryRun).toBe(true); + expect(mockOrchestrator.initialize).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // orchestrateStatus + // ============================================================ + describe('orchestrateStatus', () => { + test('returns error when no storyId', async () => { + const result = await orchestrateStatus(null); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + }); + + test('returns error when state not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await orchestrateStatus('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('State not found'); + }); + + test('displays status for existing state', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ + status: 'in_progress', + currentEpic: 4, + epics: { + 3: { status: 'completed' }, + 4: { status: 'in_progress' }, + }, + startedAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T01:00:00Z', + errors: [], + }); + MasterOrchestrator.EPIC_CONFIG = { + 3: { name: 'Spec' }, + 4: { name: 'Execute' }, + }; + + const result = await orchestrateStatus('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(true); + expect(result.state).toBeDefined(); + }); + + test('catches read errors', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockRejectedValue(new Error('corrupt JSON')); + + const result = await orchestrateStatus('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('corrupt JSON'); + }); + }); + + // ============================================================ + // orchestrateStop + // ============================================================ + describe('orchestrateStop', () => { + test('returns error when no storyId', async () => { + const result = await orchestrateStop(null); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + }); + + test('returns error when state not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await orchestrateStop('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('State not found'); + }); + + test('stops orchestrator and updates state', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ + status: 'in_progress', + currentEpic: 4, + }); + + const result = await orchestrateStop('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(fs.writeJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: 'stopped' }), + expect.any(Object), + ); + }); + + test('catches write errors', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ status: 'in_progress' }); + fs.writeJson.mockRejectedValue(new Error('disk full')); + + const result = await orchestrateStop('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('disk full'); + }); + }); + + // ============================================================ + // orchestrateResume + // ============================================================ + describe('orchestrateResume', () => { + test('returns error when no storyId', async () => { + const result = await orchestrateResume(null); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + }); + + test('returns error when state not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await orchestrateResume('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('State not found'); + }); + + test('returns error when already completed', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ status: 'complete' }); + + const result = await orchestrateResume('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(2); + expect(result.error).toContain('Already completed'); + }); + + test('resumes from current epic', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ + status: 'stopped', + currentEpic: 4, + epics: { 3: { status: 'completed' }, 4: { status: 'in_progress' } }, + updatedAt: '2025-01-01T00:00:00Z', + }); + + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + resumeFromEpic: jest.fn().mockResolvedValue({ + success: true, + epics: { executed: [4, 6, 7] }, + }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + const result = await orchestrateResume('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(true); + expect(mockOrchestrator.resumeFromEpic).toHaveBeenCalledWith(4); + }); + + test('finds next incomplete epic when current is completed', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ + status: 'stopped', + currentEpic: 3, + epics: { + 3: { status: 'completed' }, + 4: { status: 'completed' }, + 6: { status: 'pending' }, + }, + updatedAt: '2025-01-01T00:00:00Z', + }); + + const mockOrchestrator = { + startDashboard: jest.fn().mockResolvedValue(undefined), + stopDashboard: jest.fn(), + resumeFromEpic: jest.fn().mockResolvedValue({ success: true }), + on: jest.fn(), + constructor: { EPIC_CONFIG: {} }, + }; + MasterOrchestrator.mockImplementation(() => mockOrchestrator); + + await orchestrateResume('story-1', { projectRoot: '/p' }); + + expect(mockOrchestrator.resumeFromEpic).toHaveBeenCalledWith(6); + }); + + test('catches exceptions during resume', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ + status: 'stopped', + currentEpic: 4, + updatedAt: '2025-01-01', + }); + + MasterOrchestrator.mockImplementation(() => { + throw new Error('orchestrator init failed'); + }); + + const result = await orchestrateResume('story-1', { projectRoot: '/p' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('orchestrator init failed'); + }); + }); +}); diff --git a/tests/core/orchestration/condition-evaluator.test.js b/tests/core/orchestration/condition-evaluator.test.js new file mode 100644 index 0000000000..640fea1a35 --- /dev/null +++ b/tests/core/orchestration/condition-evaluator.test.js @@ -0,0 +1,462 @@ +/** + * Unit tests for condition-evaluator module + * + * Tests the ConditionEvaluator class that evaluates workflow conditions + * based on detected tech stack profile. + */ + +const ConditionEvaluator = require('../../../.aiox-core/core/orchestration/condition-evaluator'); + +describe('ConditionEvaluator', () => { + const FULL_PROFILE = { + hasDatabase: true, + hasFrontend: true, + hasBackend: true, + hasTypeScript: true, + hasTests: true, + database: { + type: 'supabase', + hasSchema: true, + hasMigrations: true, + hasRLS: true, + envVarsConfigured: true, + }, + frontend: { + framework: 'react', + buildTool: 'vite', + styling: 'tailwind', + componentLibrary: 'shadcn', + }, + backend: { + type: 'express', + hasAPI: true, + }, + }; + + const EMPTY_PROFILE = { + hasDatabase: false, + hasFrontend: false, + hasBackend: false, + hasTypeScript: false, + hasTests: false, + database: { type: null, hasRLS: false, hasMigrations: false, envVarsConfigured: false }, + frontend: { framework: null, styling: null }, + backend: { type: null, hasAPI: false }, + }; + + let evaluator; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); + evaluator = new ConditionEvaluator(FULL_PROFILE); + }); + + afterEach(() => { + console.warn.mockRestore(); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('stores profile and initializes state', () => { + expect(evaluator.profile).toBe(FULL_PROFILE); + expect(evaluator._qaApproved).toBe(false); + expect(evaluator._phaseOutputs).toEqual({}); + }); + }); + + // ============================================================ + // setQAApproval / setPhaseOutputs + // ============================================================ + describe('state setters', () => { + test('setQAApproval updates flag', () => { + evaluator.setQAApproval(true); + expect(evaluator._qaApproved).toBe(true); + }); + + test('setPhaseOutputs updates outputs', () => { + const outputs = { 1: { status: 'success' } }; + evaluator.setPhaseOutputs(outputs); + expect(evaluator._phaseOutputs).toBe(outputs); + }); + }); + + // ============================================================ + // evaluate - basic conditions + // ============================================================ + describe('evaluate - basic conditions', () => { + test('null/undefined returns true', () => { + expect(evaluator.evaluate(null)).toBe(true); + expect(evaluator.evaluate(undefined)).toBe(true); + expect(evaluator.evaluate('')).toBe(true); + }); + + test('project_has_database', () => { + expect(evaluator.evaluate('project_has_database')).toBe(true); + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('project_has_database')).toBe(false); + }); + + test('project_has_frontend', () => { + expect(evaluator.evaluate('project_has_frontend')).toBe(true); + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('project_has_frontend')).toBe(false); + }); + + test('project_has_backend', () => { + expect(evaluator.evaluate('project_has_backend')).toBe(true); + }); + + test('project_has_typescript', () => { + expect(evaluator.evaluate('project_has_typescript')).toBe(true); + }); + + test('project_has_tests', () => { + expect(evaluator.evaluate('project_has_tests')).toBe(true); + }); + }); + + // ============================================================ + // evaluate - database conditions + // ============================================================ + describe('evaluate - database conditions', () => { + test('supabase_configured', () => { + expect(evaluator.evaluate('supabase_configured')).toBe(true); + }); + + test('supabase_configured false without env vars', () => { + const profile = { + ...FULL_PROFILE, + database: { ...FULL_PROFILE.database, envVarsConfigured: false }, + }; + const ev = new ConditionEvaluator(profile); + expect(ev.evaluate('supabase_configured')).toBe(false); + }); + + test('database_has_rls', () => { + expect(evaluator.evaluate('database_has_rls')).toBe(true); + }); + + test('database_has_migrations', () => { + expect(evaluator.evaluate('database_has_migrations')).toBe(true); + }); + }); + + // ============================================================ + // evaluate - frontend conditions + // ============================================================ + describe('evaluate - frontend conditions', () => { + test('frontend_has_react', () => { + expect(evaluator.evaluate('frontend_has_react')).toBe(true); + }); + + test('frontend_has_vue', () => { + expect(evaluator.evaluate('frontend_has_vue')).toBe(false); + }); + + test('frontend_has_tailwind', () => { + expect(evaluator.evaluate('frontend_has_tailwind')).toBe(true); + }); + }); + + // ============================================================ + // evaluate - workflow state conditions + // ============================================================ + describe('evaluate - workflow state', () => { + test('qa_review_approved from flag', () => { + expect(evaluator.evaluate('qa_review_approved')).toBe(false); + evaluator.setQAApproval(true); + expect(evaluator.evaluate('qa_review_approved')).toBe(true); + }); + + test('qa_review_approved from phase output', () => { + evaluator.setPhaseOutputs({ 7: { status: 'approved' } }); + expect(evaluator.evaluate('qa_review_approved')).toBe(true); + }); + + test('qa_review_approved from result.approved', () => { + evaluator.setPhaseOutputs({ 7: { result: { approved: true } } }); + expect(evaluator.evaluate('qa_review_approved')).toBe(true); + }); + + test('phase_2_completed', () => { + expect(evaluator.evaluate('phase_2_completed')).toBe(false); + evaluator.setPhaseOutputs({ 2: { status: 'success' } }); + expect(evaluator.evaluate('phase_2_completed')).toBe(true); + }); + + test('phase_3_completed with skipped status', () => { + evaluator.setPhaseOutputs({ 3: { status: 'skipped' } }); + expect(evaluator.evaluate('phase_3_completed')).toBe(true); + }); + + test('all_collection_phases_complete', () => { + evaluator.setPhaseOutputs({ + 1: { status: 'success' }, + 2: { status: 'success' }, + 3: { status: 'success' }, + }); + expect(evaluator.evaluate('all_collection_phases_complete')).toBe(true); + }); + + test('all_collection_phases_complete fails when missing', () => { + evaluator.setPhaseOutputs({ + 1: { status: 'success' }, + 2: { status: 'success' }, + }); + expect(evaluator.evaluate('all_collection_phases_complete')).toBe(false); + }); + }); + + // ============================================================ + // evaluate - composite conditions + // ============================================================ + describe('evaluate - composite', () => { + test('has_any_data_to_analyze', () => { + expect(evaluator.evaluate('has_any_data_to_analyze')).toBe(true); + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('has_any_data_to_analyze')).toBe(false); + }); + }); + + // ============================================================ + // evaluate - negation + // ============================================================ + describe('evaluate - negation', () => { + test('negates built-in condition', () => { + expect(evaluator.evaluate('!project_has_database')).toBe(false); + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('!project_has_database')).toBe(true); + }); + }); + + // ============================================================ + // evaluate - logical operators + // ============================================================ + describe('evaluate - logical operators', () => { + test('AND conditions', () => { + expect(evaluator.evaluate('project_has_database && project_has_frontend')).toBe(true); + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('project_has_database && project_has_frontend')).toBe(false); + }); + + test('OR conditions', () => { + const ev = new ConditionEvaluator({ + ...EMPTY_PROFILE, + hasDatabase: true, + database: { type: 'pg' }, + }); + expect(ev.evaluate('project_has_database || project_has_frontend')).toBe(true); + }); + + test('OR all false', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('project_has_database || project_has_frontend')).toBe(false); + }); + + test('mixed AND/OR uses OR-of-ANDs', () => { + // "a && b || c" → ["a && b", "c"] + const ev = new ConditionEvaluator({ + ...EMPTY_PROFILE, + hasBackend: true, + backend: { type: 'express' }, + }); + // project_has_database && project_has_frontend = false + // project_has_backend = true + expect(ev.evaluate('project_has_database && project_has_frontend || project_has_backend')).toBe(true); + expect(console.warn).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // evaluate - dot notation + // ============================================================ + describe('evaluate - dot notation', () => { + test('boolean property access', () => { + expect(evaluator.evaluate('database.hasRLS')).toBe(true); + expect(evaluator.evaluate('database.envVarsConfigured')).toBe(true); + }); + + test('equality check', () => { + expect(evaluator.evaluate('database.type === "supabase"')).toBe(true); + expect(evaluator.evaluate('database.type === "postgresql"')).toBe(false); + }); + + test('equality with single quotes', () => { + expect(evaluator.evaluate("frontend.framework === 'react'")).toBe(true); + }); + + test('returns false for null path', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + expect(ev.evaluate('database.type')).toBe(false); + }); + + test('handles deep null path gracefully', () => { + const ev = new ConditionEvaluator({ database: null }); + expect(ev.evaluate('database.type.subfield')).toBe(false); + }); + }); + + // ============================================================ + // evaluate - unknown conditions + // ============================================================ + describe('evaluate - unknown conditions', () => { + test('unknown condition defaults to false', () => { + expect(evaluator.evaluate('future_condition')).toBe(false); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Unknown condition'), + ); + }); + }); + + // ============================================================ + // shouldExecutePhase + // ============================================================ + describe('shouldExecutePhase', () => { + test('no condition means always execute', () => { + const result = evaluator.shouldExecutePhase({ phase: 1 }); + expect(result.shouldExecute).toBe(true); + expect(result.reason).toBe('no_condition'); + }); + + test('condition met', () => { + const result = evaluator.shouldExecutePhase({ + phase: 2, + condition: 'project_has_database', + }); + expect(result.shouldExecute).toBe(true); + expect(result.reason).toBe('condition_met'); + }); + + test('condition not met', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const result = ev.shouldExecutePhase({ + phase: 2, + condition: 'project_has_database', + }); + expect(result.shouldExecute).toBe(false); + expect(result.reason).toContain('condition_not_met'); + }); + }); + + // ============================================================ + // getFailedConditions + // ============================================================ + describe('getFailedConditions', () => { + test('returns empty for no condition', () => { + expect(evaluator.getFailedConditions({ phase: 1 })).toEqual([]); + }); + + test('returns empty when all pass', () => { + const failed = evaluator.getFailedConditions({ + condition: 'project_has_database && project_has_frontend', + }); + expect(failed).toEqual([]); + }); + + test('returns failed AND conditions', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const failed = ev.getFailedConditions({ + condition: 'project_has_database && project_has_frontend', + }); + expect(failed).toContain('project_has_database'); + expect(failed).toContain('project_has_frontend'); + }); + + test('returns all for OR when all fail', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const failed = ev.getFailedConditions({ + condition: 'project_has_database || project_has_frontend', + }); + expect(failed).toHaveLength(2); + }); + + test('returns empty for OR when one passes', () => { + const failed = evaluator.getFailedConditions({ + condition: 'project_has_database || project_has_frontend', + }); + expect(failed).toEqual([]); + }); + + test('handles mixed AND/OR', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const failed = ev.getFailedConditions({ + condition: 'project_has_database && project_has_frontend || project_has_backend', + }); + expect(failed.length).toBeGreaterThan(0); + }); + + test('returns empty for mixed AND/OR when one group passes', () => { + const failed = evaluator.getFailedConditions({ + condition: 'project_has_database && project_has_frontend || project_has_backend', + }); + expect(failed).toEqual([]); + }); + }); + + // ============================================================ + // getSkipExplanation + // ============================================================ + describe('getSkipExplanation', () => { + test('returns execute message when conditions met', () => { + const explanation = evaluator.getSkipExplanation({ condition: 'project_has_database' }); + expect(explanation).toContain('should execute'); + }); + + test('returns human-readable explanation for known conditions', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const explanation = ev.getSkipExplanation({ condition: 'project_has_database' }); + expect(explanation).toContain('No database detected'); + }); + + test('returns generic explanation for failed conditions', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const explanation = ev.getSkipExplanation({ condition: 'project_has_frontend' }); + expect(explanation).toContain('No frontend framework detected'); + }); + }); + + // ============================================================ + // evaluateAllPhases + // ============================================================ + describe('evaluateAllPhases', () => { + test('categorizes phases into applicable and skipped', () => { + const ev = new ConditionEvaluator(EMPTY_PROFILE); + const phases = [ + { phase: 1, phase_name: 'Architecture' }, + { phase: 2, phase_name: 'Database', condition: 'project_has_database' }, + { phase: 3, phase_name: 'Frontend', condition: 'project_has_frontend' }, + { phase: 4, phase_name: 'Consolidation' }, + ]; + + const summary = ev.evaluateAllPhases(phases); + + expect(summary.applicable).toContain(1); + expect(summary.applicable).toContain(4); + expect(summary.skipped).toContain(2); + expect(summary.skipped).toContain(3); + expect(summary.details[1].shouldExecute).toBe(true); + expect(summary.details[2].shouldExecute).toBe(false); + }); + + test('all phases applicable with full stack', () => { + const phases = [ + { phase: 1, phase_name: 'Arch' }, + { phase: 2, phase_name: 'DB', condition: 'project_has_database' }, + { phase: 3, phase_name: 'FE', condition: 'project_has_frontend' }, + ]; + + const summary = evaluator.evaluateAllPhases(phases); + + expect(summary.applicable).toEqual([1, 2, 3]); + expect(summary.skipped).toEqual([]); + }); + + test('uses step when phase not set', () => { + const phases = [{ step: 'init' }]; + const summary = evaluator.evaluateAllPhases(phases); + expect(summary.details['init']).toBeDefined(); + }); + }); +}); diff --git a/tests/core/orchestration/dashboard-integration.test.js b/tests/core/orchestration/dashboard-integration.test.js new file mode 100644 index 0000000000..98e31ffe8c --- /dev/null +++ b/tests/core/orchestration/dashboard-integration.test.js @@ -0,0 +1,475 @@ +/** + * Unit tests for dashboard-integration module + * + * Tests the DashboardIntegration class that provides real-time + * orchestrator monitoring with status files, history, and notifications. + */ + +jest.mock('fs-extra'); +jest.mock('../../../.aiox-core/core/events', () => ({ + getDashboardEmitter: () => ({ + emitStoryStatusChange: jest.fn(), + emitCommandStart: jest.fn(), + emitCommandComplete: jest.fn(), + emitCommandError: jest.fn(), + emitAgentActivated: jest.fn(), + emitAgentDeactivated: jest.fn(), + }), +})); + +const fs = require('fs-extra'); +const { DashboardIntegration, NotificationType } = require('../../../.aiox-core/core/orchestration/dashboard-integration'); + +describe('DashboardIntegration', () => { + let dashboard; + + beforeEach(() => { + jest.resetAllMocks(); + jest.useFakeTimers(); + fs.ensureDir.mockResolvedValue(); + fs.writeJson.mockResolvedValue(); + dashboard = new DashboardIntegration({ projectRoot: '/project' }); + }); + + afterEach(() => { + dashboard.stop(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // ============================================================ + // NotificationType + // ============================================================ + describe('NotificationType', () => { + test('has all expected types', () => { + expect(NotificationType.INFO).toBe('info'); + expect(NotificationType.SUCCESS).toBe('success'); + expect(NotificationType.WARNING).toBe('warning'); + expect(NotificationType.ERROR).toBe('error'); + expect(NotificationType.BLOCKED).toBe('blocked'); + expect(NotificationType.COMPLETE).toBe('complete'); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets project root', () => { + expect(dashboard.projectRoot).toBe('/project'); + }); + + test('sets dashboard paths', () => { + expect(dashboard.statusPath).toContain('dashboard'); + expect(dashboard.statusPath).toContain('status.json'); + }); + + test('initializes empty state', () => { + expect(dashboard.history).toEqual([]); + expect(dashboard.notifications).toEqual([]); + expect(dashboard.isRunning).toBe(false); + }); + + test('defaults autoUpdate to true', () => { + expect(dashboard.autoUpdate).toBe(true); + }); + + test('accepts custom options', () => { + const custom = new DashboardIntegration({ + projectRoot: '/custom', + autoUpdate: false, + updateInterval: 10000, + }); + expect(custom.projectRoot).toBe('/custom'); + expect(custom.autoUpdate).toBe(false); + expect(custom.updateInterval).toBe(10000); + }); + }); + + // ============================================================ + // start / stop + // ============================================================ + describe('start / stop', () => { + test('start creates directories and marks running', async () => { + await dashboard.start(); + + expect(fs.ensureDir).toHaveBeenCalledTimes(2); + expect(dashboard.isRunning).toBe(true); + }); + + test('start emits started event', async () => { + const handler = jest.fn(); + dashboard.on('started', handler); + + await dashboard.start(); + + expect(handler).toHaveBeenCalled(); + }); + + test('start is idempotent', async () => { + await dashboard.start(); + await dashboard.start(); + + // ensureDir called twice (2 dirs) only from first start(); second call is no-op + expect(fs.ensureDir).toHaveBeenCalledTimes(2); + }); + + test('stop clears timer and marks not running', async () => { + await dashboard.start(); + dashboard.stop(); + + expect(dashboard.isRunning).toBe(false); + expect(dashboard.updateTimer).toBeNull(); + }); + + test('stop emits stopped event', () => { + const handler = jest.fn(); + dashboard.on('stopped', handler); + + dashboard.stop(); + + expect(handler).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // updateStatus + // ============================================================ + describe('updateStatus', () => { + test('returns undefined when no orchestrator', async () => { + const result = await dashboard.updateStatus(); + expect(result).toBeUndefined(); + }); + + test('writes status to file when orchestrator exists', async () => { + dashboard.orchestrator = createMockOrchestrator(); + + await dashboard.updateStatus(); + + expect(fs.writeJson).toHaveBeenCalledWith( + dashboard.statusPath, + expect.any(Object), + { spaces: 2 }, + ); + }); + + test('emits statusUpdated event', async () => { + dashboard.orchestrator = createMockOrchestrator(); + const handler = jest.fn(); + dashboard.on('statusUpdated', handler); + + await dashboard.updateStatus(); + + expect(handler).toHaveBeenCalledWith(expect.any(Object)); + }); + + test('handles write errors gracefully with error listener', async () => { + dashboard.orchestrator = createMockOrchestrator(); + fs.writeJson.mockRejectedValue(new Error('write failed')); + const errorHandler = jest.fn(); + dashboard.on('error', errorHandler); + + await dashboard.updateStatus(); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ type: 'statusUpdate' }), + ); + }); + + test('handles write errors with console.warn when no error listener', async () => { + dashboard.orchestrator = createMockOrchestrator(); + fs.writeJson.mockRejectedValue(new Error('write failed')); + jest.spyOn(console, 'warn').mockImplementation(); + + await dashboard.updateStatus(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('statusUpdate'), + ); + }); + }); + + // ============================================================ + // buildStatus + // ============================================================ + describe('buildStatus', () => { + test('returns empty object when no orchestrator', () => { + expect(dashboard.buildStatus()).toEqual({}); + }); + + test('builds status with orchestrator data', () => { + dashboard.orchestrator = createMockOrchestrator(); + + const status = dashboard.buildStatus(); + + expect(status.orchestrator).toBeDefined(); + expect(status.orchestrator['story-1']).toBeDefined(); + expect(status.orchestrator['story-1'].status).toBe('running'); + expect(status.orchestrator['story-1'].currentEpic).toBe(3); + }); + + test('includes progress information', () => { + dashboard.orchestrator = createMockOrchestrator(); + + const status = dashboard.buildStatus(); + const storyStatus = status.orchestrator['story-1']; + + expect(storyStatus.progress).toBeDefined(); + expect(storyStatus.progress.overall).toBe(50); + }); + + test('includes history and notifications', () => { + dashboard.orchestrator = createMockOrchestrator(); + dashboard.addToHistory({ type: 'test' }); + dashboard.addNotification({ type: 'info', title: 'Test' }); + + const status = dashboard.buildStatus(); + const storyStatus = status.orchestrator['story-1']; + + expect(storyStatus.history.length).toBe(1); + expect(storyStatus.notifications.length).toBe(1); + }); + + test('includes logs path', () => { + dashboard.orchestrator = createMockOrchestrator(); + + const status = dashboard.buildStatus(); + const storyStatus = status.orchestrator['story-1']; + + expect(storyStatus.logsPath).toContain('story-1.log'); + }); + + test('includes blocked flag', () => { + const orch = createMockOrchestrator(); + orch.state = 'blocked'; + dashboard.orchestrator = orch; + + const status = dashboard.buildStatus(); + expect(status.orchestrator['story-1'].blocked).toBe(true); + }); + }); + + // ============================================================ + // addToHistory / getHistory + // ============================================================ + describe('history', () => { + test('addToHistory adds entry with id', () => { + dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); + + expect(dashboard.history).toHaveLength(1); + expect(dashboard.history[0].id).toMatch(/^hist-/); + expect(dashboard.history[0].type).toBe('epicComplete'); + }); + + test('getHistory returns copy', () => { + dashboard.addToHistory({ type: 'test' }); + + const history = dashboard.getHistory(); + history.push({ type: 'extra' }); + + expect(dashboard.history).toHaveLength(1); + }); + + test('getHistoryForEpic filters by epicNum', () => { + dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); + dashboard.addToHistory({ type: 'epicFailed', epicNum: 4 }); + dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); + + expect(dashboard.getHistoryForEpic(3)).toHaveLength(2); + expect(dashboard.getHistoryForEpic(4)).toHaveLength(1); + expect(dashboard.getHistoryForEpic(5)).toHaveLength(0); + }); + + test('history is capped at 100 entries', () => { + for (let i = 0; i < 110; i++) { + dashboard.addToHistory({ type: 'test', index: i }); + } + + expect(dashboard.history).toHaveLength(100); + // Oldest entries should be trimmed + expect(dashboard.history[0].index).toBe(10); + }); + }); + + // ============================================================ + // Notifications + // ============================================================ + describe('notifications', () => { + test('addNotification adds with id and read=false', () => { + dashboard.addNotification({ type: 'info', title: 'Test' }); + + expect(dashboard.notifications).toHaveLength(1); + expect(dashboard.notifications[0].id).toMatch(/^notif-/); + expect(dashboard.notifications[0].read).toBe(false); + }); + + test('addNotification emits notification event', () => { + const handler = jest.fn(); + dashboard.on('notification', handler); + + dashboard.addNotification({ type: 'info', title: 'Test' }); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test' })); + }); + + test('getNotifications returns all', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + dashboard.addNotification({ type: 'error', title: 'B' }); + + expect(dashboard.getNotifications()).toHaveLength(2); + }); + + test('getNotifications unread only', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + dashboard.addNotification({ type: 'error', title: 'B' }); + dashboard.notifications[0].read = true; + + expect(dashboard.getNotifications(true)).toHaveLength(1); + expect(dashboard.getNotifications(true)[0].title).toBe('B'); + }); + + test('getNotifications returns copy', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + + const notifs = dashboard.getNotifications(); + notifs.push({ type: 'extra' }); + + expect(dashboard.notifications).toHaveLength(1); + }); + + test('markNotificationRead marks specific notification', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + const id = dashboard.notifications[0].id; + + dashboard.markNotificationRead(id); + + expect(dashboard.notifications[0].read).toBe(true); + }); + + test('markNotificationRead ignores unknown id', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + + dashboard.markNotificationRead('nonexistent'); + + expect(dashboard.notifications[0].read).toBe(false); + }); + + test('markAllNotificationsRead marks all', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + dashboard.addNotification({ type: 'error', title: 'B' }); + + dashboard.markAllNotificationsRead(); + + expect(dashboard.notifications.every(n => n.read)).toBe(true); + }); + + test('clearNotifications removes all', () => { + dashboard.addNotification({ type: 'info', title: 'A' }); + dashboard.addNotification({ type: 'error', title: 'B' }); + + dashboard.clearNotifications(); + + expect(dashboard.notifications).toHaveLength(0); + }); + + test('notifications are capped at 50', () => { + for (let i = 0; i < 55; i++) { + dashboard.addNotification({ type: 'info', title: `N${i}` }); + } + + expect(dashboard.notifications).toHaveLength(50); + }); + }); + + // ============================================================ + // getProgressPercentage + // ============================================================ + describe('getProgressPercentage', () => { + test('returns 0 when no orchestrator', () => { + expect(dashboard.getProgressPercentage()).toBe(0); + }); + + test('delegates to orchestrator', () => { + dashboard.orchestrator = createMockOrchestrator(); + expect(dashboard.getProgressPercentage()).toBe(50); + }); + }); + + // ============================================================ + // getStatusPath / readStatus + // ============================================================ + describe('getStatusPath / readStatus', () => { + test('getStatusPath returns status file path', () => { + expect(dashboard.getStatusPath()).toBe(dashboard.statusPath); + }); + + test('readStatus returns JSON when file exists', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readJson.mockResolvedValue({ status: 'ok' }); + + const result = await dashboard.readStatus(); + expect(result).toEqual({ status: 'ok' }); + }); + + test('readStatus returns null when file missing', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await dashboard.readStatus(); + expect(result).toBeNull(); + }); + + test('readStatus emits error and returns null on failure', async () => { + fs.pathExists.mockRejectedValue(new Error('read error')); + const errorHandler = jest.fn(); + dashboard.on('error', errorHandler); + + const result = await dashboard.readStatus(); + + expect(result).toBeNull(); + expect(errorHandler).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // clear + // ============================================================ + describe('clear', () => { + test('clears history and notifications', () => { + dashboard.addToHistory({ type: 'test' }); + dashboard.addNotification({ type: 'info', title: 'A' }); + + dashboard.clear(); + + expect(dashboard.history).toHaveLength(0); + expect(dashboard.notifications).toHaveLength(0); + }); + }); +}); + +// ============================================================ +// Helpers +// ============================================================ +function createMockOrchestrator() { + return { + storyId: 'story-1', + state: 'running', + executionState: { + currentEpic: 3, + startedAt: '2026-01-01T00:00:00Z', + epics: { + 1: { status: 'completed' }, + 2: { status: 'completed' }, + 3: { status: 'in_progress' }, + 4: { status: 'pending' }, + }, + errors: [], + }, + constructor: { + EPIC_CONFIG: { + 3: { name: 'Epic 3 - Implementation' }, + }, + }, + getProgressPercentage: jest.fn().mockReturnValue(50), + on: jest.fn(), + }; +} diff --git a/tests/core/orchestration/execution-profile-resolver.test.js b/tests/core/orchestration/execution-profile-resolver.test.js new file mode 100644 index 0000000000..8c3601a6c4 --- /dev/null +++ b/tests/core/orchestration/execution-profile-resolver.test.js @@ -0,0 +1,243 @@ +/** + * Unit tests for execution-profile-resolver module + * + * Tests profile resolution logic including context-based enforcement, + * explicit profile selection, yolo mode, and input normalization. + */ + +const { + VALID_PROFILES, + VALID_CONTEXTS, + PROFILE_POLICIES, + normalizeProfile, + normalizeContext, + resolveExecutionProfile, +} = require('../../../.aiox-core/core/orchestration/execution-profile-resolver'); + +describe('execution-profile-resolver', () => { + // ============================================================ + // Constants + // ============================================================ + describe('constants', () => { + test('VALID_PROFILES contains safe, balanced, aggressive', () => { + expect(VALID_PROFILES).toEqual(['safe', 'balanced', 'aggressive']); + }); + + test('VALID_CONTEXTS contains expected contexts', () => { + expect(VALID_CONTEXTS).toEqual(['production', 'migration', 'security-sensitive', 'development']); + }); + + test('PROFILE_POLICIES has entries for all profiles', () => { + for (const profile of VALID_PROFILES) { + expect(PROFILE_POLICIES[profile]).toBeDefined(); + } + }); + + test('safe policy is most restrictive', () => { + const safe = PROFILE_POLICIES.safe; + expect(safe.require_confirmation).toBe(true); + expect(safe.require_tests_before_handoff).toBe(true); + expect(safe.max_parallel_changes).toBe(1); + expect(safe.allow_destructive_operations).toBe(false); + expect(safe.allow_autonomous_refactors).toBe(false); + }); + + test('aggressive policy is most permissive', () => { + const aggressive = PROFILE_POLICIES.aggressive; + expect(aggressive.require_confirmation).toBe(false); + expect(aggressive.require_tests_before_handoff).toBe(false); + expect(aggressive.max_parallel_changes).toBe(8); + expect(aggressive.allow_autonomous_refactors).toBe(true); + }); + + test('balanced policy is intermediate', () => { + const balanced = PROFILE_POLICIES.balanced; + expect(balanced.require_confirmation).toBe('high-risk-only'); + expect(balanced.max_parallel_changes).toBe(3); + expect(balanced.allow_autonomous_refactors).toBe(true); + }); + }); + + // ============================================================ + // normalizeProfile + // ============================================================ + describe('normalizeProfile', () => { + test('returns valid profile as-is', () => { + expect(normalizeProfile('safe')).toBe('safe'); + expect(normalizeProfile('balanced')).toBe('balanced'); + expect(normalizeProfile('aggressive')).toBe('aggressive'); + }); + + test('lowercases input', () => { + expect(normalizeProfile('SAFE')).toBe('safe'); + expect(normalizeProfile('Balanced')).toBe('balanced'); + }); + + test('trims whitespace', () => { + expect(normalizeProfile(' safe ')).toBe('safe'); + }); + + test('returns null for invalid profile', () => { + expect(normalizeProfile('unknown')).toBeNull(); + expect(normalizeProfile('turbo')).toBeNull(); + }); + + test('returns null for empty/null/undefined', () => { + expect(normalizeProfile('')).toBeNull(); + expect(normalizeProfile(null)).toBeNull(); + expect(normalizeProfile(undefined)).toBeNull(); + }); + }); + + // ============================================================ + // normalizeContext + // ============================================================ + describe('normalizeContext', () => { + test('returns valid context as-is', () => { + expect(normalizeContext('production')).toBe('production'); + expect(normalizeContext('migration')).toBe('migration'); + expect(normalizeContext('security-sensitive')).toBe('security-sensitive'); + expect(normalizeContext('development')).toBe('development'); + }); + + test('lowercases input', () => { + expect(normalizeContext('PRODUCTION')).toBe('production'); + }); + + test('trims whitespace', () => { + expect(normalizeContext(' migration ')).toBe('migration'); + }); + + test('defaults to development for invalid context', () => { + expect(normalizeContext('unknown')).toBe('development'); + expect(normalizeContext('staging')).toBe('development'); + }); + + test('defaults to development for empty/null/undefined', () => { + expect(normalizeContext('')).toBe('development'); + expect(normalizeContext(null)).toBe('development'); + expect(normalizeContext(undefined)).toBe('development'); + }); + }); + + // ============================================================ + // resolveExecutionProfile + // ============================================================ + describe('resolveExecutionProfile', () => { + test('explicit profile takes highest priority', () => { + const result = resolveExecutionProfile({ + explicitProfile: 'aggressive', + context: 'production', + yolo: true, + }); + + expect(result.profile).toBe('aggressive'); + expect(result.source).toBe('explicit'); + expect(result.policy).toBe(PROFILE_POLICIES.aggressive); + expect(result.reasons[0]).toContain('explicit'); + }); + + test('production context enforces safe profile', () => { + const result = resolveExecutionProfile({ context: 'production' }); + + expect(result.profile).toBe('safe'); + expect(result.context).toBe('production'); + expect(result.source).toBe('context'); + expect(result.policy).toBe(PROFILE_POLICIES.safe); + }); + + test('security-sensitive context enforces safe profile', () => { + const result = resolveExecutionProfile({ context: 'security-sensitive' }); + + expect(result.profile).toBe('safe'); + expect(result.source).toBe('context'); + }); + + test('migration context enforces balanced profile', () => { + const result = resolveExecutionProfile({ context: 'migration' }); + + expect(result.profile).toBe('balanced'); + expect(result.source).toBe('context'); + expect(result.reasons[0]).toContain('migration'); + }); + + test('yolo mode in development sets aggressive', () => { + const result = resolveExecutionProfile({ + context: 'development', + yolo: true, + }); + + expect(result.profile).toBe('aggressive'); + expect(result.source).toBe('yolo'); + expect(result.reasons[0]).toContain('yolo'); + }); + + test('yolo is ignored when context enforces safe', () => { + const result = resolveExecutionProfile({ + context: 'production', + yolo: true, + }); + + expect(result.profile).toBe('safe'); + expect(result.source).toBe('context'); + }); + + test('default is balanced for development context', () => { + const result = resolveExecutionProfile({ context: 'development' }); + + expect(result.profile).toBe('balanced'); + expect(result.source).toBe('default'); + expect(result.reasons[0]).toContain('default'); + }); + + test('empty input defaults to balanced/development', () => { + const result = resolveExecutionProfile({}); + + expect(result.profile).toBe('balanced'); + expect(result.context).toBe('development'); + expect(result.source).toBe('default'); + }); + + test('no arguments defaults to balanced/development', () => { + const result = resolveExecutionProfile(); + + expect(result.profile).toBe('balanced'); + expect(result.context).toBe('development'); + }); + + test('result always includes policy object', () => { + const result = resolveExecutionProfile({}); + + expect(result.policy).toBeDefined(); + expect(result.policy.max_parallel_changes).toBeDefined(); + }); + + test('result always includes reasons array', () => { + const result = resolveExecutionProfile({}); + + expect(Array.isArray(result.reasons)).toBe(true); + expect(result.reasons.length).toBeGreaterThan(0); + }); + + test('explicit safe profile with development context', () => { + const result = resolveExecutionProfile({ + explicitProfile: 'safe', + context: 'development', + }); + + expect(result.profile).toBe('safe'); + expect(result.context).toBe('development'); + expect(result.source).toBe('explicit'); + }); + + test('invalid explicit profile falls through to context resolution', () => { + const result = resolveExecutionProfile({ + explicitProfile: 'turbo', + context: 'production', + }); + + expect(result.profile).toBe('safe'); + expect(result.source).toBe('context'); + }); + }); +}); diff --git a/tests/core/orchestration/executor-assignment.test.js b/tests/core/orchestration/executor-assignment.test.js new file mode 100644 index 0000000000..75313ba06d --- /dev/null +++ b/tests/core/orchestration/executor-assignment.test.js @@ -0,0 +1,358 @@ +/** + * Unit tests for executor-assignment module + * + * Tests dynamic executor and quality gate assignment based on + * keyword matching against story content. + */ + +const { + detectStoryType, + assignExecutor, + assignExecutorFromContent, + validateExecutorAssignment, + getStoryTypes, + getStoryTypeConfig, + getExecutorWorkTypes, + EXECUTOR_ASSIGNMENT_TABLE, + DEFAULT_ASSIGNMENT, +} = require('../../../.aiox-core/core/orchestration/executor-assignment'); + +describe('executor-assignment', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ============================================================ + // Constants + // ============================================================ + describe('constants', () => { + test('EXECUTOR_ASSIGNMENT_TABLE has all expected types', () => { + const types = Object.keys(EXECUTOR_ASSIGNMENT_TABLE); + expect(types).toContain('code_general'); + expect(types).toContain('database'); + expect(types).toContain('infrastructure'); + expect(types).toContain('ui_ux'); + expect(types).toContain('research'); + expect(types).toContain('architecture'); + }); + + test('each type has required fields', () => { + for (const [, config] of Object.entries(EXECUTOR_ASSIGNMENT_TABLE)) { + expect(Array.isArray(config.keywords)).toBe(true); + expect(config.keywords.length).toBeGreaterThan(0); + expect(typeof config.executor).toBe('string'); + expect(typeof config.quality_gate).toBe('string'); + expect(Array.isArray(config.quality_gate_tools)).toBe(true); + } + }); + + test('executor and quality_gate are different in each type', () => { + for (const [, config] of Object.entries(EXECUTOR_ASSIGNMENT_TABLE)) { + expect(config.executor).not.toBe(config.quality_gate); + } + }); + + test('DEFAULT_ASSIGNMENT has required fields', () => { + expect(DEFAULT_ASSIGNMENT.executor).toBe('@dev'); + expect(DEFAULT_ASSIGNMENT.quality_gate).toBe('@architect'); + expect(Array.isArray(DEFAULT_ASSIGNMENT.quality_gate_tools)).toBe(true); + }); + }); + + // ============================================================ + // detectStoryType + // ============================================================ + describe('detectStoryType', () => { + test('detects code_general from feature keywords', () => { + expect(detectStoryType('Implement user authentication handler')).toBe('code_general'); + }); + + test('detects database from schema keywords', () => { + expect(detectStoryType('Create RLS policies for user table')).toBe('database'); + }); + + test('detects infrastructure from deploy keywords', () => { + expect(detectStoryType('Setup CI/CD pipeline for deployment')).toBe('infrastructure'); + }); + + test('detects ui_ux from component keywords', () => { + expect(detectStoryType('Build responsive UI component with accessibility')).toBe('ui_ux'); + }); + + test('detects research from investigation keywords', () => { + expect(detectStoryType('Research and analyze benchmark results')).toBe('research'); + }); + + test('detects architecture from design keywords', () => { + expect(detectStoryType('Architecture design decision for scalability pattern')).toBe('architecture'); + }); + + test('defaults to code_general for null input', () => { + expect(detectStoryType(null)).toBe('code_general'); + }); + + test('defaults to code_general for undefined input', () => { + expect(detectStoryType(undefined)).toBe('code_general'); + }); + + test('defaults to code_general for empty string', () => { + expect(detectStoryType('')).toBe('code_general'); + }); + + test('defaults to code_general for non-string input', () => { + expect(detectStoryType(42)).toBe('code_general'); + expect(detectStoryType({})).toBe('code_general'); + }); + + test('is case insensitive', () => { + expect(detectStoryType('CREATE RLS POLICIES FOR USER TABLE')).toBe('database'); + }); + + test('picks type with highest keyword count', () => { + // Multiple database keywords should win + expect(detectStoryType('Create schema with table, migration, and query index')).toBe('database'); + }); + + test('defaults to code_general for no keyword matches', () => { + expect(detectStoryType('do something random here')).toBe('code_general'); + }); + }); + + // ============================================================ + // assignExecutor + // ============================================================ + describe('assignExecutor', () => { + test('assigns correct executor for code_general', () => { + const result = assignExecutor('code_general'); + expect(result.executor).toBe('@dev'); + expect(result.quality_gate).toBe('@architect'); + }); + + test('assigns correct executor for database', () => { + const result = assignExecutor('database'); + expect(result.executor).toBe('@data-engineer'); + expect(result.quality_gate).toBe('@dev'); + }); + + test('assigns correct executor for infrastructure', () => { + const result = assignExecutor('infrastructure'); + expect(result.executor).toBe('@devops'); + expect(result.quality_gate).toBe('@architect'); + }); + + test('assigns correct executor for ui_ux', () => { + const result = assignExecutor('ui_ux'); + expect(result.executor).toBe('@ux-design-expert'); + expect(result.quality_gate).toBe('@dev'); + }); + + test('assigns correct executor for research', () => { + const result = assignExecutor('research'); + expect(result.executor).toBe('@analyst'); + expect(result.quality_gate).toBe('@pm'); + }); + + test('assigns correct executor for architecture', () => { + const result = assignExecutor('architecture'); + expect(result.executor).toBe('@architect'); + expect(result.quality_gate).toBe('@pm'); + }); + + test('returns default for unknown type', () => { + const result = assignExecutor('unknown_type'); + expect(result.executor).toBe('@dev'); + expect(result.quality_gate).toBe('@architect'); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('unknown_type')); + }); + + test('returns copy of quality_gate_tools', () => { + const result1 = assignExecutor('code_general'); + const result2 = assignExecutor('code_general'); + result1.quality_gate_tools.push('extra'); + expect(result2.quality_gate_tools).not.toContain('extra'); + }); + }); + + // ============================================================ + // assignExecutorFromContent + // ============================================================ + describe('assignExecutorFromContent', () => { + test('combines detection and assignment', () => { + const result = assignExecutorFromContent('Create database schema with migrations'); + expect(result.executor).toBe('@data-engineer'); + expect(result.quality_gate).toBe('@dev'); + }); + + test('handles null content', () => { + const result = assignExecutorFromContent(null); + expect(result.executor).toBe('@dev'); + }); + }); + + // ============================================================ + // validateExecutorAssignment + // ============================================================ + describe('validateExecutorAssignment', () => { + test('valid assignment passes', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@architect', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('missing executor fails', () => { + const result = validateExecutorAssignment({ + quality_gate: '@architect', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('executor')); + }); + + test('missing quality_gate fails', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('quality_gate')); + }); + + test('missing quality_gate_tools fails', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@architect', + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('quality_gate_tools')); + }); + + test('non-array quality_gate_tools fails', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@architect', + quality_gate_tools: 'not_array', + }); + + expect(result.isValid).toBe(false); + }); + + test('empty quality_gate_tools fails', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@architect', + quality_gate_tools: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('empty')); + }); + + test('same executor and quality_gate fails', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@dev', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('cannot be the same')); + }); + + test('unknown executor warns', () => { + const result = validateExecutorAssignment({ + executor: '@unknown-agent', + quality_gate: '@architect', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('Unknown executor')); + }); + + test('unknown quality_gate warns', () => { + const result = validateExecutorAssignment({ + executor: '@dev', + quality_gate: '@unknown-reviewer', + quality_gate_tools: ['code_review'], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual(expect.stringContaining('Unknown quality_gate')); + }); + + test('@pm is accepted as quality_gate', () => { + const result = validateExecutorAssignment({ + executor: '@analyst', + quality_gate: '@pm', + quality_gate_tools: ['research_validation'], + }); + + expect(result.isValid).toBe(true); + }); + }); + + // ============================================================ + // getStoryTypes + // ============================================================ + describe('getStoryTypes', () => { + test('returns all type keys', () => { + const types = getStoryTypes(); + expect(types).toContain('code_general'); + expect(types).toContain('database'); + expect(types).toContain('infrastructure'); + expect(types).toContain('ui_ux'); + expect(types).toContain('research'); + expect(types).toContain('architecture'); + }); + }); + + // ============================================================ + // getStoryTypeConfig + // ============================================================ + describe('getStoryTypeConfig', () => { + test('returns config for valid type', () => { + const config = getStoryTypeConfig('database'); + expect(config.executor).toBe('@data-engineer'); + expect(config.keywords).toContain('schema'); + }); + + test('returns null for unknown type', () => { + expect(getStoryTypeConfig('nonexistent')).toBeNull(); + }); + }); + + // ============================================================ + // getExecutorWorkTypes + // ============================================================ + describe('getExecutorWorkTypes', () => { + test('returns map of executors to work types', () => { + const map = getExecutorWorkTypes(); + + expect(map['@dev']).toContain('code_general'); + expect(map['@data-engineer']).toContain('database'); + expect(map['@devops']).toContain('infrastructure'); + expect(map['@analyst']).toContain('research'); + }); + + test('each executor has at least one work type', () => { + const map = getExecutorWorkTypes(); + + for (const [, types] of Object.entries(map)) { + expect(types.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/core/orchestration/gate-evaluator.test.js b/tests/core/orchestration/gate-evaluator.test.js new file mode 100644 index 0000000000..3697174e4a --- /dev/null +++ b/tests/core/orchestration/gate-evaluator.test.js @@ -0,0 +1,769 @@ +/** + * Unit tests for gate-evaluator module + * + * Tests the GateEvaluator class that evaluates quality gates + * between epics to prevent bad outputs from propagating. + */ + +jest.mock('fs-extra'); +jest.mock('js-yaml'); + +const fs = require('fs-extra'); +const yaml = require('js-yaml'); + +const { + GateEvaluator, + GateVerdict, + DEFAULT_GATE_CONFIG, +} = require('../../../.aiox-core/core/orchestration/gate-evaluator'); + +describe('GateEvaluator', () => { + let evaluator; + + beforeEach(() => { + jest.resetAllMocks(); + evaluator = new GateEvaluator({ projectRoot: '/project' }); + }); + + // ============================================================ + // Exports & Constants + // ============================================================ + describe('exports', () => { + test('exports GateVerdict enum', () => { + expect(GateVerdict.APPROVED).toBe('approved'); + expect(GateVerdict.NEEDS_REVISION).toBe('needs_revision'); + expect(GateVerdict.BLOCKED).toBe('blocked'); + }); + + test('exports DEFAULT_GATE_CONFIG', () => { + expect(DEFAULT_GATE_CONFIG.epic3_to_epic4).toBeDefined(); + expect(DEFAULT_GATE_CONFIG.epic4_to_epic6).toBeDefined(); + expect(DEFAULT_GATE_CONFIG.epic3_to_epic4.checks).toContain('spec_exists'); + expect(DEFAULT_GATE_CONFIG.epic4_to_epic6.requireTests).toBe(true); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets defaults', () => { + expect(evaluator.projectRoot).toBe('/project'); + expect(evaluator.strictMode).toBe(false); + expect(evaluator.gateConfig).toBeNull(); + expect(evaluator.results).toEqual([]); + expect(evaluator.logs).toEqual([]); + }); + + test('uses process.cwd when no projectRoot given', () => { + const ev = new GateEvaluator(); + expect(ev.projectRoot).toBe(process.cwd()); + }); + + test('accepts strictMode and custom gateConfig', () => { + const custom = { myGate: { blocking: true } }; + const ev = new GateEvaluator({ strictMode: true, gateConfig: custom }); + expect(ev.strictMode).toBe(true); + expect(ev.gateConfig).toBe(custom); + }); + }); + + // ============================================================ + // _loadConfig (private, tested through evaluate) + // ============================================================ + describe('config loading', () => { + test('uses custom gateConfig when provided', async () => { + const custom = { epic3_to_epic4: { blocking: false, checks: [] } }; + const ev = new GateEvaluator({ projectRoot: '/p', gateConfig: custom }); + + const result = await ev.evaluate(3, 4, {}); + expect(result.config).toEqual({ blocking: false, checks: [] }); + }); + + test('loads config from core-config.yaml', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('yaml content'); + yaml.load.mockReturnValue({ + autoClaude: { + orchestrator: { + gates: { + epic3_to_epic4: { blocking: true, checks: ['custom_check'], minScore: 4 }, + }, + }, + }, + }); + + const result = await evaluator.evaluate(3, 4, {}); + // Should merge with defaults + expect(result.config.checks).toContain('custom_check'); + }); + + test('falls back to DEFAULT_GATE_CONFIG when no config file', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await evaluator.evaluate(3, 4, { specPath: '/spec.md', complexity: 'low' }); + expect(result.config).toEqual(DEFAULT_GATE_CONFIG.epic3_to_epic4); + }); + + test('falls back to defaults on config load error', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockRejectedValue(new Error('read error')); + + const result = await evaluator.evaluate(3, 4, { specPath: '/s' }); + // Should still work with defaults + expect(result.verdict).toBeDefined(); + }); + }); + + // ============================================================ + // _getGateKey + // ============================================================ + describe('gate key generation', () => { + test('generates correct gate key', () => { + expect(evaluator._getGateKey(3, 4)).toBe('epic3_to_epic4'); + expect(evaluator._getGateKey(4, 6)).toBe('epic4_to_epic6'); + }); + }); + + // ============================================================ + // evaluate - Epic 3 checks + // ============================================================ + describe('evaluate - epic 3 checks', () => { + beforeEach(() => { + fs.pathExists.mockResolvedValue(false); + }); + + test('APPROVED when all checks pass', async () => { + const epicResult = { + specPath: '/specs/design.md', + complexity: 'medium', + requirements: ['R1', 'R2'], + score: 4.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + expect(result.verdict).toBe(GateVerdict.APPROVED); + expect(result.gate).toBe('epic3_to_epic4'); + expect(result.fromEpic).toBe(3); + expect(result.toEpic).toBe(4); + expect(result.score).toBe(5); // all passed + expect(result.issues).toHaveLength(0); + }); + + test('spec_exists passes with artifacts', async () => { + const epicResult = { + artifacts: [{ type: 'spec', path: '/spec.md' }], + complexity: 'high', + requirements: ['R1'], + score: 4.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const specCheck = result.checks.find((c) => c.name === 'spec_exists'); + expect(specCheck.passed).toBe(true); + }); + + test('spec_exists fails when no spec', async () => { + const epicResult = { + complexity: 'low', + requirements: ['R1'], + score: 4.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const specCheck = result.checks.find((c) => c.name === 'spec_exists'); + expect(specCheck.passed).toBe(false); + expect(specCheck.severity).toBe('critical'); + }); + + test('complexity_assessed fails when missing', async () => { + const epicResult = { + specPath: '/s', + requirements: ['R1'], + score: 4.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const complexityCheck = result.checks.find((c) => c.name === 'complexity_assessed'); + expect(complexityCheck.passed).toBe(false); + }); + + test('requirements_defined fails when empty', async () => { + const epicResult = { + specPath: '/s', + complexity: 'low', + requirements: [], + score: 4.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const reqCheck = result.checks.find((c) => c.name === 'requirements_defined'); + expect(reqCheck.passed).toBe(false); + }); + + test('minScore check added when epicResult has score', async () => { + const epicResult = { + specPath: '/s', + complexity: 'low', + requirements: ['R1'], + score: 2.0, + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const scoreCheck = result.checks.find((c) => c.name === 'min_score'); + expect(scoreCheck).toBeDefined(); + expect(scoreCheck.passed).toBe(false); + expect(scoreCheck.message).toContain('2'); + }); + + test('minScore check not added when epicResult has no score', async () => { + const epicResult = { + specPath: '/s', + complexity: 'low', + requirements: ['R1'], + }; + + const result = await evaluator.evaluate(3, 4, epicResult); + const scoreCheck = result.checks.find((c) => c.name === 'min_score'); + expect(scoreCheck).toBeUndefined(); + }); + }); + + // ============================================================ + // evaluate - Epic 4 checks + // ============================================================ + describe('evaluate - epic 4 checks', () => { + beforeEach(() => { + fs.pathExists.mockResolvedValue(false); + }); + + test('APPROVED when all checks pass', async () => { + const epicResult = { + planPath: '/plan.md', + implementationPath: '/src/index.js', + errors: [], + testResults: [{ name: 'test1', passed: true }], + testsRun: 1, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + expect(result.verdict).toBe(GateVerdict.APPROVED); + }); + + test('plan_complete passes with planComplete flag', async () => { + const epicResult = { + planComplete: true, + codeChanges: ['/src/file.js'], + errors: [], + testResults: [{ name: 'test1', passed: true }], + testsRun: 1, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const planCheck = result.checks.find((c) => c.name === 'plan_complete'); + expect(planCheck.passed).toBe(true); + }); + + test('implementation_exists passes with codeChanges', async () => { + const epicResult = { + planPath: '/plan.md', + codeChanges: ['/src/a.js', '/src/b.js'], + errors: [], + testResults: [{ name: 'test1', passed: true }], + testsRun: 1, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const implCheck = result.checks.find((c) => c.name === 'implementation_exists'); + expect(implCheck.passed).toBe(true); + }); + + test('no_critical_errors fails with critical errors', async () => { + const epicResult = { + planPath: '/p', + implementationPath: '/src', + errors: [{ severity: 'critical', message: 'Fatal crash' }], + testResults: [{ name: 't', passed: true }], + testsRun: 1, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const errorCheck = result.checks.find((c) => c.name === 'no_critical_errors'); + expect(errorCheck.passed).toBe(false); + expect(errorCheck.severity).toBe('critical'); + }); + + test('requireTests check added when testResults present', async () => { + const epicResult = { + planPath: '/p', + implementationPath: '/src', + errors: [], + testResults: [{ name: 't', passed: true }], + testsRun: 1, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const testCheck = result.checks.find((c) => c.name === 'require_tests'); + expect(testCheck).toBeDefined(); + expect(testCheck.passed).toBe(true); + }); + + test('requireTests skipped when testResults has skipped flag', async () => { + const epicResult = { + planPath: '/p', + implementationPath: '/src', + errors: [], + testResults: { skipped: true }, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const testCheck = result.checks.find((c) => c.name === 'require_tests'); + expect(testCheck).toBeUndefined(); + }); + + test('requireTests fails when no tests run (array)', async () => { + const epicResult = { + planPath: '/p', + implementationPath: '/src', + errors: [], + testResults: [], + testsRun: 0, + }; + + const result = await evaluator.evaluate(4, 6, epicResult); + const testCheck = result.checks.find((c) => c.name === 'require_tests'); + expect(testCheck).toBeDefined(); + expect(testCheck.passed).toBe(false); + }); + }); + + // ============================================================ + // evaluate - Epic 6 checks (QA) + // ============================================================ + describe('evaluate - epic 6 checks', () => { + beforeEach(() => { + fs.pathExists.mockResolvedValue(false); + // Use custom config for epic 6 gate + evaluator.gateConfig = { + epic6_to_epic7: { + blocking: false, + checks: ['qa_report_exists', 'verdict_generated', 'tests_pass'], + }, + }; + }); + + test('qa_report_exists passes with reportPath', async () => { + const epicResult = { + reportPath: '/report.md', + verdict: 'approved', + testResults: [{ name: 't1', passed: true }], + }; + + const result = await evaluator.evaluate(6, 7, epicResult); + const qaCheck = result.checks.find((c) => c.name === 'qa_report_exists'); + expect(qaCheck.passed).toBe(true); + }); + + test('verdict_generated passes with verdict', async () => { + const epicResult = { + qaReport: 'Report content', + verdict: 'needs_work', + testResults: [{ name: 't', passed: true }], + }; + + const result = await evaluator.evaluate(6, 7, epicResult); + const verdictCheck = result.checks.find((c) => c.name === 'verdict_generated'); + expect(verdictCheck.passed).toBe(true); + expect(verdictCheck.message).toContain('needs_work'); + }); + + test('tests_pass checks all tests pass', async () => { + const epicResult = { + reportPath: '/r', + verdict: 'ok', + testResults: [ + { name: 't1', passed: true }, + { name: 't2', passed: false }, + ], + }; + + const result = await evaluator.evaluate(6, 7, epicResult); + const testsCheck = result.checks.find((c) => c.name === 'tests_pass'); + expect(testsCheck.passed).toBe(false); + expect(testsCheck.message).toContain('failed'); + }); + + test('tests_pass handles empty testResults', async () => { + const epicResult = { reportPath: '/r', verdict: 'ok', testResults: [] }; + + const result = await evaluator.evaluate(6, 7, epicResult); + const testsCheck = result.checks.find((c) => c.name === 'tests_pass'); + expect(testsCheck.passed).toBe(false); + expect(testsCheck.message).toContain('No test results'); + }); + }); + + // ============================================================ + // evaluate - unknown checks and default checks + // ============================================================ + describe('evaluate - unknown and default checks', () => { + test('unknown check passes by default', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['future_check'] }, + }; + + const result = await evaluator.evaluate(3, 4, {}); + const unknownCheck = result.checks.find((c) => c.name === 'future_check'); + expect(unknownCheck.passed).toBe(true); + expect(unknownCheck.message).toContain('Unknown check'); + }); + + test('uses default checks when gate has no config', async () => { + fs.pathExists.mockResolvedValue(false); + // Epic 3 default checks: spec_exists, complexity_assessed + const ev = new GateEvaluator({ projectRoot: '/p' }); + + const result = await ev.evaluate(3, 99, {}); + const checkNames = result.checks.map((c) => c.name); + expect(checkNames).toContain('spec_exists'); + expect(checkNames).toContain('complexity_assessed'); + }); + + test('epic with no default checks returns empty', async () => { + evaluator.gateConfig = {}; + const result = await evaluator.evaluate(99, 100, {}); + expect(result.checks).toHaveLength(0); + expect(result.verdict).toBe(GateVerdict.APPROVED); + }); + }); + + // ============================================================ + // evaluate - minTestCoverage + // ============================================================ + describe('evaluate - minTestCoverage', () => { + test('adds coverage check when configured and threshold > 0', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { + blocking: false, + checks: [], + minTestCoverage: 80, + }, + }; + + const result = await evaluator.evaluate(4, 6, { testCoverage: 90 }); + const covCheck = result.checks.find((c) => c.name === 'min_coverage'); + expect(covCheck.passed).toBe(true); + }); + + test('coverage check fails when below threshold', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { + blocking: false, + checks: [], + minTestCoverage: 80, + }, + }; + + const result = await evaluator.evaluate(4, 6, { testCoverage: 50 }); + const covCheck = result.checks.find((c) => c.name === 'min_coverage'); + expect(covCheck.passed).toBe(false); + }); + + test('coverage defaults to 0 when not in epicResult', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { + blocking: false, + checks: [], + minTestCoverage: 80, + }, + }; + + const result = await evaluator.evaluate(4, 6, {}); + const covCheck = result.checks.find((c) => c.name === 'min_coverage'); + expect(covCheck.passed).toBe(false); + }); + + test('skips coverage check when minTestCoverage is 0', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { + blocking: false, + checks: [], + minTestCoverage: 0, + }, + }; + + const result = await evaluator.evaluate(4, 6, {}); + const covCheck = result.checks.find((c) => c.name === 'min_coverage'); + expect(covCheck).toBeUndefined(); + }); + }); + + // ============================================================ + // _determineVerdict + // ============================================================ + describe('verdict determination', () => { + test('strict mode: any issue = BLOCKED', async () => { + const ev = new GateEvaluator({ projectRoot: '/p', strictMode: true }); + ev.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['spec_exists'] }, + }; + + const result = await ev.evaluate(3, 4, {}); // no spec → fail + expect(result.verdict).toBe(GateVerdict.BLOCKED); + }); + + test('critical issue always blocks', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['spec_exists'] }, + }; + + const result = await evaluator.evaluate(3, 4, {}); // spec_exists severity=critical + expect(result.verdict).toBe(GateVerdict.BLOCKED); + }); + + test('high severity issues on blocking gate = BLOCKED', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { blocking: true, checks: ['plan_complete'] }, + }; + + const result = await evaluator.evaluate(4, 6, {}); // plan_complete severity=high + expect(result.verdict).toBe(GateVerdict.BLOCKED); + }); + + test('high severity issues on non-blocking gate = NEEDS_REVISION', async () => { + evaluator.gateConfig = { + epic4_to_epic6: { blocking: false, checks: ['plan_complete'] }, + }; + + const result = await evaluator.evaluate(4, 6, {}); // plan_complete severity=high + expect(result.verdict).toBe(GateVerdict.NEEDS_REVISION); + }); + + test('allowMinorIssues approves low/medium issues', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { + blocking: false, + allowMinorIssues: true, + checks: ['complexity_assessed'], + }, + }; + + const result = await evaluator.evaluate(3, 4, {}); // complexity_assessed severity=medium + expect(result.verdict).toBe(GateVerdict.APPROVED); + }); + + test('medium issues without allowMinorIssues = NEEDS_REVISION', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { + blocking: false, + checks: ['complexity_assessed'], + }, + }; + + const result = await evaluator.evaluate(3, 4, {}); // medium severity + expect(result.verdict).toBe(GateVerdict.NEEDS_REVISION); + }); + + test('evaluate catches errors and returns BLOCKED', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { checks: 42 }, // non-iterable triggers TypeError in for-of + }; + + const result = await evaluator.evaluate(3, 4, {}); + expect(result.verdict).toBe(GateVerdict.BLOCKED); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ check: 'gate_evaluation', severity: 'critical' }), + ]), + ); + }); + }); + + // ============================================================ + // Score calculation + // ============================================================ + describe('score calculation', () => { + test('perfect score when all checks pass', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['spec_exists', 'complexity_assessed'] }, + }; + + const result = await evaluator.evaluate(3, 4, { + specPath: '/s', + complexity: 'low', + }); + expect(result.score).toBe(5); + }); + + test('partial score with some failures', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['spec_exists', 'complexity_assessed'] }, + }; + + const result = await evaluator.evaluate(3, 4, { + specPath: '/s', + // no complexity → 1 of 2 pass + }); + expect(result.score).toBe(2.5); + }); + + test('zero score when all fail', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: ['spec_exists', 'complexity_assessed'] }, + }; + + const result = await evaluator.evaluate(3, 4, {}); + expect(result.score).toBe(0); + }); + + test('default score 5 when no checks', async () => { + evaluator.gateConfig = { + epic99_to_epic100: { blocking: false, checks: [] }, + }; + + const result = await evaluator.evaluate(99, 100, {}); + expect(result.score).toBe(5); + }); + }); + + // ============================================================ + // shouldBlock / needsRevision helpers + // ============================================================ + describe('verdict helpers', () => { + test('shouldBlock returns true for BLOCKED', () => { + expect(evaluator.shouldBlock(GateVerdict.BLOCKED)).toBe(true); + expect(evaluator.shouldBlock(GateVerdict.APPROVED)).toBe(false); + expect(evaluator.shouldBlock(GateVerdict.NEEDS_REVISION)).toBe(false); + }); + + test('needsRevision returns true for NEEDS_REVISION', () => { + expect(evaluator.needsRevision(GateVerdict.NEEDS_REVISION)).toBe(true); + expect(evaluator.needsRevision(GateVerdict.APPROVED)).toBe(false); + expect(evaluator.needsRevision(GateVerdict.BLOCKED)).toBe(false); + }); + }); + + // ============================================================ + // Results storage (AC6) + // ============================================================ + describe('results storage', () => { + beforeEach(() => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: [] }, + epic4_to_epic6: { blocking: false, checks: [] }, + }; + }); + + test('getResults returns copy of results', async () => { + await evaluator.evaluate(3, 4, {}); + const results = evaluator.getResults(); + expect(results).toHaveLength(1); + // Verify it's a copy + results.push({ fake: true }); + expect(evaluator.getResults()).toHaveLength(1); + }); + + test('getResult finds by gate key', async () => { + await evaluator.evaluate(3, 4, {}); + await evaluator.evaluate(4, 6, {}); + + expect(evaluator.getResult('epic3_to_epic4')).toBeDefined(); + expect(evaluator.getResult('epic4_to_epic6')).toBeDefined(); + expect(evaluator.getResult('nonexistent')).toBeNull(); + }); + + test('getSummary calculates stats', async () => { + evaluator.gateConfig = { + epic3_to_epic4: { blocking: false, checks: [] }, + epic4_to_epic6: { blocking: false, checks: ['plan_complete'] }, + }; + + await evaluator.evaluate(3, 4, {}); // APPROVED (no checks) + await evaluator.evaluate(4, 6, {}); // NEEDS_REVISION (plan_complete high, non-blocking) + + const summary = evaluator.getSummary(); + expect(summary.total).toBe(2); + expect(summary.approved).toBe(1); + expect(summary.needsRevision).toBe(1); + expect(summary.blocked).toBe(0); + expect(summary.allPassed).toBe(false); + }); + + test('getSummary with all approved', async () => { + await evaluator.evaluate(3, 4, {}); + + const summary = evaluator.getSummary(); + expect(summary.allPassed).toBe(true); + expect(summary.averageScore).toBe(5); + }); + + test('getSummary with empty results', () => { + const summary = evaluator.getSummary(); + expect(summary.total).toBe(0); + expect(summary.averageScore).toBe(0); + }); + }); + + // ============================================================ + // clear + // ============================================================ + describe('clear', () => { + test('resets results and logs', async () => { + evaluator.gateConfig = { epic3_to_epic4: { checks: [] } }; + await evaluator.evaluate(3, 4, {}); + + expect(evaluator.getResults()).toHaveLength(1); + expect(evaluator.getLogs().length).toBeGreaterThan(0); + + evaluator.clear(); + + expect(evaluator.getResults()).toHaveLength(0); + expect(evaluator.getLogs()).toHaveLength(0); + }); + }); + + // ============================================================ + // Logging + // ============================================================ + describe('logging', () => { + test('logs evaluation steps', async () => { + evaluator.gateConfig = { epic3_to_epic4: { checks: [] } }; + await evaluator.evaluate(3, 4, {}); + + const logs = evaluator.getLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs[0]).toHaveProperty('timestamp'); + expect(logs[0]).toHaveProperty('level'); + expect(logs[0]).toHaveProperty('message'); + }); + + test('getLogs returns a copy', async () => { + evaluator.gateConfig = { epic3_to_epic4: { checks: [] } }; + await evaluator.evaluate(3, 4, {}); + + const logs = evaluator.getLogs(); + logs.push({ fake: true }); + expect(evaluator.getLogs()).not.toContainEqual({ fake: true }); + }); + }); + + // ============================================================ + // Result structure + // ============================================================ + describe('result structure', () => { + test('result has all required fields', async () => { + evaluator.gateConfig = { epic3_to_epic4: { checks: [] } }; + const result = await evaluator.evaluate(3, 4, {}); + + expect(result).toHaveProperty('gate', 'epic3_to_epic4'); + expect(result).toHaveProperty('fromEpic', 3); + expect(result).toHaveProperty('toEpic', 4); + expect(result).toHaveProperty('timestamp'); + expect(result).toHaveProperty('verdict'); + expect(result).toHaveProperty('score'); + expect(result).toHaveProperty('checks'); + expect(result).toHaveProperty('issues'); + expect(result).toHaveProperty('config'); + }); + }); +}); diff --git a/tests/core/orchestration/parallel-executor.test.js b/tests/core/orchestration/parallel-executor.test.js new file mode 100644 index 0000000000..8babdb6b6c --- /dev/null +++ b/tests/core/orchestration/parallel-executor.test.js @@ -0,0 +1,404 @@ +/** + * Unit tests for parallel-executor module + * + * Tests the ParallelExecutor class that manages concurrent phase execution + * with configurable concurrency limits. + */ + +jest.mock('chalk', () => ({ + yellow: (s) => s, + red: (s) => s, + gray: (s) => s, +})); + +const ParallelExecutor = require('../../../.aiox-core/core/orchestration/parallel-executor'); + +describe('ParallelExecutor', () => { + let executor; + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(console, 'log').mockImplementation(); + executor = new ParallelExecutor(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets default maxConcurrency to 3', () => { + expect(executor.maxConcurrency).toBe(3); + }); + + test('initializes empty runningTasks map', () => { + expect(executor.runningTasks).toBeInstanceOf(Map); + expect(executor.runningTasks.size).toBe(0); + }); + }); + + // ============================================================ + // executeParallel + // ============================================================ + describe('executeParallel', () => { + test('executes all phases successfully', async () => { + const phases = [ + { phase: 'discovery' }, + { phase: 'analysis' }, + ]; + const executePhase = jest.fn().mockResolvedValue({ ok: true }); + + const result = await executor.executeParallel(phases, executePhase); + + expect(result.results).toHaveLength(2); + expect(result.errors).toHaveLength(0); + expect(result.summary).toEqual({ total: 2, success: 2, failed: 0 }); + expect(executePhase).toHaveBeenCalledTimes(2); + }); + + test('handles phase failures via internal catch', async () => { + const phases = [ + { phase: 'good' }, + { phase: 'bad' }, + ]; + const executePhase = jest.fn() + .mockResolvedValueOnce({ ok: true }) + .mockRejectedValueOnce(new Error('Phase failed')); + + const result = await executor.executeParallel(phases, executePhase); + + // Internal try/catch catches errors, marks tasks as failed in runningTasks + const status = executor.getStatus(); + expect(status['bad'].status).toBe('failed'); + expect(status['bad'].error).toBe('Phase failed'); + expect(status['good'].status).toBe('completed'); + }); + + test('all phases fail - marks all as failed in runningTasks', async () => { + const phases = [{ phase: 'a' }, { phase: 'b' }]; + const executePhase = jest.fn().mockRejectedValue(new Error('fail')); + + await executor.executeParallel(phases, executePhase); + + const status = executor.getStatus(); + expect(status['a'].status).toBe('failed'); + expect(status['b'].status).toBe('failed'); + }); + + test('uses custom maxConcurrency from options', async () => { + const phases = [{ phase: 'a' }]; + const executePhase = jest.fn().mockResolvedValue({}); + + await executor.executeParallel(phases, executePhase, { maxConcurrency: 5 }); + + expect(executePhase).toHaveBeenCalledTimes(1); + }); + + test('uses step key when phase key is absent', async () => { + const phases = [{ step: 'step-1' }]; + const executePhase = jest.fn().mockResolvedValue({ done: true }); + + await executor.executeParallel(phases, executePhase); + + const status = executor.getStatus(); + expect(status['step-1']).toBeDefined(); + expect(status['step-1'].status).toBe('completed'); + }); + + test('tracks running tasks during execution', async () => { + let capturedStatus; + const phases = [{ phase: 'check' }]; + const executePhase = jest.fn().mockImplementation(async () => { + capturedStatus = executor.getStatus(); + return {}; + }); + + await executor.executeParallel(phases, executePhase); + + expect(capturedStatus['check'].status).toBe('running'); + }); + + test('updates task status on completion', async () => { + const phases = [{ phase: 'done' }]; + const executePhase = jest.fn().mockResolvedValue({ result: 1 }); + + await executor.executeParallel(phases, executePhase); + + const status = executor.getStatus(); + expect(status['done'].status).toBe('completed'); + expect(status['done'].result).toEqual({ result: 1 }); + }); + + test('updates task status on failure', async () => { + const phases = [{ phase: 'err' }]; + const executePhase = jest.fn().mockRejectedValue(new Error('boom')); + + await executor.executeParallel(phases, executePhase); + + const status = executor.getStatus(); + expect(status['err'].status).toBe('failed'); + expect(status['err'].error).toBe('boom'); + }); + + test('handles empty phases array', async () => { + const executePhase = jest.fn(); + + const result = await executor.executeParallel([], executePhase); + + expect(result.results).toHaveLength(0); + expect(result.errors).toHaveLength(0); + expect(result.summary).toEqual({ total: 0, success: 0, failed: 0 }); + expect(executePhase).not.toHaveBeenCalled(); + }); + + test('logs execution start', async () => { + const phases = [{ phase: 'a' }, { phase: 'b' }, { phase: 'c' }]; + const executePhase = jest.fn().mockResolvedValue({}); + + await executor.executeParallel(phases, executePhase); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('3 phases in parallel'), + ); + }); + + test('logs completion summary with counts', async () => { + const phases = [{ phase: 'a' }, { phase: 'b' }]; + const executePhase = jest.fn().mockResolvedValue({}); + + await executor.executeParallel(phases, executePhase); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Completed'), + ); + }); + + test('logs completion summary', async () => { + const phases = [{ phase: 'x' }]; + const executePhase = jest.fn().mockResolvedValue({}); + + await executor.executeParallel(phases, executePhase); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('1 success, 0 failed'), + ); + }); + }); + + // ============================================================ + // getStatus + // ============================================================ + describe('getStatus', () => { + test('returns empty object when no tasks', () => { + expect(executor.getStatus()).toEqual({}); + }); + + test('returns task statuses', () => { + executor.runningTasks.set('a', { status: 'running', startTime: 1000 }); + executor.runningTasks.set('b', { status: 'completed', startTime: 1000, endTime: 2000 }); + + const status = executor.getStatus(); + + expect(status.a.status).toBe('running'); + expect(status.b.status).toBe('completed'); + }); + + test('calculates duration for completed tasks', () => { + executor.runningTasks.set('task', { + status: 'completed', + startTime: 1000, + endTime: 3500, + }); + + const status = executor.getStatus(); + expect(status.task.duration).toBe(2500); + }); + + test('does not calculate duration for running tasks', () => { + executor.runningTasks.set('task', { + status: 'running', + startTime: 1000, + }); + + const status = executor.getStatus(); + expect(status.task.duration).toBeUndefined(); + }); + + test('returns copies not references', () => { + executor.runningTasks.set('task', { status: 'running' }); + + const status = executor.getStatus(); + status.task.status = 'modified'; + + expect(executor.runningTasks.get('task').status).toBe('running'); + }); + }); + + // ============================================================ + // hasRunningTasks + // ============================================================ + describe('hasRunningTasks', () => { + test('returns false when no tasks', () => { + expect(executor.hasRunningTasks()).toBe(false); + }); + + test('returns true when a task is running', () => { + executor.runningTasks.set('a', { status: 'running' }); + expect(executor.hasRunningTasks()).toBe(true); + }); + + test('returns false when all tasks completed', () => { + executor.runningTasks.set('a', { status: 'completed' }); + executor.runningTasks.set('b', { status: 'failed' }); + expect(executor.hasRunningTasks()).toBe(false); + }); + }); + + // ============================================================ + // waitForCompletion + // ============================================================ + describe('waitForCompletion', () => { + test('resolves immediately when no running tasks', async () => { + await expect(executor.waitForCompletion()).resolves.toBeUndefined(); + }); + + test('throws on timeout', async () => { + executor.runningTasks.set('stuck', { status: 'running' }); + + await expect(executor.waitForCompletion(50)).rejects.toThrow( + 'Timeout waiting for parallel tasks to complete', + ); + }); + }); + + // ============================================================ + // cancelAll + // ============================================================ + describe('cancelAll', () => { + test('marks running tasks as cancelled', () => { + executor.runningTasks.set('a', { status: 'running', startTime: 1000 }); + executor.runningTasks.set('b', { status: 'running', startTime: 2000 }); + + executor.cancelAll(); + + expect(executor.runningTasks.get('a').status).toBe('cancelled'); + expect(executor.runningTasks.get('b').status).toBe('cancelled'); + expect(executor.runningTasks.get('a').cancelledAt).toBeDefined(); + }); + + test('does not affect completed tasks', () => { + executor.runningTasks.set('done', { status: 'completed' }); + executor.runningTasks.set('err', { status: 'failed' }); + + executor.cancelAll(); + + expect(executor.runningTasks.get('done').status).toBe('completed'); + expect(executor.runningTasks.get('err').status).toBe('failed'); + }); + + test('preserves original startTime on cancelled tasks', () => { + executor.runningTasks.set('a', { status: 'running', startTime: 5000 }); + + executor.cancelAll(); + + expect(executor.runningTasks.get('a').startTime).toBe(5000); + }); + }); + + // ============================================================ + // clear + // ============================================================ + describe('clear', () => { + test('removes all tasks', () => { + executor.runningTasks.set('a', { status: 'completed' }); + executor.runningTasks.set('b', { status: 'running' }); + + executor.clear(); + + expect(executor.runningTasks.size).toBe(0); + }); + }); + + // ============================================================ + // setMaxConcurrency + // ============================================================ + describe('setMaxConcurrency', () => { + test('sets valid concurrency', () => { + executor.setMaxConcurrency(5); + expect(executor.maxConcurrency).toBe(5); + }); + + test('clamps minimum to 1', () => { + executor.setMaxConcurrency(0); + expect(executor.maxConcurrency).toBe(1); + + executor.setMaxConcurrency(-5); + expect(executor.maxConcurrency).toBe(1); + }); + + test('clamps maximum to 10', () => { + executor.setMaxConcurrency(100); + expect(executor.maxConcurrency).toBe(10); + }); + }); + + // ============================================================ + // getSummary + // ============================================================ + describe('getSummary', () => { + test('returns zeros when no tasks', () => { + const summary = executor.getSummary(); + + expect(summary).toEqual({ + total: 0, + completed: 0, + failed: 0, + running: 0, + averageDuration: 0, + }); + }); + + test('counts tasks by status', () => { + executor.runningTasks.set('a', { status: 'completed', startTime: 1000, endTime: 1100 }); + executor.runningTasks.set('b', { status: 'failed' }); + executor.runningTasks.set('c', { status: 'running' }); + executor.runningTasks.set('d', { status: 'completed', startTime: 1000, endTime: 1200 }); + + const summary = executor.getSummary(); + + expect(summary.total).toBe(4); + expect(summary.completed).toBe(2); + expect(summary.failed).toBe(1); + expect(summary.running).toBe(1); + }); + + test('calculates average duration for completed tasks', () => { + executor.runningTasks.set('a', { status: 'completed', startTime: 1000, endTime: 1100 }); + executor.runningTasks.set('b', { status: 'completed', startTime: 1000, endTime: 1300 }); + + const summary = executor.getSummary(); + expect(summary.averageDuration).toBe(200); + }); + + test('averageDuration is 0 when no completed tasks', () => { + executor.runningTasks.set('a', { status: 'running' }); + executor.runningTasks.set('b', { status: 'failed' }); + + const summary = executor.getSummary(); + expect(summary.averageDuration).toBe(0); + }); + + test('ignores cancelled tasks in counts', () => { + executor.runningTasks.set('a', { status: 'cancelled' }); + + const summary = executor.getSummary(); + expect(summary.total).toBe(1); + expect(summary.completed).toBe(0); + expect(summary.failed).toBe(0); + expect(summary.running).toBe(0); + }); + }); +}); diff --git a/tests/core/orchestration/recovery-handler.test.js b/tests/core/orchestration/recovery-handler.test.js new file mode 100644 index 0000000000..b7350f9c6c --- /dev/null +++ b/tests/core/orchestration/recovery-handler.test.js @@ -0,0 +1,540 @@ +/** + * Unit tests for recovery-handler module + * + * Tests the RecoveryHandler class that manages automatic error recovery + * for the orchestration pipeline with multiple strategies. + */ + +jest.mock('fs-extra'); + +const fs = require('fs-extra'); + +const { + RecoveryHandler, + RecoveryStrategy, + RecoveryResult, +} = require('../../../.aiox-core/core/orchestration/recovery-handler'); + +describe('RecoveryHandler', () => { + let handler; + + beforeEach(() => { + jest.resetAllMocks(); + fs.ensureDir.mockResolvedValue(undefined); + fs.writeJson.mockResolvedValue(undefined); + handler = new RecoveryHandler({ + projectRoot: '/project', + storyId: 'story-1', + maxRetries: 3, + }); + }); + + // ============================================================ + // Exports & Constants + // ============================================================ + describe('exports', () => { + test('exports RecoveryStrategy enum', () => { + expect(RecoveryStrategy.RETRY_SAME_APPROACH).toBe('retry_same_approach'); + expect(RecoveryStrategy.ROLLBACK_AND_RETRY).toBe('rollback_and_retry'); + expect(RecoveryStrategy.SKIP_PHASE).toBe('skip_phase'); + expect(RecoveryStrategy.ESCALATE_TO_HUMAN).toBe('escalate_to_human'); + expect(RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW).toBe('trigger_recovery_workflow'); + }); + + test('exports RecoveryResult enum', () => { + expect(RecoveryResult.SUCCESS).toBe('success'); + expect(RecoveryResult.FAILED).toBe('failed'); + expect(RecoveryResult.ESCALATED).toBe('escalated'); + expect(RecoveryResult.SKIPPED).toBe('skipped'); + }); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets defaults correctly', () => { + expect(handler.projectRoot).toBe('/project'); + expect(handler.storyId).toBe('story-1'); + expect(handler.maxRetries).toBe(3); + expect(handler.autoEscalate).toBe(true); + expect(handler.circularDetection).toBe(true); + expect(handler.attempts).toEqual({}); + expect(handler.logs).toEqual([]); + }); + + test('uses process.cwd when no projectRoot', () => { + const h = new RecoveryHandler(); + expect(h.projectRoot).toBe(process.cwd()); + }); + + test('respects custom options', () => { + const h = new RecoveryHandler({ + maxRetries: 5, + autoEscalate: false, + circularDetection: false, + }); + expect(h.maxRetries).toBe(5); + expect(h.autoEscalate).toBe(false); + expect(h.circularDetection).toBe(false); + }); + + test('extends EventEmitter', () => { + expect(typeof handler.on).toBe('function'); + expect(typeof handler.emit).toBe('function'); + }); + }); + + // ============================================================ + // _classifyError + // ============================================================ + describe('error classification', () => { + test('classifies transient errors', () => { + expect(handler._classifyError('Connection timeout')).toBe('transient'); + expect(handler._classifyError('ECONNREFUSED')).toBe('transient'); + expect(handler._classifyError('ETIMEDOUT error')).toBe('transient'); + expect(handler._classifyError('Network unreachable')).toBe('transient'); + expect(handler._classifyError('fetch request failed')).toBe('transient'); + }); + + test('classifies state errors', () => { + expect(handler._classifyError('State corrupted')).toBe('state'); + expect(handler._classifyError('Inconsistent data')).toBe('state'); + expect(handler._classifyError('Invalid state detected')).toBe('state'); + expect(handler._classifyError('Out of sync')).toBe('state'); + }); + + test('classifies configuration errors', () => { + expect(handler._classifyError('Config missing')).toBe('configuration'); + expect(handler._classifyError('env not set')).toBe('configuration'); + expect(handler._classifyError('Missing config file')).toBe('configuration'); + }); + + test('classifies dependency errors', () => { + expect(handler._classifyError('Cannot find module')).toBe('dependency'); + expect(handler._classifyError('Module not found')).toBe('dependency'); + expect(handler._classifyError('Package not found')).toBe('dependency'); + }); + + test('classifies fatal errors', () => { + expect(handler._classifyError('Fatal error occurred')).toBe('fatal'); + expect(handler._classifyError('Critical failure')).toBe('fatal'); + expect(handler._classifyError('Out of memory')).toBe('fatal'); + expect(handler._classifyError('Heap overflow')).toBe('fatal'); + }); + + test('returns unknown for unrecognized errors', () => { + expect(handler._classifyError('Something went wrong')).toBe('unknown'); + expect(handler._classifyError('')).toBe('unknown'); + }); + }); + + // ============================================================ + // _isEpicCritical + // ============================================================ + describe('epic criticality', () => { + test('epic 3 and 4 are critical', () => { + expect(handler._isEpicCritical(3)).toBe(true); + expect(handler._isEpicCritical(4)).toBe(true); + }); + + test('other epics are not critical', () => { + expect(handler._isEpicCritical(1)).toBe(false); + expect(handler._isEpicCritical(5)).toBe(false); + expect(handler._isEpicCritical(6)).toBe(false); + }); + }); + + // ============================================================ + // _getEpicName + // ============================================================ + describe('epic names', () => { + test('returns known epic names', () => { + expect(handler._getEpicName(3)).toBe('Spec Pipeline'); + expect(handler._getEpicName(4)).toBe('Execution Engine'); + expect(handler._getEpicName(5)).toBe('Recovery System'); + expect(handler._getEpicName(6)).toBe('QA Loop'); + expect(handler._getEpicName(7)).toBe('Memory Layer'); + }); + + test('returns fallback for unknown epics', () => { + expect(handler._getEpicName(99)).toBe('Epic 99'); + }); + }); + + // ============================================================ + // _selectRecoveryStrategy + // ============================================================ + describe('strategy selection', () => { + test('escalates after max retries with autoEscalate', () => { + handler.attempts[3] = [{}, {}, {}]; // 3 attempts = maxRetries + const strategy = handler._selectRecoveryStrategy(3, 'error', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); + }); + + test('does not escalate after max retries without autoEscalate', () => { + handler.autoEscalate = false; + handler.attempts[3] = [{}, {}, {}]; + const strategy = handler._selectRecoveryStrategy(3, 'error', { stuck: false }); + expect(strategy).not.toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); + }); + + test('rollback on circular approach detection', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'error', { + stuck: true, reason: 'circular approach detected', + }); + expect(strategy).toBe(RecoveryStrategy.ROLLBACK_AND_RETRY); + }); + + test('retry on transient error', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'Connection timeout', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.RETRY_SAME_APPROACH); + }); + + test('rollback on state error', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'State corrupted', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.ROLLBACK_AND_RETRY); + }); + + test('skip on config error for non-critical epic', () => { + handler.attempts[6] = [{}]; + const strategy = handler._selectRecoveryStrategy(6, 'Config missing', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.SKIP_PHASE); + }); + + test('escalates on config error for critical epic', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'Config missing', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); + }); + + test('trigger recovery workflow on dependency error', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'Cannot find module', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW); + }); + + test('escalates on fatal error', () => { + handler.attempts[3] = [{}]; + const strategy = handler._selectRecoveryStrategy(3, 'Fatal error', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); + }); + + test('retry on unknown error first attempt', () => { + handler.attempts[3] = [{}]; // 1 attempt + const strategy = handler._selectRecoveryStrategy(3, 'weird error', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.RETRY_SAME_APPROACH); + }); + + test('rollback on unknown error after 2 attempts', () => { + handler.attempts[3] = [{}, {}]; // 2 attempts + const strategy = handler._selectRecoveryStrategy(3, 'weird error', { stuck: false }); + expect(strategy).toBe(RecoveryStrategy.ROLLBACK_AND_RETRY); + }); + }); + + // ============================================================ + // handleEpicFailure + // ============================================================ + describe('handleEpicFailure', () => { + test('returns result with retry for transient error', async () => { + const result = await handler.handleEpicFailure(3, 'Connection timeout'); + + expect(result.epicNum).toBe(3); + expect(result.strategy).toBe(RecoveryStrategy.RETRY_SAME_APPROACH); + expect(result.shouldRetry).toBe(true); + expect(result.success).toBe(true); + }); + + test('tracks attempts per epic', async () => { + await handler.handleEpicFailure(3, 'error 1'); + await handler.handleEpicFailure(3, 'error 2'); + await handler.handleEpicFailure(4, 'error 3'); + + expect(handler.attempts[3]).toHaveLength(2); + expect(handler.attempts[4]).toHaveLength(1); + }); + + test('accepts Error objects', async () => { + const result = await handler.handleEpicFailure(3, new Error('Connection timeout')); + + expect(result.strategy).toBe(RecoveryStrategy.RETRY_SAME_APPROACH); + }); + + test('emits recoveryAttempt event', async () => { + const events = []; + handler.on('recoveryAttempt', (e) => events.push(e)); + + await handler.handleEpicFailure(3, 'timeout'); + + expect(events).toHaveLength(1); + expect(events[0].epicNum).toBe(3); + expect(events[0].attempt).toBe(1); + }); + + test('escalates after max retries', async () => { + await handler.handleEpicFailure(3, 'error 1'); + await handler.handleEpicFailure(3, 'error 2'); + const result = await handler.handleEpicFailure(3, 'error 3'); + + expect(result.strategy).toBe(RecoveryStrategy.ESCALATE_TO_HUMAN); + expect(result.escalated).toBe(true); + expect(result.success).toBe(false); + }); + + test('records approach in attempt context', async () => { + await handler.handleEpicFailure(3, 'error', { approach: 'tdd' }); + + expect(handler.attempts[3][0].approach).toBe('tdd'); + }); + + test('records recovery result in attempt', async () => { + await handler.handleEpicFailure(3, 'Connection timeout'); + + expect(handler.attempts[3][0].recoveryStrategy).toBe(RecoveryStrategy.RETRY_SAME_APPROACH); + expect(handler.attempts[3][0].recoveryResult).toBe(RecoveryResult.SUCCESS); + }); + }); + + // ============================================================ + // _executeRecoveryStrategy + // ============================================================ + describe('strategy execution', () => { + test('RETRY_SAME_APPROACH sets shouldRetry', async () => { + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.RETRY_SAME_APPROACH, 'error', {}, + ); + expect(result.shouldRetry).toBe(true); + expect(result.success).toBe(true); + expect(result.newApproach).toBe(false); + }); + + test('ROLLBACK_AND_RETRY sets shouldRetry and newApproach', async () => { + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.ROLLBACK_AND_RETRY, 'error', {}, + ); + expect(result.shouldRetry).toBe(true); + expect(result.newApproach).toBe(true); + expect(result.success).toBe(true); + }); + + test('SKIP_PHASE sets skipped flag', async () => { + const result = await handler._executeRecoveryStrategy( + 6, RecoveryStrategy.SKIP_PHASE, 'error', {}, + ); + expect(result.skipped).toBe(true); + expect(result.success).toBe(true); + }); + + test('ESCALATE_TO_HUMAN sets escalated and saves report', async () => { + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.ESCALATE_TO_HUMAN, 'fatal error', {}, + ); + expect(result.escalated).toBe(true); + expect(result.success).toBe(false); + expect(fs.writeJson).toHaveBeenCalled(); + }); + + test('TRIGGER_RECOVERY_WORKFLOW with orchestrator', async () => { + handler.orchestrator = { + executeEpic: jest.fn().mockResolvedValue({ success: true, shouldRetry: true }), + }; + + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW, 'dep error', {}, + ); + expect(result.success).toBe(true); + expect(result.shouldRetry).toBe(true); + expect(handler.orchestrator.executeEpic).toHaveBeenCalledWith(5, expect.anything()); + }); + + test('TRIGGER_RECOVERY_WORKFLOW without orchestrator', async () => { + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW, 'dep error', {}, + ); + expect(result.success).toBe(false); + expect(result.shouldRetry).toBe(false); + }); + + test('TRIGGER_RECOVERY_WORKFLOW handles orchestrator error', async () => { + handler.orchestrator = { + executeEpic: jest.fn().mockRejectedValue(new Error('workflow failed')), + }; + + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.TRIGGER_RECOVERY_WORKFLOW, 'dep error', {}, + ); + expect(result.success).toBe(false); + }); + + test('unknown strategy logs error', async () => { + const result = await handler._executeRecoveryStrategy( + 3, 'invalid_strategy', 'error', {}, + ); + expect(result.success).toBe(false); + expect(result.details.message).toContain('Unknown strategy'); + }); + + test('catches errors during strategy execution', async () => { + handler.orchestrator = { + _log: jest.fn(), + }; + + // Force an error during escalation report save (fs.ensureDir rejects) + fs.ensureDir.mockRejectedValueOnce(new Error('disk full')); + + const result = await handler._executeRecoveryStrategy( + 3, RecoveryStrategy.ESCALATE_TO_HUMAN, 'error', {}, + ); + expect(result.success).toBe(false); + expect(result.details.error).toContain('disk full'); + }); + }); + + // ============================================================ + // _escalateToHuman + // ============================================================ + describe('escalation', () => { + test('generates escalation report with suggestions', async () => { + const events = []; + handler.on('escalation', (e) => events.push(e)); + handler.attempts[3] = [{ approach: 'default', error: 'timeout' }]; + + await handler._escalateToHuman(3, 'Connection timeout', {}); + + expect(events).toHaveLength(1); + expect(events[0].storyId).toBe('story-1'); + expect(events[0].epicNum).toBe(3); + expect(events[0].epicName).toBe('Spec Pipeline'); + expect(events[0].suggestions).toBeDefined(); + expect(events[0].suggestions.length).toBeGreaterThan(0); + }); + }); + + // ============================================================ + // _generateSuggestions + // ============================================================ + describe('suggestions', () => { + test('generates transient error suggestions', () => { + const suggestions = handler._generateSuggestions(3, 'Connection timeout'); + expect(suggestions).toContain('Check network connectivity'); + }); + + test('generates state error suggestions', () => { + const suggestions = handler._generateSuggestions(3, 'State corrupted'); + expect(suggestions).toContain('Check for conflicting changes'); + }); + + test('generates config error suggestions', () => { + const suggestions = handler._generateSuggestions(3, 'Config missing'); + expect(suggestions).toContain('Verify all required environment variables are set'); + }); + + test('generates dependency error suggestions', () => { + const suggestions = handler._generateSuggestions(3, 'Cannot find module'); + expect(suggestions).toContain('Run npm install to ensure all dependencies are installed'); + }); + + test('generates unknown error suggestions', () => { + const suggestions = handler._generateSuggestions(3, 'random error'); + expect(suggestions).toContain('Review error logs for more details'); + }); + }); + + // ============================================================ + // Attempt tracking and helpers + // ============================================================ + describe('attempt tracking', () => { + test('getAttemptCount returns 0 for new epic', () => { + expect(handler.getAttemptCount(3)).toBe(0); + }); + + test('getAttemptCount increments', async () => { + await handler.handleEpicFailure(3, 'error 1'); + await handler.handleEpicFailure(3, 'error 2'); + expect(handler.getAttemptCount(3)).toBe(2); + }); + + test('canRetry returns true when under max', () => { + expect(handler.canRetry(3)).toBe(true); + }); + + test('canRetry returns false at max retries', async () => { + await handler.handleEpicFailure(3, 'e1'); + await handler.handleEpicFailure(3, 'e2'); + await handler.handleEpicFailure(3, 'e3'); + expect(handler.canRetry(3)).toBe(false); + }); + + test('resetAttempts clears attempts for epic', async () => { + await handler.handleEpicFailure(3, 'error'); + handler.resetAttempts(3); + expect(handler.getAttemptCount(3)).toBe(0); + expect(handler.canRetry(3)).toBe(true); + }); + + test('getAttemptHistory returns copy', async () => { + await handler.handleEpicFailure(3, 'error'); + const history = handler.getAttemptHistory(); + history[99] = []; + expect(handler.attempts[99]).toBeUndefined(); + }); + }); + + // ============================================================ + // Logging + // ============================================================ + describe('logging', () => { + test('getLogs returns copy of all logs', async () => { + await handler.handleEpicFailure(3, 'Connection timeout'); + + const logs = handler.getLogs(); + expect(logs.length).toBeGreaterThan(0); + expect(logs[0]).toHaveProperty('timestamp'); + expect(logs[0]).toHaveProperty('level'); + expect(logs[0]).toHaveProperty('message'); + + logs.push({ fake: true }); + expect(handler.getLogs()).not.toContainEqual({ fake: true }); + }); + + test('getEpicLogs filters by epic number', async () => { + await handler.handleEpicFailure(3, 'error'); + await handler.handleEpicFailure(4, 'error'); + + const epic3Logs = handler.getEpicLogs(3); + const epic4Logs = handler.getEpicLogs(4); + + expect(epic3Logs.length).toBeGreaterThan(0); + expect(epic4Logs.length).toBeGreaterThan(0); + for (const log of epic3Logs) { + expect(log.message).toMatch(/Epic 3|epic-3/); + } + }); + + test('logs to orchestrator when available', async () => { + const mockLog = jest.fn(); + handler.orchestrator = { _log: mockLog }; + + await handler.handleEpicFailure(3, 'error'); + expect(mockLog).toHaveBeenCalled(); + }); + }); + + // ============================================================ + // clear + // ============================================================ + describe('clear', () => { + test('resets attempts and logs', async () => { + await handler.handleEpicFailure(3, 'error'); + + handler.clear(); + + expect(handler.attempts).toEqual({}); + expect(handler.logs).toEqual([]); + }); + }); +}); diff --git a/tests/core/orchestration/skill-dispatcher.test.js b/tests/core/orchestration/skill-dispatcher.test.js new file mode 100644 index 0000000000..b548cd5732 --- /dev/null +++ b/tests/core/orchestration/skill-dispatcher.test.js @@ -0,0 +1,453 @@ +/** + * Unit tests for skill-dispatcher module + * + * Tests the SkillDispatcher class that maps agent IDs to AIOX Skill + * invocations and handles dispatch payloads and result parsing. + */ + +const SkillDispatcher = require('../../../.aiox-core/core/orchestration/skill-dispatcher'); + +describe('SkillDispatcher', () => { + let dispatcher; + + beforeEach(() => { + dispatcher = new SkillDispatcher(); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('initializes with default options', () => { + expect(dispatcher.options).toEqual({}); + expect(dispatcher.skillMapping).toBeDefined(); + expect(dispatcher.agentPersonas).toBeDefined(); + }); + + test('stores custom options', () => { + const d = new SkillDispatcher({ debug: true }); + expect(d.options).toEqual({ debug: true }); + }); + + test('has all primary agents in skill mapping', () => { + const primary = [ + 'architect', 'data-engineer', 'dev', 'qa', + 'pm', 'po', 'sm', 'analyst', + 'ux-design-expert', 'devops', 'aiox-master', + ]; + for (const agent of primary) { + expect(dispatcher.skillMapping[agent]).toBeDefined(); + } + }); + + test('has aliases in skill mapping', () => { + expect(dispatcher.skillMapping['ux-expert']).toBe('AIOX:agents:ux-design-expert'); + expect(dispatcher.skillMapping['github-devops']).toBe('AIOX:agents:devops'); + }); + }); + + // ============================================================ + // getSkillName + // ============================================================ + describe('getSkillName', () => { + test('returns mapped skill name for known agents', () => { + expect(dispatcher.getSkillName('architect')).toBe('AIOX:agents:architect'); + expect(dispatcher.getSkillName('dev')).toBe('AIOX:agents:dev'); + expect(dispatcher.getSkillName('qa')).toBe('AIOX:agents:qa'); + }); + + test('resolves aliases to canonical skill', () => { + expect(dispatcher.getSkillName('ux-expert')).toBe('AIOX:agents:ux-design-expert'); + expect(dispatcher.getSkillName('github-devops')).toBe('AIOX:agents:devops'); + }); + + test('generates fallback skill name for unknown agents', () => { + expect(dispatcher.getSkillName('custom-agent')).toBe('AIOX:agents:custom-agent'); + }); + }); + + // ============================================================ + // getAgentPersona + // ============================================================ + describe('getAgentPersona', () => { + test('returns persona for known agents', () => { + expect(dispatcher.getAgentPersona('architect')).toEqual({ + name: 'Aria', title: 'System Architect', + }); + expect(dispatcher.getAgentPersona('dev')).toEqual({ + name: 'Dex', title: 'Senior Developer', + }); + expect(dispatcher.getAgentPersona('qa')).toEqual({ + name: 'Quinn', title: 'QA Guardian', + }); + }); + + test('returns default persona for unknown agents', () => { + expect(dispatcher.getAgentPersona('unknown')).toEqual({ + name: 'unknown', title: 'AIOX Agent', + }); + }); + + test('returns persona for aliases', () => { + expect(dispatcher.getAgentPersona('ux-expert')).toEqual({ + name: 'Brad', title: 'UX Design Expert', + }); + }); + }); + + // ============================================================ + // buildDispatchPayload + // ============================================================ + describe('buildDispatchPayload', () => { + const baseParams = { + agentId: 'architect', + prompt: 'Design the system architecture', + phase: { + phase: 1, + phase_name: 'Architecture', + step: 'design', + action: 'create-architecture', + task: 'design-system.md', + creates: 'docs/architecture.md', + checklist: 'arch-review', + template: 'arch-template', + }, + context: { + workflowId: 'wf-123', + yoloMode: true, + previousPhases: { 0: { agent: 'pm' } }, + executionProfile: 'fast', + executionPolicy: { risk: 'low' }, + }, + }; + + test('builds complete payload', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + + expect(payload.skill).toBe('AIOX:agents:architect'); + expect(payload.context.phase).toBe(1); + expect(payload.context.phaseName).toBe('Architecture'); + expect(payload.context.step).toBe('design'); + expect(payload.context.action).toBe('create-architecture'); + expect(payload.context.task).toBe('design-system.md'); + expect(payload.context.creates).toBe('docs/architecture.md'); + expect(payload.context.prompt).toBe('Design the system architecture'); + expect(payload.context.workflowId).toBe('wf-123'); + expect(payload.context.yoloMode).toBe(true); + expect(payload.context.executionProfile).toBe('fast'); + }); + + test('builds args string with task and output', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + + expect(payload.args).toContain('--task="design-system.md"'); + expect(payload.args).toContain('--output="docs/architecture.md"'); + expect(payload.args).toContain('--phase=1'); + expect(payload.args).toContain('--yolo'); + }); + + test('handles array creates (uses first element)', () => { + const params = { + ...baseParams, + phase: { ...baseParams.phase, creates: ['docs/a.md', 'docs/b.md'] }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--output="docs/a.md"'); + }); + + test('omits optional args when not present', () => { + const params = { + agentId: 'dev', + prompt: 'Implement', + phase: { phase: 2 }, + context: { workflowId: 'wf-1' }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).not.toContain('--task'); + expect(payload.args).not.toContain('--output'); + expect(payload.args).not.toContain('--yolo'); + expect(payload.args).toContain('--phase=2'); + }); + + test('includes tech stack flags', () => { + const params = { + ...baseParams, + techStackProfile: { + hasDatabase: true, + database: { type: 'postgresql' }, + hasFrontend: true, + frontend: { framework: 'react' }, + hasTypeScript: true, + }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--has-database'); + expect(payload.args).toContain('--db-type="postgresql"'); + expect(payload.args).toContain('--has-frontend'); + expect(payload.args).toContain('--frontend="react"'); + expect(payload.args).toContain('--typescript'); + expect(payload.context.techStack).toBeDefined(); + }); + + test('omits tech stack flags when no profile', () => { + const payload = dispatcher.buildDispatchPayload(baseParams); + expect(payload.args).not.toContain('--has-database'); + expect(payload.args).not.toContain('--typescript'); + }); + + test('handles partial tech stack (db only)', () => { + const params = { + ...baseParams, + techStackProfile: { + hasDatabase: true, + database: { type: 'mysql' }, + hasFrontend: false, + hasTypeScript: false, + }, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.args).toContain('--has-database'); + expect(payload.args).toContain('--db-type="mysql"'); + expect(payload.args).not.toContain('--has-frontend'); + expect(payload.args).not.toContain('--typescript'); + }); + + test('defaults for missing context fields', () => { + const params = { + agentId: 'dev', + prompt: 'Build', + phase: { phase: 1 }, + context: {}, + }; + + const payload = dispatcher.buildDispatchPayload(params); + expect(payload.context.yoloMode).toBe(false); + expect(payload.context.previousPhases).toEqual({}); + expect(payload.context.executionProfile).toBeNull(); + expect(payload.context.executionPolicy).toBeNull(); + }); + }); + + // ============================================================ + // parseSkillOutput + // ============================================================ + describe('parseSkillOutput', () => { + test('handles null result', () => { + const result = dispatcher.parseSkillOutput(null); + expect(result.status).toBe('failed'); + expect(result.summary).toContain('No result'); + expect(result.timestamp).toBeDefined(); + }); + + test('handles undefined result', () => { + const result = dispatcher.parseSkillOutput(undefined); + expect(result.status).toBe('failed'); + }); + + test('passes through structured object with status', () => { + const input = { + status: 'success', + output_path: '/out.md', + summary: 'Done', + timestamp: '2025-01-01T00:00:00Z', + }; + + const result = dispatcher.parseSkillOutput(input); + expect(result.status).toBe('success'); + expect(result.output_path).toBe('/out.md'); + expect(result.timestamp).toBe('2025-01-01T00:00:00Z'); + }); + + test('adds timestamp to structured object if missing', () => { + const input = { status: 'success', summary: 'Done' }; + const result = dispatcher.parseSkillOutput(input); + expect(result.timestamp).toBeDefined(); + expect(result.timestamp).not.toBe('2025-01-01T00:00:00Z'); + }); + + test('extracts JSON from markdown code block', () => { + const input = 'Some text\n```json\n{"status":"success","summary":"Extracted","output_path":"/out.md"}\n```\nMore text'; + const result = dispatcher.parseSkillOutput(input, { creates: '/default.md' }); + + expect(result.status).toBe('success'); + expect(result.summary).toBe('Extracted'); + expect(result.output_path).toBe('/out.md'); + }); + + test('uses phase.creates when JSON has no output_path', () => { + const input = '```json\n{"summary":"Done"}\n```'; + const result = dispatcher.parseSkillOutput(input, { creates: '/phase-out.md' }); + + expect(result.output_path).toBe('/phase-out.md'); + }); + + test('parses plain JSON string', () => { + const input = '{"status":"success","summary":"Plain JSON"}'; + const result = dispatcher.parseSkillOutput(input); + + expect(result.status).toBe('success'); + expect(result.summary).toBe('Plain JSON'); + }); + + test('handles plain text as success with summary', () => { + const input = 'The architecture has been designed successfully.'; + const result = dispatcher.parseSkillOutput(input, { creates: '/arch.md' }); + + expect(result.status).toBe('success'); + expect(result.summary).toBe(input); + expect(result.output_path).toBe('/arch.md'); + }); + + test('truncates long text summaries to 500 chars', () => { + const input = 'A'.repeat(1000); + const result = dispatcher.parseSkillOutput(input); + + expect(result.summary.length).toBe(500); + }); + + test('handles invalid JSON in markdown block gracefully', () => { + const input = '```json\n{invalid json}\n```'; + const result = dispatcher.parseSkillOutput(input); + + // Falls through to plain JSON parse, fails, then plain text + expect(result.status).toBe('success'); + expect(result.summary).toContain('```json'); + }); + + test('wraps unknown types in default structure', () => { + const input = 42; + const result = dispatcher.parseSkillOutput(input, { creates: '/out.md' }); + + expect(result.status).toBe('success'); + expect(result.output).toBe(42); + expect(result.output_path).toBe('/out.md'); + }); + + test('wraps boolean in default structure', () => { + const result = dispatcher.parseSkillOutput(true); + expect(result.status).toBe('success'); + expect(result.output).toBe(true); + }); + }); + + // ============================================================ + // createSkipResult + // ============================================================ + describe('createSkipResult', () => { + test('creates skip result with all fields', () => { + const phase = { + phase: 3, + phase_name: 'QA Review', + agent: 'qa', + }; + + const result = dispatcher.createSkipResult(phase, 'No tests configured'); + + expect(result.status).toBe('skipped'); + expect(result.reason).toBe('No tests configured'); + expect(result.phase).toBe(3); + expect(result.phaseName).toBe('QA Review'); + expect(result.agent).toBe('qa'); + expect(result.timestamp).toBeDefined(); + }); + }); + + // ============================================================ + // formatDispatchLog + // ============================================================ + describe('formatDispatchLog', () => { + test('formats log with persona and details', () => { + const payload = { + skill: 'AIOX:agents:architect', + args: '--task="design.md"', + context: { + phase: 1, + phaseName: 'Architecture', + task: 'design-system.md', + creates: 'docs/arch.md', + }, + }; + + const log = dispatcher.formatDispatchLog(payload); + + expect(log).toContain('Aria'); + expect(log).toContain('@architect'); + expect(log).toContain('AIOX:agents:architect'); + expect(log).toContain('1 - Architecture'); + expect(log).toContain('design-system.md'); + expect(log).toContain('docs/arch.md'); + }); + + test('shows N/A for missing task and output', () => { + const payload = { + skill: 'AIOX:agents:dev', + args: '', + context: { phase: 2, phaseName: 'Dev' }, + }; + + const log = dispatcher.formatDispatchLog(payload); + expect(log).toContain('Task: N/A'); + expect(log).toContain('Output: N/A'); + }); + + test('uses agent ID as name for unknown agents', () => { + const payload = { + skill: 'AIOX:agents:custom', + args: '', + context: { phase: 1, phaseName: 'Custom' }, + }; + + const log = dispatcher.formatDispatchLog(payload); + expect(log).toContain('custom'); + }); + }); + + // ============================================================ + // getAvailableAgents + // ============================================================ + describe('getAvailableAgents', () => { + test('returns only primary agents (no aliases)', () => { + const agents = dispatcher.getAvailableAgents(); + + expect(agents).toContain('architect'); + expect(agents).toContain('dev'); + expect(agents).toContain('qa'); + expect(agents).toContain('devops'); + expect(agents).toContain('aiox-master'); + + // Aliases should NOT be included + expect(agents).not.toContain('ux-expert'); + expect(agents).not.toContain('github-devops'); + }); + + test('returns expected count of primary agents', () => { + const agents = dispatcher.getAvailableAgents(); + // Intentional: update this count when adding/removing primary agents + expect(agents).toHaveLength(11); + }); + }); + + // ============================================================ + // isValidAgent + // ============================================================ + describe('isValidAgent', () => { + test('returns true for known agents', () => { + expect(dispatcher.isValidAgent('architect')).toBe(true); + expect(dispatcher.isValidAgent('dev')).toBe(true); + expect(dispatcher.isValidAgent('ux-expert')).toBe(true); + }); + + test('returns true for AIOX: prefixed agents', () => { + expect(dispatcher.isValidAgent('AIOX:custom:agent')).toBe(true); + }); + + test('returns false for unknown non-AIOX agents', () => { + expect(dispatcher.isValidAgent('random-agent')).toBe(false); + expect(dispatcher.isValidAgent('')).toBe(false); + }); + }); +}); diff --git a/tests/core/orchestration/subagent-prompt-builder.test.js b/tests/core/orchestration/subagent-prompt-builder.test.js new file mode 100644 index 0000000000..93776a3388 --- /dev/null +++ b/tests/core/orchestration/subagent-prompt-builder.test.js @@ -0,0 +1,425 @@ +/** + * Unit tests for subagent-prompt-builder module + * + * Tests the SubagentPromptBuilder class that assembles prompts + * from real agent definitions, task files, checklists, and templates. + */ + +jest.mock('fs-extra'); +jest.mock('js-yaml'); + +const fs = require('fs-extra'); +const path = require('path'); +const yaml = require('js-yaml'); + +const SubagentPromptBuilder = require('../../../.aiox-core/core/orchestration/subagent-prompt-builder'); + +describe('SubagentPromptBuilder', () => { + let builder; + + beforeEach(() => { + jest.resetAllMocks(); + builder = new SubagentPromptBuilder('/project'); + }); + + // ============================================================ + // Constructor + // ============================================================ + describe('constructor', () => { + test('sets project root and paths', () => { + expect(builder.projectRoot).toBe('/project'); + expect(builder.aioxCoreRoot).toBe(path.join('/project', '.aiox-core')); + expect(builder.paths.agents).toContain(path.join('development', 'agents')); + expect(builder.paths.tasks).toContain(path.join('development', 'tasks')); + expect(builder.paths.checklists).toContain(path.join('product', 'checklists')); + expect(builder.paths.templates).toContain(path.join('product', 'templates')); + }); + }); + + // ============================================================ + // loadAgentDefinition + // ============================================================ + describe('loadAgentDefinition', () => { + test('loads agent definition file', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('# Agent: architect\nYou are Aria.'); + + const result = await builder.loadAgentDefinition('architect'); + + expect(result).toBe('# Agent: architect\nYou are Aria.'); + }); + + test('tries alternative naming pattern with underscores', async () => { + fs.pathExists + .mockResolvedValueOnce(false) // architect.md not found + .mockResolvedValueOnce(true); // architect.md (no dash to underscore here) + fs.readFile.mockResolvedValueOnce('# Data Engineer'); + + const result = await builder.loadAgentDefinition('data-engineer'); + + expect(fs.pathExists).toHaveBeenCalledTimes(2); + expect(result).toBe('# Data Engineer'); + }); + + test('returns minimal definition when not found', async () => { + fs.pathExists.mockResolvedValue(false); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = await builder.loadAgentDefinition('unknown-agent'); + + expect(result).toContain('# Agent: unknown-agent'); + expect(result).toContain('No definition file found'); + consoleSpy.mockRestore(); + }); + }); + + // ============================================================ + // loadTaskDefinition + // ============================================================ + describe('loadTaskDefinition', () => { + test('loads task definition by filename', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('# Document Project\nSteps...'); + + const result = await builder.loadTaskDefinition('document-project.md'); + + expect(result).toBe('# Document Project\nSteps...'); + }); + + test('appends .md extension if missing', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('# Task Content'); + + const result = await builder.loadTaskDefinition('document-project'); + + expect(result).toBe('# Task Content'); + }); + + test('tries alternative path for action names', async () => { + fs.pathExists + .mockResolvedValueOnce(false) // primary path not found + .mockResolvedValueOnce(true); // alt path found + fs.readFile.mockResolvedValueOnce('# Alt Task'); + + const result = await builder.loadTaskDefinition('*create-story'); + + expect(result).toBe('# Alt Task'); + }); + + test('returns minimal definition when not found', async () => { + fs.pathExists.mockResolvedValue(false); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const result = await builder.loadTaskDefinition('missing-task'); + + expect(result).toContain('# Task: missing-task'); + consoleSpy.mockRestore(); + }); + }); + + // ============================================================ + // extractAndLoadChecklists + // ============================================================ + describe('extractAndLoadChecklists', () => { + test('loads override checklist from phase config', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('- [ ] Check 1\n- [ ] Check 2'); + + const result = await builder.extractAndLoadChecklists('No frontmatter', 'quality-gate'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('quality-gate'); + expect(result[0].content).toContain('Check 1'); + }); + + test('loads checklists from task frontmatter', async () => { + const taskDef = '---\nchecklists:\n - code-review\n - testing\n---\n# Task'; + yaml.load.mockReturnValue({ checklists: ['code-review', 'testing'] }); + fs.pathExists.mockResolvedValue(true); + fs.readFile + .mockResolvedValueOnce('Code review checklist') + .mockResolvedValueOnce('Testing checklist'); + + const result = await builder.extractAndLoadChecklists(taskDef); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('code-review'); + expect(result[1].name).toBe('testing'); + }); + + test('skips duplicate checklist from override', async () => { + const taskDef = '---\nchecklists:\n - quality-gate\n - testing\n---\n# Task'; + yaml.load.mockReturnValue({ checklists: ['quality-gate', 'testing'] }); + fs.pathExists.mockResolvedValue(true); + fs.readFile + .mockResolvedValueOnce('Override checklist') + .mockResolvedValueOnce('Testing checklist'); + + const result = await builder.extractAndLoadChecklists(taskDef, 'quality-gate'); + + // quality-gate loaded once (override), testing loaded from frontmatter + expect(result).toHaveLength(2); + expect(result[0].name).toBe('quality-gate'); + expect(result[1].name).toBe('testing'); + }); + + test('handles missing frontmatter gracefully', async () => { + const result = await builder.extractAndLoadChecklists('# No frontmatter here'); + expect(result).toHaveLength(0); + }); + + test('handles invalid YAML in frontmatter', async () => { + const taskDef = '---\n{invalid yaml\n---\n# Task'; + yaml.load.mockImplementation(() => { throw new Error('parse error'); }); + + const result = await builder.extractAndLoadChecklists(taskDef); + expect(result).toHaveLength(0); + }); + }); + + // ============================================================ + // loadChecklist + // ============================================================ + describe('loadChecklist', () => { + test('loads checklist file', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('- [ ] Item 1'); + + const result = await builder.loadChecklist('quality-gate'); + expect(result).toBe('- [ ] Item 1'); + }); + + test('appends .md extension', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockResolvedValue('content'); + + await builder.loadChecklist('code-review'); + expect(fs.pathExists).toHaveBeenCalledWith(expect.stringContaining('code-review.md')); + }); + + test('returns null when not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await builder.loadChecklist('missing'); + expect(result).toBeNull(); + }); + }); + + // ============================================================ + // extractAndLoadTemplates + // ============================================================ + describe('extractAndLoadTemplates', () => { + test('loads override template from phase config', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('template: content'); + + const result = await builder.extractAndLoadTemplates('No frontmatter', 'report.yaml'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('report.yaml'); + }); + + test('loads templates from task frontmatter', async () => { + const taskDef = '---\ntemplates:\n - report\n - schema\n---\n# Task'; + yaml.load.mockReturnValue({ templates: ['report', 'schema'] }); + fs.pathExists.mockResolvedValue(true); + fs.readFile + .mockResolvedValueOnce('report template') + .mockResolvedValueOnce('schema template'); + + const result = await builder.extractAndLoadTemplates(taskDef); + + expect(result).toHaveLength(2); + }); + + test('handles invalid YAML frontmatter', async () => { + const taskDef = '---\n{bad\n---\n# Task'; + yaml.load.mockImplementation(() => { throw new Error('parse'); }); + + const result = await builder.extractAndLoadTemplates(taskDef); + expect(result).toHaveLength(0); + }); + }); + + // ============================================================ + // loadTemplate + // ============================================================ + describe('loadTemplate', () => { + test('tries .yaml, .yml, and .md extensions', async () => { + fs.pathExists + .mockResolvedValueOnce(false) // .yaml not found + .mockResolvedValueOnce(false) // .yml not found + .mockResolvedValueOnce(true); // .md found + fs.readFile.mockResolvedValueOnce('# Template'); + + const result = await builder.loadTemplate('report'); + + expect(result).toBe('# Template'); + expect(fs.pathExists).toHaveBeenCalledTimes(3); + }); + + test('strips existing extension before trying', async () => { + fs.pathExists.mockResolvedValueOnce(true); + fs.readFile.mockResolvedValueOnce('content'); + + await builder.loadTemplate('report.yaml'); + + expect(fs.pathExists).toHaveBeenCalledWith(expect.stringContaining('report.yaml')); + }); + + test('returns null when not found', async () => { + fs.pathExists.mockResolvedValue(false); + + const result = await builder.loadTemplate('missing'); + expect(result).toBeNull(); + }); + }); + + // ============================================================ + // formatContextSection + // ============================================================ + describe('formatContextSection', () => { + test('returns empty message when no previous phases', () => { + expect(builder.formatContextSection({})).toContain('No previous phase outputs'); + expect(builder.formatContextSection({ previousPhases: {} })).toContain('No previous phase outputs'); + }); + + test('formats previous phases with details', () => { + const context = { + previousPhases: { + 1: { agent: 'architect', action: 'design', result: { output_path: '/out/arch.md', summary: 'Architecture designed' } }, + 2: { agent: 'dev', action: 'implement', result: { summary: 'Code implemented' } }, + }, + }; + + const result = builder.formatContextSection(context); + + expect(result).toContain('Phase 1: architect'); + expect(result).toContain('Action: design'); + expect(result).toContain('Output: /out/arch.md'); + expect(result).toContain('Summary: Architecture designed'); + expect(result).toContain('Phase 2: dev'); + }); + + test('handles phase without output_path', () => { + const context = { + previousPhases: { + 1: { agent: 'qa', action: 'test', result: {} }, + }, + }; + + const result = builder.formatContextSection(context); + expect(result).toContain('Phase 1: qa'); + expect(result).not.toContain('Output:'); + }); + }); + + // ============================================================ + // assemblePrompt + // ============================================================ + describe('assemblePrompt', () => { + test('assembles complete prompt with all components', () => { + const result = builder.assemblePrompt({ + agentId: 'architect', + agentDef: '# Aria\nYou are the architect.', + taskFile: 'design-system.md', + taskDef: '# Design System\n1. Create architecture', + checklists: [{ name: 'quality-gate', content: '- [ ] Review design' }], + templates: [{ name: 'arch-template', content: 'structure: layers' }], + context: { + creates: 'docs/architecture.md', + yoloMode: true, + executionProfile: 'fast', + elicit: false, + executionPolicy: { risk: 'low' }, + }, + contextSection: 'Phase 1 output here', + }); + + expect(result).toContain('AGENT TRANSFORMATION'); + expect(result).toContain('@architect'); + expect(result).toContain('# Aria'); + expect(result).toContain('design-system.md'); + expect(result).toContain('# Design System'); + expect(result).toContain('QUALITY CHECKLISTS'); + expect(result).toContain('quality-gate'); + expect(result).toContain('OUTPUT TEMPLATES'); + expect(result).toContain('arch-template'); + expect(result).toContain('YOLO (autonomous)'); + expect(result).toContain('Phase 1 output here'); + expect(result).toContain('EXECUTION INSTRUCTIONS'); + }); + + test('omits checklists section when empty', () => { + const result = builder.assemblePrompt({ + agentId: 'dev', + agentDef: 'Agent', + taskFile: 'task.md', + taskDef: 'Task', + checklists: [], + templates: [], + context: {}, + contextSection: '', + }); + + expect(result).not.toContain('QUALITY CHECKLISTS'); + expect(result).not.toContain('OUTPUT TEMPLATES'); + }); + + test('uses default values for missing context fields', () => { + const result = builder.assemblePrompt({ + agentId: 'dev', + agentDef: 'Agent', + taskFile: 'task.md', + taskDef: 'Task', + checklists: [], + templates: [], + context: {}, + contextSection: '', + }); + + expect(result).toContain('See task definition'); + expect(result).toContain('Interactive'); + expect(result).toContain('balanced'); + }); + }); + + // ============================================================ + // buildPrompt (integration) + // ============================================================ + describe('buildPrompt', () => { + test('builds complete prompt from files', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockImplementation((path) => { + if (path.includes('agents')) return Promise.resolve('# Agent Definition'); + if (path.includes('tasks')) return Promise.resolve('# Task Definition'); + return Promise.resolve('content'); + }); + + const result = await builder.buildPrompt('architect', 'design.md', { + creates: 'output.md', + }); + + expect(result).toContain('AGENT TRANSFORMATION'); + expect(result).toContain('# Agent Definition'); + expect(result).toContain('# Task Definition'); + }); + + test('builds prompt with checklists from frontmatter', async () => { + fs.pathExists.mockResolvedValue(true); + fs.readFile.mockImplementation((path) => { + if (path.includes('agents')) return Promise.resolve('# Agent'); + if (path.includes('tasks')) { + return Promise.resolve('---\nchecklists:\n - qa\n---\n# Task'); + } + if (path.includes('checklists')) return Promise.resolve('- [ ] Check'); + return Promise.resolve(''); + }); + yaml.load.mockReturnValue({ checklists: ['qa'] }); + + const result = await builder.buildPrompt('qa', 'run-tests.md', {}); + + expect(result).toContain('QUALITY CHECKLISTS'); + }); + }); +}); diff --git a/tests/core/orchestration/surface-checker.test.js b/tests/core/orchestration/surface-checker.test.js new file mode 100644 index 0000000000..5e4c6f00ee --- /dev/null +++ b/tests/core/orchestration/surface-checker.test.js @@ -0,0 +1,606 @@ +/** + * Unit tests for surface-checker module + * + * Tests the SurfaceChecker class that determines when Bob should + * surface to ask the human for decisions. + */ + +jest.mock('fs'); +jest.mock('js-yaml'); + +const fs = require('fs'); +const yaml = require('js-yaml'); + +const { SurfaceChecker, createSurfaceChecker, shouldSurface } = require('../../../.aiox-core/core/orchestration/surface-checker'); + +const MOCK_CRITERIA = { + version: '1.0.0', + metadata: { author: 'test', description: 'Test criteria' }, + evaluation_order: ['cost_threshold', 'high_risk', 'error_threshold'], + criteria: { + cost_threshold: { + id: 'SURF-1', + name: 'Cost Threshold', + condition: 'estimated_cost > 10', + action: 'confirm_cost', + message: 'Estimated cost: $${estimated_cost}. Proceed?', + severity: 'warning', + bypass: true, + }, + high_risk: { + id: 'SURF-2', + name: 'High Risk', + condition: 'risk_level == "HIGH"', + action: 'confirm_risk', + message: 'High risk detected: ${risk_details}', + severity: 'critical', + bypass: false, + }, + error_threshold: { + id: 'SURF-3', + name: 'Error Threshold', + condition: 'errors_in_task >= 3', + action: 'ask_help', + message: 'Multiple errors: ${error_summary}', + severity: 'warning', + }, + destructive_actions: ['delete', 'drop', 'reset', 'force-push'], + }, + actions: { + confirm_cost: { + type: 'confirm', + prompt_type: 'yes_no', + default: null, + timeout_seconds: 120, + on_timeout: 'abort', + }, + confirm_risk: { + type: 'explicit_confirm', + prompt_type: 'text', + required_input: 'CONFIRM', + timeout_seconds: 300, + on_timeout: 'abort', + }, + }, +}; + +describe('SurfaceChecker', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + // ============================================================ + // Constructor and Loading + // ============================================================ + describe('constructor', () => { + test('sets default criteria path', () => { + const checker = new SurfaceChecker(); + expect(checker.criteriaPath).toContain('bob-surface-criteria.yaml'); + expect(checker._loaded).toBe(false); + }); + + test('accepts custom criteria path', () => { + const checker = new SurfaceChecker('/custom/path.yaml'); + expect(checker.criteriaPath).toBe('/custom/path.yaml'); + }); + }); + + describe('load', () => { + test('loads criteria from YAML file successfully', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('yaml content'); + yaml.load.mockReturnValue(MOCK_CRITERIA); + + const checker = new SurfaceChecker('/test/criteria.yaml'); + const result = checker.load(); + + expect(result).toBe(true); + expect(checker._loaded).toBe(true); + expect(checker.criteria).toEqual(MOCK_CRITERIA); + }); + + test('returns false when file does not exist', () => { + fs.existsSync.mockReturnValue(false); + + const checker = new SurfaceChecker('/test/missing.yaml'); + const result = checker.load(); + + expect(result).toBe(false); + expect(checker._loaded).toBe(false); + }); + + test('returns false on parse error', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation(() => { throw new Error('parse error'); }); + + const checker = new SurfaceChecker('/test/bad.yaml'); + const result = checker.load(); + + expect(result).toBe(false); + }); + + test('_ensureLoaded calls load if not loaded', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('yaml content'); + yaml.load.mockReturnValue(MOCK_CRITERIA); + + const checker = new SurfaceChecker('/test/criteria.yaml'); + checker._ensureLoaded(); + + expect(checker._loaded).toBe(true); + }); + }); + + // ============================================================ + // Condition Evaluation + // ============================================================ + describe('evaluateCondition', () => { + let checker; + + beforeEach(() => { + checker = new SurfaceChecker(); + checker.criteria = MOCK_CRITERIA; + checker._loaded = true; + }); + + test('evaluates greater than (>) correctly', () => { + expect(checker.evaluateCondition('estimated_cost > 10', { estimated_cost: 15 })).toBe(true); + expect(checker.evaluateCondition('estimated_cost > 10', { estimated_cost: 5 })).toBe(false); + expect(checker.evaluateCondition('estimated_cost > 10', { estimated_cost: 10 })).toBe(false); + }); + + test('evaluates greater than or equal (>=) correctly', () => { + expect(checker.evaluateCondition('errors_in_task >= 3', { errors_in_task: 3 })).toBe(true); + expect(checker.evaluateCondition('errors_in_task >= 3', { errors_in_task: 5 })).toBe(true); + expect(checker.evaluateCondition('errors_in_task >= 3', { errors_in_task: 2 })).toBe(false); + }); + + test('evaluates less than (<) correctly', () => { + expect(checker.evaluateCondition('score < 50', { score: 30 })).toBe(true); + expect(checker.evaluateCondition('score < 50', { score: 50 })).toBe(false); + expect(checker.evaluateCondition('score < 50', { score: 70 })).toBe(false); + }); + + test('evaluates less than or equal (<=) correctly', () => { + expect(checker.evaluateCondition('score <= 50', { score: 50 })).toBe(true); + expect(checker.evaluateCondition('score <= 50', { score: 30 })).toBe(true); + expect(checker.evaluateCondition('score <= 50', { score: 70 })).toBe(false); + }); + + test('evaluates string equality (==) correctly', () => { + expect(checker.evaluateCondition('risk_level == "HIGH"', { risk_level: 'HIGH' })).toBe(true); + expect(checker.evaluateCondition('risk_level == "HIGH"', { risk_level: 'LOW' })).toBe(false); + }); + + test('evaluates numeric equality (==) correctly', () => { + expect(checker.evaluateCondition('valid_options_count == 0', { valid_options_count: 0 })).toBe(true); + expect(checker.evaluateCondition('valid_options_count == 0', { valid_options_count: 1 })).toBe(false); + }); + + test('evaluates IN operator correctly', () => { + expect(checker.evaluateCondition('action_type IN destructive_actions', { action_type: 'delete' })).toBe(true); + expect(checker.evaluateCondition('action_type IN destructive_actions', { action_type: 'create' })).toBe(false); + }); + + test('evaluates scope comparison correctly', () => { + expect(checker.evaluateCondition('requested_scope > approved_scope', { + scope_expanded: true, + })).toBe(true); + + expect(checker.evaluateCondition('requested_scope > approved_scope', { + requested_scope: 'full project', + approved_scope: 'module', + })).toBe(true); + + expect(checker.evaluateCondition('requested_scope > approved_scope', { + requested_scope: 'a', + approved_scope: 'longer scope', + })).toBe(false); + }); + + test('evaluates OR conditions correctly', () => { + expect(checker.evaluateCondition('score > 90 OR risk_level == "HIGH"', { + score: 95, risk_level: 'LOW', + })).toBe(true); + + expect(checker.evaluateCondition('score > 90 OR risk_level == "HIGH"', { + score: 50, risk_level: 'HIGH', + })).toBe(true); + + expect(checker.evaluateCondition('score > 90 OR risk_level == "HIGH"', { + score: 50, risk_level: 'LOW', + })).toBe(false); + }); + + test('evaluates AND conditions correctly', () => { + expect(checker.evaluateCondition('score > 50 AND risk_level == "HIGH"', { + score: 80, risk_level: 'HIGH', + })).toBe(true); + + expect(checker.evaluateCondition('score > 50 AND risk_level == "HIGH"', { + score: 80, risk_level: 'LOW', + })).toBe(false); + }); + + test('evaluates boolean field check correctly', () => { + expect(checker.evaluateCondition('requires_api_key', { requires_api_key: true })).toBe(true); + expect(checker.evaluateCondition('requires_api_key', { requires_api_key: false })).toBe(false); + expect(checker.evaluateCondition('requires_api_key', {})).toBe(false); + }); + + test('handles missing field with default 0 for numeric comparisons', () => { + expect(checker.evaluateCondition('missing_field > 10', {})).toBe(false); + expect(checker.evaluateCondition('missing_field >= 0', {})).toBe(true); + }); + + test('returns false for unknown condition format', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + expect(checker.evaluateCondition('invalid %%% condition', {})).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown condition')); + consoleSpy.mockRestore(); + }); + + test('evaluates float values correctly', () => { + expect(checker.evaluateCondition('estimated_cost > 9.99', { estimated_cost: 10 })).toBe(true); + expect(checker.evaluateCondition('estimated_cost > 10.5', { estimated_cost: 10 })).toBe(false); + }); + }); + + // ============================================================ + // Message Interpolation + // ============================================================ + describe('interpolateMessage', () => { + let checker; + + beforeEach(() => { + checker = new SurfaceChecker(); + }); + + test('interpolates simple variables', () => { + const result = checker.interpolateMessage( + 'Error in ${action_description}', + { action_description: 'file deletion' }, + ); + expect(result).toBe('Error in file deletion'); + }); + + test('formats cost values with 2 decimal places', () => { + const result = checker.interpolateMessage( + 'Estimated cost: $${estimated_cost}', + { estimated_cost: 10.5 }, + ); + expect(result).toBe('Estimated cost: $10.50'); + }); + + test('keeps placeholder when variable not in context', () => { + const result = checker.interpolateMessage( + 'Missing: ${unknown_var}', + {}, + ); + expect(result).toBe('Missing: ${unknown_var}'); + }); + + test('handles null/undefined values', () => { + const result = checker.interpolateMessage( + 'Value: ${field}', + { field: null }, + ); + expect(result).toBe('Value: '); + }); + + test('handles empty template', () => { + expect(checker.interpolateMessage('', {})).toBe(''); + expect(checker.interpolateMessage(null, {})).toBe(''); + }); + + test('interpolates multiple variables', () => { + const result = checker.interpolateMessage( + '${action_type} on ${affected_files}', + { action_type: 'delete', affected_files: 'src/index.js' }, + ); + expect(result).toBe('delete on src/index.js'); + }); + }); + + // ============================================================ + // shouldSurface + // ============================================================ + describe('shouldSurface', () => { + let checker; + + beforeEach(() => { + checker = new SurfaceChecker(); + checker.criteria = MOCK_CRITERIA; + checker._loaded = true; + }); + + test('returns no-surface when no criteria match', () => { + const result = checker.shouldSurface({ estimated_cost: 5, risk_level: 'LOW', errors_in_task: 0 }); + + expect(result.should_surface).toBe(false); + expect(result.criterion_id).toBeNull(); + expect(result.can_bypass).toBe(true); + }); + + test('returns surface result when cost threshold exceeded', () => { + const result = checker.shouldSurface({ estimated_cost: 25 }); + + expect(result.should_surface).toBe(true); + expect(result.criterion_id).toBe('SURF-1'); + expect(result.criterion_name).toBe('Cost Threshold'); + expect(result.severity).toBe('warning'); + expect(result.message).toContain('25.00'); + expect(result.can_bypass).toBe(true); + }); + + test('returns surface result for high risk', () => { + const result = checker.shouldSurface({ risk_level: 'HIGH', risk_details: 'data loss possible' }); + + expect(result.should_surface).toBe(true); + expect(result.criterion_id).toBe('SURF-2'); + expect(result.message).toContain('data loss possible'); + expect(result.can_bypass).toBe(false); + }); + + test('returns surface result for error threshold', () => { + const result = checker.shouldSurface({ errors_in_task: 5, error_summary: '5 timeouts' }); + + expect(result.should_surface).toBe(true); + expect(result.criterion_id).toBe('SURF-3'); + expect(result.message).toContain('5 timeouts'); + }); + + test('first match wins (evaluation order)', () => { + // Both cost and error thresholds are exceeded, but cost comes first + const result = checker.shouldSurface({ estimated_cost: 20, errors_in_task: 10 }); + + expect(result.criterion_id).toBe('SURF-1'); + }); + + test('returns no-surface when criteria not loaded', () => { + checker.criteria = null; + const result = checker.shouldSurface({}); + + expect(result.should_surface).toBe(false); + }); + + test('uses Object.keys when no evaluation_order', () => { + checker.criteria = { + criteria: { + test_criterion: { + id: 'TEST-1', + condition: 'score > 50', + action: 'confirm', + message: 'Score: ${score}', + }, + }, + }; + + const result = checker.shouldSurface({ score: 80 }); + + expect(result.should_surface).toBe(true); + expect(result.criterion_id).toBe('TEST-1'); + }); + + test('skips non-criterion entries like arrays', () => { + checker.criteria = { + criteria: { + destructive_actions: ['delete', 'drop'], // array, not criterion + test_criterion: { + id: 'TEST-1', + condition: 'score > 50', + action: 'confirm', + message: 'Test', + }, + }, + }; + + const result = checker.shouldSurface({ score: 80 }); + expect(result.should_surface).toBe(true); + }); + }); + + // ============================================================ + // Helper Methods + // ============================================================ + // Shared factory for helper method tests + function loadedChecker(criteria = MOCK_CRITERIA) { + const checker = new SurfaceChecker(); + checker.criteria = criteria; + checker._loaded = true; + return checker; + } + + describe('getActionConfig', () => { + test('returns action config when found', () => { + const config = loadedChecker().getActionConfig('confirm_cost'); + expect(config.type).toBe('confirm'); + expect(config.timeout_seconds).toBe(120); + }); + + test('returns null for unknown action', () => { + expect(loadedChecker().getActionConfig('unknown')).toBeNull(); + }); + + test('returns null when criteria not loaded', () => { + expect(loadedChecker(null).getActionConfig('confirm_cost')).toBeNull(); + }); + }); + + describe('getCriteria', () => { + test('returns criteria definitions', () => { + const criteria = loadedChecker().getCriteria(); + expect(criteria.cost_threshold).toBeDefined(); + expect(criteria.high_risk).toBeDefined(); + }); + + test('returns empty object when no criteria', () => { + expect(loadedChecker(null).getCriteria()).toEqual({}); + }); + }); + + describe('getDestructiveActions', () => { + test('returns destructive actions list', () => { + const actions = loadedChecker().getDestructiveActions(); + expect(actions).toEqual(['delete', 'drop', 'reset', 'force-push']); + }); + + test('returns empty array when no criteria', () => { + expect(loadedChecker(null).getDestructiveActions()).toEqual([]); + }); + }); + + describe('isDestructiveAction', () => { + test('returns true for destructive actions', () => { + const checker = loadedChecker(); + expect(checker.isDestructiveAction('delete')).toBe(true); + expect(checker.isDestructiveAction('force-push')).toBe(true); + }); + + test('returns false for non-destructive actions', () => { + const checker = loadedChecker(); + expect(checker.isDestructiveAction('create')).toBe(false); + expect(checker.isDestructiveAction('read')).toBe(false); + }); + }); + + describe('getMetadata', () => { + test('returns metadata from criteria', () => { + expect(loadedChecker().getMetadata().author).toBe('test'); + }); + + test('returns empty object when no criteria', () => { + expect(loadedChecker(null).getMetadata()).toEqual({}); + }); + }); + + // ============================================================ + // Validation + // ============================================================ + describe('validate', () => { + test('validates valid criteria', () => { + const checker = new SurfaceChecker(); + checker.criteria = MOCK_CRITERIA; + checker._loaded = true; + + const result = checker.validate(); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test('reports missing criteria file', () => { + const checker = new SurfaceChecker(); + checker.criteria = null; + checker._loaded = true; + + const result = checker.validate(); + expect(result.valid).toBe(false); + expect(result.errors).toContain('Criteria file not loaded'); + }); + + test('reports missing version', () => { + const checker = new SurfaceChecker(); + checker.criteria = { criteria: {}, actions: {} }; + checker._loaded = true; + + const result = checker.validate(); + expect(result.errors).toContain('Missing version field'); + }); + + test('reports missing criteria section', () => { + const checker = new SurfaceChecker(); + checker.criteria = { version: '1.0', actions: {} }; + checker._loaded = true; + + const result = checker.validate(); + expect(result.errors).toContain('Missing criteria section'); + }); + + test('reports missing actions section', () => { + const checker = new SurfaceChecker(); + checker.criteria = { version: '1.0', criteria: {} }; + checker._loaded = true; + + const result = checker.validate(); + expect(result.errors).toContain('Missing actions section'); + }); + + test('reports invalid criterion fields', () => { + const checker = new SurfaceChecker(); + checker.criteria = { + version: '1.0', + criteria: { + bad_criterion: { name: 'missing id, condition, action, message' }, + }, + actions: {}, + }; + checker._loaded = true; + + const result = checker.validate(); + expect(result.errors).toContain("Criterion 'bad_criterion' missing id field"); + expect(result.errors).toContain("Criterion 'bad_criterion' missing condition field"); + expect(result.errors).toContain("Criterion 'bad_criterion' missing action field"); + expect(result.errors).toContain("Criterion 'bad_criterion' missing message field"); + }); + + test('skips array entries in criteria', () => { + const checker = new SurfaceChecker(); + checker.criteria = { + version: '1.0', + criteria: { + destructive_actions: ['delete', 'drop'], + }, + actions: {}, + }; + checker._loaded = true; + + const result = checker.validate(); + expect(result.valid).toBe(true); + }); + }); + + // ============================================================ + // Factory Functions + // ============================================================ + describe('createSurfaceChecker', () => { + test('creates and loads checker', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('yaml'); + yaml.load.mockReturnValue(MOCK_CRITERIA); + + const checker = createSurfaceChecker('/test/criteria.yaml'); + + expect(checker).toBeInstanceOf(SurfaceChecker); + expect(checker._loaded).toBe(true); + }); + + test('works with default path', () => { + fs.existsSync.mockReturnValue(false); + + const checker = createSurfaceChecker(); + + expect(checker).toBeInstanceOf(SurfaceChecker); + }); + }); + + describe('shouldSurface (convenience function)', () => { + test('evaluates context and returns result', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('yaml'); + yaml.load.mockReturnValue(MOCK_CRITERIA); + + const result = shouldSurface({ estimated_cost: 25 }, '/test/criteria.yaml'); + + expect(result.should_surface).toBe(true); + expect(result.criterion_id).toBe('SURF-1'); + }); + + test('returns no-surface when criteria file missing', () => { + fs.existsSync.mockReturnValue(false); + + const result = shouldSurface({ estimated_cost: 25 }); + + expect(result.should_surface).toBe(false); + }); + }); +});