/** * Quality Review Tests * * Tests for the quality-review dispatch hook that intercepts agent:stopped * events and spawns a review agent when conditions are met. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { computeQualifyingFiles, shouldRunQualityReview, runQualityReview } from './quality-review.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { AgentManager } from '../agent/types.js'; import type { createModuleLogger } from '../logger/index.js'; type Logger = ReturnType; // --------------------------------------------------------------------------- // Mock helpers // --------------------------------------------------------------------------- function createBranchManagerMock(): BranchManager { return { ensureBranch: vi.fn(), mergeBranch: vi.fn(), diffBranches: vi.fn(), diffBranchesStat: vi.fn().mockResolvedValue([]), diffFileSingle: vi.fn(), getHeadCommitHash: vi.fn(), deleteBranch: vi.fn(), branchExists: vi.fn(), remoteBranchExists: vi.fn(), listCommits: vi.fn(), diffCommit: vi.fn(), getMergeBase: vi.fn(), pushBranch: vi.fn(), checkMergeability: vi.fn(), fetchRemote: vi.fn(), fastForwardBranch: vi.fn(), updateRef: vi.fn(), } as unknown as BranchManager; } function createAgentRepositoryMock(): AgentRepository { return { findById: vi.fn().mockResolvedValue({ id: 'a1', mode: 'execute' }), findByTaskId: vi.fn(), findAll: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as unknown as AgentRepository; } function createTaskRepositoryMock(): TaskRepository { return { findById: vi.fn().mockResolvedValue({ id: 't1', status: 'in_progress', initiativeId: 'i1', phaseId: 'p1', }), findByPhaseId: vi.fn(), findByInitiativeId: vi.fn(), create: vi.fn(), update: vi.fn().mockResolvedValue(undefined), delete: vi.fn(), } as unknown as TaskRepository; } function createInitiativeRepositoryMock(): InitiativeRepository { return { findById: vi.fn().mockResolvedValue({ id: 'i1', qualityReview: true, branch: 'cw/test', }), findAll: vi.fn(), findByStatus: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as unknown as InitiativeRepository; } function createPhaseRepositoryMock(): PhaseRepository { return { findById: vi.fn().mockResolvedValue({ id: 'p1', name: 'impl', initiativeId: 'i1' }), findByInitiativeId: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as unknown as PhaseRepository; } function createAgentManagerMock(): AgentManager { return { spawn: vi.fn().mockResolvedValue({ id: 'review-agent-1' }), stop: vi.fn(), list: vi.fn(), resume: vi.fn(), delete: vi.fn(), getStatus: vi.fn(), answerQuestion: vi.fn(), spawnWithLifecycle: vi.fn(), } as unknown as AgentManager; } function createLoggerMock(): Logger { return { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), fatal: vi.fn(), child: vi.fn(), } as unknown as Logger; } // --------------------------------------------------------------------------- // computeQualifyingFiles // --------------------------------------------------------------------------- describe('computeQualifyingFiles', () => { let branchManager: BranchManager; beforeEach(() => { branchManager = createBranchManagerMock(); }); it('includes .ts files', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/foo.ts', status: 'modified', additions: 5, deletions: 2 }, ]); expect(await computeQualifyingFiles(branchManager, '/repo', 'task-branch', 'base')).toEqual(['src/foo.ts']); }); it('includes .tsx and .js files', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/Comp.tsx', status: 'modified', additions: 3, deletions: 1 }, { path: 'src/util.js', status: 'added', additions: 10, deletions: 0 }, ]); const result = await computeQualifyingFiles(branchManager, '/repo', 'task-branch', 'base'); expect(result).toContain('src/Comp.tsx'); expect(result).toContain('src/util.js'); }); it('excludes *.gen.ts files', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/routeTree.gen.ts', status: 'modified', additions: 1, deletions: 0 }, ]); expect(await computeQualifyingFiles(branchManager, '/repo', 'branch', 'base')).toEqual([]); }); it('excludes files starting with dist/', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'dist/index.js', status: 'modified', additions: 1, deletions: 0 }, ]); expect(await computeQualifyingFiles(branchManager, '/repo', 'branch', 'base')).toEqual([]); }); it('excludes files containing /dist/', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'packages/foo/dist/bar.js', status: 'modified', additions: 1, deletions: 0 }, ]); expect(await computeQualifyingFiles(branchManager, '/repo', 'branch', 'base')).toEqual([]); }); it('returns empty array when diffBranchesStat throws', async () => { vi.mocked(branchManager.diffBranchesStat).mockRejectedValue(new Error('branch not found')); expect(await computeQualifyingFiles(branchManager, '/repo', 'branch', 'base')).toEqual([]); }); it('returns only qualifying files from a mixed set', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/foo.ts', status: 'modified', additions: 5, deletions: 2 }, { path: 'src/routeTree.gen.ts', status: 'modified', additions: 1, deletions: 0 }, { path: 'dist/bundle.js', status: 'modified', additions: 1, deletions: 0 }, { path: 'src/bar.tsx', status: 'added', additions: 10, deletions: 0 }, { path: 'README.md', status: 'modified', additions: 2, deletions: 0 }, ]); const result = await computeQualifyingFiles(branchManager, '/repo', 'branch', 'base'); expect(result).toEqual(['src/foo.ts', 'src/bar.tsx']); }); }); // --------------------------------------------------------------------------- // shouldRunQualityReview // --------------------------------------------------------------------------- describe('shouldRunQualityReview', () => { let branchManager: BranchManager; let agentRepository: AgentRepository; let taskRepository: TaskRepository; let initiativeRepository: InitiativeRepository; let phaseRepository: PhaseRepository; // Base params where all conditions pass let params: Parameters[0]; beforeEach(() => { branchManager = createBranchManagerMock(); agentRepository = createAgentRepositoryMock(); taskRepository = createTaskRepositoryMock(); initiativeRepository = createInitiativeRepositoryMock(); phaseRepository = createPhaseRepositoryMock(); // Default diffBranchesStat returns qualifying file vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/foo.ts', status: 'modified', additions: 5, deletions: 2 }, ]); params = { agentId: 'a1', taskId: 't1', stopReason: 'task_complete', agentRepository, taskRepository, initiativeRepository, phaseRepository, branchManager, repoPath: '/repo', }; }); it('returns false when stopReason is not task_complete', async () => { const result = await shouldRunQualityReview({ ...params, stopReason: 'error' }); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when agent is not found', async () => { vi.mocked(agentRepository.findById).mockResolvedValue(undefined as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when agent mode is errand', async () => { vi.mocked(agentRepository.findById).mockResolvedValue({ id: 'a1', mode: 'errand' } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when task is not found', async () => { vi.mocked(taskRepository.findById).mockResolvedValue(undefined as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when task status is quality_review (recursion guard)', async () => { vi.mocked(taskRepository.findById).mockResolvedValue({ id: 't1', status: 'quality_review', initiativeId: 'i1', phaseId: 'p1', } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when task status is not in_progress', async () => { vi.mocked(taskRepository.findById).mockResolvedValue({ id: 't1', status: 'pending', initiativeId: 'i1', phaseId: 'p1', } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when task has no initiativeId', async () => { vi.mocked(taskRepository.findById).mockResolvedValue({ id: 't1', status: 'in_progress', initiativeId: null, phaseId: 'p1', } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when initiative is not found', async () => { vi.mocked(initiativeRepository.findById).mockResolvedValue(undefined as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when initiative qualityReview is false', async () => { vi.mocked(initiativeRepository.findById).mockResolvedValue({ id: 'i1', qualityReview: false, branch: 'cw/test', } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when task has no phaseId', async () => { vi.mocked(taskRepository.findById).mockResolvedValue({ id: 't1', status: 'in_progress', initiativeId: 'i1', phaseId: null, } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when phase is not found', async () => { vi.mocked(phaseRepository.findById).mockResolvedValue(undefined as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when initiative has no branch', async () => { vi.mocked(initiativeRepository.findById).mockResolvedValue({ id: 'i1', qualityReview: true, branch: null, } as any); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns false when no qualifying files in changeset', async () => { vi.mocked(branchManager.diffBranchesStat).mockResolvedValue([ { path: 'src/routeTree.gen.ts', status: 'modified', additions: 1, deletions: 0 }, ]); const result = await shouldRunQualityReview(params); expect(result).toEqual({ run: false, qualifyingFiles: [] }); }); it('returns true with qualifying files when all conditions pass', async () => { const result = await shouldRunQualityReview(params); expect(result.run).toBe(true); expect(result.qualifyingFiles).toContain('src/foo.ts'); }); }); // --------------------------------------------------------------------------- // runQualityReview // --------------------------------------------------------------------------- describe('runQualityReview', () => { let taskRepository: TaskRepository; let agentManager: AgentManager; let log: Logger; let params: Parameters[0]; beforeEach(() => { taskRepository = createTaskRepositoryMock(); agentManager = createAgentManagerMock(); log = createLoggerMock(); params = { taskId: 't1', taskBranch: 'cw/test-task-t1', baseBranch: 'cw/test-phase-impl', initiativeId: 'i1', qualifyingFiles: ['src/foo.ts', 'src/bar.ts'], taskRepository, agentManager, log, }; }); it('transitions task to quality_review before spawning', async () => { await runQualityReview(params); expect(taskRepository.update).toHaveBeenCalledWith('t1', { status: 'quality_review' }); // update called BEFORE spawn const updateOrder = vi.mocked(taskRepository.update).mock.invocationCallOrder[0]!; const spawnOrder = vi.mocked(agentManager.spawn).mock.invocationCallOrder[0]!; expect(updateOrder).toBeLessThan(spawnOrder); }); it('spawns agent with mode execute on the task branch', async () => { await runQualityReview(params); expect(agentManager.spawn).toHaveBeenCalledWith( expect.objectContaining({ mode: 'execute', branchName: 'cw/test-task-t1', baseBranch: 'cw/test-phase-impl', }), ); }); it('includes qualifying files in the prompt', async () => { await runQualityReview(params); const spawnArgs = vi.mocked(agentManager.spawn).mock.calls[0]![0]; expect(spawnArgs.prompt).toContain('src/foo.ts'); expect(spawnArgs.prompt).toContain('src/bar.ts'); expect(spawnArgs.prompt).toContain('/simplify'); }); it('spawns with taskId and initiativeId', async () => { await runQualityReview(params); expect(agentManager.spawn).toHaveBeenCalledWith( expect.objectContaining({ taskId: 't1', initiativeId: 'i1', }), ); }); it('on spawn failure: marks task completed and does not throw', async () => { vi.mocked(agentManager.spawn).mockRejectedValue(new Error('spawn failed')); await expect(runQualityReview(params)).resolves.toBeUndefined(); // Last call to update should set status to completed const updateCalls = vi.mocked(taskRepository.update).mock.calls; const lastCall = updateCalls[updateCalls.length - 1]!; expect(lastCall).toEqual(['t1', { status: 'completed' }]); }); it('logs info after successful spawn', async () => { await runQualityReview(params); expect(log.info).toHaveBeenCalledWith( expect.objectContaining({ taskId: 't1', reviewAgentId: 'review-agent-1' }), expect.any(String), ); }); it('logs error on spawn failure', async () => { vi.mocked(agentManager.spawn).mockRejectedValue(new Error('spawn failed')); await runQualityReview(params); expect(log.error).toHaveBeenCalledWith( expect.objectContaining({ taskId: 't1' }), expect.any(String), ); }); });