workflows.test.js
1 /** 2 * Agent Workflow Tests 3 * 4 * Tests for the three workflow creation modules: 5 * - bug-fix.js: Triage → Developer → QA → Security 6 * - feature.js: Architect → Developer → QA (with optional skip) 7 * - refactor.js: Architect → Developer → QA (ESLint threshold driven) 8 * 9 * All tests mock createAgentTask to avoid database I/O. 10 */ 11 12 import { test, describe, mock, beforeEach } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import { createPgMock } from '../helpers/pg-mock.js'; // eslint-disable-line no-unused-vars 15 16 // ─── Mock state ─────────────────────────────────────────────────────────────── 17 18 let taskIdCounter = 100; 19 let callCounter = 0; // separate from createdTasks.length so failures don't skew it 20 let createdTasks = []; 21 let mockCreateAgentTask; 22 let shouldFailOnCall = null; // Set to a call number to throw on that call 23 24 // ─── Mocks ──────────────────────────────────────────────────────────────────── 25 26 mock.module('../../src/agents/utils/task-manager.js', { 27 namedExports: { 28 createAgentTask: async (...args) => mockCreateAgentTask(...args), 29 }, 30 }); 31 32 mock.module('dotenv', { 33 defaultExport: { config: () => {} }, 34 namedExports: { config: () => {} }, 35 }); 36 37 // ─── Import modules under test ─────────────────────────────────────────────── 38 39 const { createBugFixWorkflow, createBulkBugFixWorkflows, getBugFixWorkflowStatus } = 40 await import('../../src/agents/workflows/bug-fix.js'); 41 42 const { createFeatureWorkflow, getFeatureWorkflowStatus } = 43 await import('../../src/agents/workflows/feature.js'); 44 45 const { createRefactorWorkflow, createBulkRefactorWorkflows, getRefactorWorkflowStatus } = 46 await import('../../src/agents/workflows/refactor.js'); 47 48 // ─── Test setup ─────────────────────────────────────────────────────────────── 49 50 beforeEach(() => { 51 createdTasks = []; 52 taskIdCounter = 100; 53 callCounter = 0; 54 shouldFailOnCall = null; 55 56 mockCreateAgentTask = async options => { 57 callCounter++; 58 if (shouldFailOnCall !== null && callCounter === shouldFailOnCall) { 59 throw new Error(`Task creation failed on call ${callCounter}`); 60 } 61 const id = taskIdCounter++; 62 createdTasks.push({ id, ...options }); 63 return id; 64 }; 65 }); 66 67 // ─── Bug Fix Workflow ───────────────────────────────────────────────────────── 68 69 describe('createBugFixWorkflow', () => { 70 test('creates a triage task with correct fields', async () => { 71 const workflowId = await createBugFixWorkflow( 72 'TypeError: Cannot read property x of undefined', 73 'at scoring.js:42', 74 'scoring', 75 3 76 ); 77 78 assert.equal(workflowId, 100, 'Returns first task ID'); 79 assert.equal(createdTasks.length, 1, 'Creates exactly one task'); 80 81 const task = createdTasks[0]; 82 assert.equal(task.task_type, 'classify_error'); 83 assert.equal(task.assigned_to, 'triage'); 84 assert.equal(task.created_by, 'system'); 85 assert.equal(task.priority, 5); 86 assert.equal(task.context.error_message, 'TypeError: Cannot read property x of undefined'); 87 assert.equal(task.context.stack_trace, 'at scoring.js:42'); 88 assert.equal(task.context.stage, 'scoring'); 89 assert.equal(task.context.frequency, 3); 90 }); 91 92 test('defaults: empty stack trace, unknown stage, frequency 1', async () => { 93 await createBugFixWorkflow('Some error'); 94 95 const task = createdTasks[0]; 96 assert.equal(task.context.stack_trace, ''); 97 assert.equal(task.context.stage, 'unknown'); 98 assert.equal(task.context.frequency, 1); 99 }); 100 101 test('respects custom priority option', async () => { 102 await createBugFixWorkflow('Critical error', '', 'outreach', 10, { priority: 9 }); 103 104 assert.equal(createdTasks[0].priority, 9); 105 }); 106 107 test('respects custom created_by option', async () => { 108 await createBugFixWorkflow('Error', '', 'serps', 1, { created_by: 'monitor' }); 109 110 assert.equal(createdTasks[0].created_by, 'monitor'); 111 }); 112 113 test('throws when createAgentTask fails', async () => { 114 shouldFailOnCall = 1; 115 await assert.rejects( 116 () => createBugFixWorkflow('fatal error'), 117 /Task creation failed on call 1/ 118 ); 119 }); 120 }); 121 122 // ─── Bulk Bug Fix Workflows ─────────────────────────────────────────────────── 123 124 describe('createBulkBugFixWorkflows', () => { 125 test('creates one workflow per error', async () => { 126 const errors = [ 127 { message: 'Error A', stage: 'scoring', frequency: 2 }, 128 { message: 'Error B', stage: 'enrich', frequency: 1 }, 129 { message: 'Error C', stack: 'at foo.js:10' }, 130 ]; 131 132 const ids = await createBulkBugFixWorkflows(errors); 133 134 assert.equal(ids.length, 3); 135 assert.equal(createdTasks.length, 3); 136 assert.equal(createdTasks[0].context.error_message, 'Error A'); 137 assert.equal(createdTasks[1].context.stage, 'enrich'); 138 assert.equal(createdTasks[2].context.stack_trace, 'at foo.js:10'); 139 }); 140 141 test('continues processing when one workflow fails', async () => { 142 shouldFailOnCall = 2; // Second error will fail 143 144 const errors = [ 145 { message: 'Error A' }, 146 { message: 'Error B - will fail' }, 147 { message: 'Error C' }, 148 ]; 149 150 const ids = await createBulkBugFixWorkflows(errors); 151 152 // Only 2 succeed (1st and 3rd) 153 assert.equal(ids.length, 2, 'Should skip failed workflows'); 154 assert.equal(ids[0], 100); 155 assert.equal(ids[1], 101); 156 }); 157 158 test('returns empty array for empty input', async () => { 159 const ids = await createBulkBugFixWorkflows([]); 160 assert.equal(ids.length, 0); 161 assert.equal(createdTasks.length, 0); 162 }); 163 164 test('passes created_by from error object', async () => { 165 await createBulkBugFixWorkflows([{ message: 'Error', created_by: 'cron' }]); 166 167 assert.equal(createdTasks[0].created_by, 'cron'); 168 }); 169 }); 170 171 // ─── Bug Fix Workflow Status ────────────────────────────────────────────────── 172 173 describe('getBugFixWorkflowStatus', () => { 174 test('returns status summary from database', async () => { 175 // getBugFixWorkflowStatus uses a dynamic import of better-sqlite3 inside the function 176 // We mock it at module level but here we need it to work with our mock db 177 // Since the mock is at top level, it should be used automatically 178 179 // This function opens DB + queries workflow_chain — hard to unit test without 180 // a real DB. We verify the interface at least. 181 assert.ok(typeof getBugFixWorkflowStatus === 'function', 'Should be a function'); 182 }); 183 }); 184 185 // ─── Feature Workflow ───────────────────────────────────────────────────────── 186 187 describe('createFeatureWorkflow', () => { 188 test('creates architect review task by default', async () => { 189 const workflowId = await createFeatureWorkflow('Add dark mode toggle', [ 190 'Must support prefers-color-scheme', 191 'Persists across sessions', 192 ]); 193 194 assert.equal(workflowId, 100); 195 assert.equal(createdTasks.length, 1); 196 197 const task = createdTasks[0]; 198 assert.equal(task.task_type, 'review_design'); 199 assert.equal(task.assigned_to, 'architect'); 200 assert.equal(task.priority, 5); // default 201 assert.equal(task.context.feature_description, 'Add dark mode toggle'); 202 assert.deepEqual(task.context.requirements, [ 203 'Must support prefers-color-scheme', 204 'Persists across sessions', 205 ]); 206 assert.equal(task.context.workflow_type, 'feature'); 207 }); 208 209 test('skips architect review when skipArchitectReview=true', async () => { 210 const workflowId = await createFeatureWorkflow('Fix typo in label', [], { 211 skipArchitectReview: true, 212 }); 213 214 assert.equal(workflowId, 100); 215 assert.equal(createdTasks.length, 1); 216 217 const task = createdTasks[0]; 218 assert.equal(task.task_type, 'implement_feature'); 219 assert.equal(task.assigned_to, 'developer'); 220 assert.equal(task.context.feature_description, 'Fix typo in label'); 221 }); 222 223 test('defaults to empty requirements array', async () => { 224 await createFeatureWorkflow('Some feature'); 225 226 assert.deepEqual(createdTasks[0].context.requirements, []); 227 }); 228 229 test('respects custom priority and created_by', async () => { 230 await createFeatureWorkflow('Critical feature', ['req1'], { priority: 8, created_by: 'po' }); 231 232 const task = createdTasks[0]; 233 assert.equal(task.priority, 8); 234 assert.equal(task.created_by, 'po'); 235 }); 236 237 test('throws when createAgentTask fails', async () => { 238 shouldFailOnCall = 1; 239 await assert.rejects(() => createFeatureWorkflow('Will fail'), /Task creation failed/); 240 }); 241 }); 242 243 // ─── Refactor Workflow ──────────────────────────────────────────────────────── 244 245 describe('createRefactorWorkflow', () => { 246 test('creates architect suggest_refactor task', async () => { 247 const workflowId = await createRefactorWorkflow( 248 'src/stages/scoring.js', 249 'File exceeds 150 lines' 250 ); 251 252 assert.equal(workflowId, 100); 253 assert.equal(createdTasks.length, 1); 254 255 const task = createdTasks[0]; 256 assert.equal(task.task_type, 'suggest_refactor'); 257 assert.equal(task.assigned_to, 'architect'); 258 assert.equal(task.priority, 4); // default for refactors 259 assert.equal(task.context.file_path, 'src/stages/scoring.js'); 260 assert.equal(task.context.reason, 'File exceeds 150 lines'); 261 assert.equal(task.context.workflow_type, 'refactor'); 262 assert.deepEqual(task.context.complexity_issues, []); 263 }); 264 265 test('passes complexity_issues option', async () => { 266 await createRefactorWorkflow('src/heavy.js', 'Too complex', { 267 complexity_issues: ['Complexity 20 > 15', 'Nesting 5 > 4'], 268 }); 269 270 assert.deepEqual(createdTasks[0].context.complexity_issues, [ 271 'Complexity 20 > 15', 272 'Nesting 5 > 4', 273 ]); 274 }); 275 276 test('respects custom priority', async () => { 277 await createRefactorWorkflow('src/foo.js', 'reason', { priority: 7 }); 278 assert.equal(createdTasks[0].priority, 7); 279 }); 280 }); 281 282 // ─── Bulk Refactor Workflows ────────────────────────────────────────────────── 283 284 describe('createBulkRefactorWorkflows', () => { 285 test('creates workflows only for files exceeding thresholds', async () => { 286 const files = [ 287 { path: 'src/a.js', lines: 100, complexity: 10, depth: 3 }, // under all thresholds → skip 288 { path: 'src/b.js', lines: 200, complexity: 10, depth: 3 }, // lines > 150 → create 289 { path: 'src/c.js', lines: 100, complexity: 20, depth: 3 }, // complexity > 15 → create 290 { path: 'src/d.js', lines: 100, complexity: 10, depth: 5 }, // depth > 4 → create 291 ]; 292 293 const ids = await createBulkRefactorWorkflows(files); 294 295 assert.equal(ids.length, 3, 'Should create 3 workflows (skip a.js)'); 296 assert.equal(createdTasks.length, 3); 297 }); 298 299 test('includes specific issues in complexity_issues', async () => { 300 const files = [{ path: 'src/complex.js', lines: 200, complexity: 20, depth: 5 }]; 301 302 await createBulkRefactorWorkflows(files); 303 304 const issues = createdTasks[0].context.complexity_issues; 305 assert.ok( 306 issues.some(i => i.includes('200')), 307 'Should mention line count' 308 ); 309 assert.ok( 310 issues.some(i => i.includes('20')), 311 'Should mention complexity score' 312 ); 313 assert.ok( 314 issues.some(i => i.includes('5')), 315 'Should mention depth' 316 ); 317 assert.equal(issues.length, 3, 'Should have 3 issues'); 318 }); 319 320 test('skips files under all thresholds', async () => { 321 const files = [{ path: 'src/tiny.js', lines: 50, complexity: 5, depth: 2 }]; 322 323 const ids = await createBulkRefactorWorkflows(files); 324 assert.equal(ids.length, 0); 325 assert.equal(createdTasks.length, 0); 326 }); 327 328 test('continues on individual workflow failure', async () => { 329 shouldFailOnCall = 1; 330 331 const files = [ 332 { path: 'src/fail.js', lines: 200, complexity: 5, depth: 2 }, // will fail 333 { path: 'src/ok.js', lines: 200, complexity: 5, depth: 2 }, // should succeed 334 ]; 335 336 const ids = await createBulkRefactorWorkflows(files); 337 assert.equal(ids.length, 1, 'Should return 1 successful ID'); 338 }); 339 340 test('uses monitor as created_by', async () => { 341 await createBulkRefactorWorkflows([{ path: 'src/x.js', lines: 200, complexity: 5, depth: 2 }]); 342 343 assert.equal(createdTasks[0].created_by, 'monitor'); 344 }); 345 346 test('uses priority 4 for all refactor workflows', async () => { 347 await createBulkRefactorWorkflows([{ path: 'src/y.js', lines: 200, complexity: 5, depth: 2 }]); 348 349 assert.equal(createdTasks[0].priority, 4); 350 }); 351 352 test('returns empty array for empty input', async () => { 353 const ids = await createBulkRefactorWorkflows([]); 354 assert.equal(ids.length, 0); 355 }); 356 });