/ tests / agents / workflows.test.js
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  });