/** * Shared helpers for PieceEngine integration tests. * * Provides mock setup, factory functions, and a default piece config * matching the parallel reviewers structure (plan → implement → ai_review → reviewers → supervise). */ import { vi } from 'vitest'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { PieceConfig, PieceMovement, AgentResponse } from '../core/models/index.js'; import { makeRule } from './test-helpers.js'; // --- Mock imports (consumers must call vi.mock before importing this) --- import { runAgent } from '../agents/runner.js'; import { detectMatchedRule } from '../core/piece/evaluation/index.js'; import type { RuleMatch } from '../core/piece/evaluation/index.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/phase-runner.js'; import { generateReportDir } from '../shared/utils/index.js'; // --- Factory functions --- export { makeRule }; export function makeResponse(overrides: Partial = {}): AgentResponse { return { persona: 'test-agent', status: 'done', content: 'test response', timestamp: new Date(), sessionId: `session-${randomUUID()}`, ...overrides, }; } export function makeMovement(name: string, overrides: Partial = {}): PieceMovement { return { name, persona: `../personas/${name}.md`, personaDisplayName: name, instructionTemplate: `Run ${name}`, passPreviousResponse: true, ...overrides, }; } /** * Build a piece config matching the default.yaml parallel reviewers structure: * plan → implement → ai_review → (ai_fix↔) → reviewers(parallel) → (fix↔) → supervise */ export function buildDefaultPieceConfig(overrides: Partial = {}): PieceConfig { const archReviewSubMovement = makeMovement('arch-review', { rules: [ makeRule('approved', 'COMPLETE'), makeRule('needs_fix', 'fix'), ], }); const securityReviewSubMovement = makeMovement('security-review', { rules: [ makeRule('approved', 'COMPLETE'), makeRule('needs_fix', 'fix'), ], }); return { name: 'test-default', description: 'Test piece', maxMovements: 30, initialMovement: 'plan', movements: [ makeMovement('plan', { rules: [ makeRule('Requirements are clear', 'implement'), makeRule('Requirements unclear', 'ABORT'), ], }), makeMovement('implement', { rules: [ makeRule('Implementation complete', 'ai_review'), makeRule('Cannot proceed', 'plan'), ], }), makeMovement('ai_review', { rules: [ makeRule('No AI-specific issues', 'reviewers'), makeRule('AI-specific issues found', 'ai_fix'), ], }), makeMovement('ai_fix', { rules: [ makeRule('AI issues fixed', 'reviewers'), makeRule('Cannot proceed', 'plan'), ], }), makeMovement('reviewers', { parallel: [archReviewSubMovement, securityReviewSubMovement], rules: [ makeRule('all("approved")', 'supervise', { isAggregateCondition: true, aggregateType: 'all', aggregateConditionText: 'approved', }), makeRule('any("needs_fix")', 'fix', { isAggregateCondition: true, aggregateType: 'any', aggregateConditionText: 'needs_fix', }), ], }), makeMovement('fix', { rules: [ makeRule('Fix complete', 'reviewers'), makeRule('Cannot proceed', 'plan'), ], }), makeMovement('supervise', { rules: [ makeRule('All checks passed', 'COMPLETE'), makeRule('Requirements unmet', 'plan'), ], }), ], ...overrides, }; } // --- Mock sequence helpers --- /** * Configure runAgent mock to return a sequence of responses. */ export function mockRunAgentSequence(responses: AgentResponse[]): void { const mock = vi.mocked(runAgent); for (const response of responses) { mock.mockResolvedValueOnce(response); } } /** * Configure detectMatchedRule mock to return a sequence of rule matches. */ export function mockDetectMatchedRuleSequence(matches: (RuleMatch | undefined)[]): void { const mock = vi.mocked(detectMatchedRule); for (const match of matches) { mock.mockResolvedValueOnce(match); } } // --- Test environment setup --- /** * Create a temporary directory with the required .takt/runs structure. * Returns the tmpDir path. Caller is responsible for cleanup. */ export function createTestTmpDir(): string { const tmpDir = join(tmpdir(), `takt-engine-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'knowledge'), { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'policy'), { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'), { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'logs'), { recursive: true }); return tmpDir; } /** * Re-apply default mocks for phase-runner and session after vi.resetAllMocks(). */ export function applyDefaultMocks(): void { vi.mocked(needsStatusJudgmentPhase).mockReturnValue(false); vi.mocked(runReportPhase).mockResolvedValue(undefined); vi.mocked(runStatusJudgmentPhase).mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }); vi.mocked(generateReportDir).mockReturnValue('test-report-dir'); } type RemovableListeners = { removeAllListeners: () => void; }; function hasRemovableListeners(value: unknown): value is RemovableListeners { if (!value || typeof value !== 'object') { return false; } if (!('removeAllListeners' in value)) { return false; } const candidate = value as { removeAllListeners: unknown }; return typeof candidate.removeAllListeners === 'function'; } /** * Clean up PieceEngine instances to prevent EventEmitter memory leaks. * Call this in afterEach to ensure all event listeners are removed. */ export function cleanupPieceEngine(engine: unknown): void { if (hasRemovableListeners(engine)) { engine.removeAllListeners(); } }