/** * Mock scenario support for integration testing. * * Provides a queue-based mechanism to control mock provider responses * per agent or by call order. Scenarios can be loaded from JSON files * (via TAKT_MOCK_SCENARIO env var) or set programmatically in tests. */ import { readFileSync, existsSync } from 'node:fs'; import type { ScenarioEntry } from './types.js'; export type { ScenarioEntry }; /** * Queue that dispenses scenario entries. * * Matching rules: * 1. If an entry has `agent` set, it only matches calls for that agent name. * 2. Entries without `agent` match any call (consumed in order). * 3. First matching entry is removed from the queue and returned. * 4. Returns undefined when no matching entry remains. */ export class ScenarioQueue { private entries: ScenarioEntry[]; constructor(entries: ScenarioEntry[]) { // Defensive copy this.entries = [...entries]; } /** * Consume the next matching entry for the given agent. */ consume(personaName: string): ScenarioEntry | undefined { // Try persona-specific match first const personaIndex = this.entries.findIndex( (e) => e.persona !== undefined && e.persona === personaName, ); if (personaIndex >= 0) { return this.entries.splice(personaIndex, 1)[0]; } // Fall back to first unspecified entry const anyIndex = this.entries.findIndex((e) => e.persona === undefined); if (anyIndex >= 0) { return this.entries.splice(anyIndex, 1)[0]; } return undefined; } /** Number of remaining entries */ get remaining(): number { return this.entries.length; } } // --- Global singleton (module-level state) --- let globalQueue: ScenarioQueue | null = null; /** * Set mock scenario programmatically (for tests). * Pass null to clear. */ export function setMockScenario(entries: ScenarioEntry[] | null): void { globalQueue = entries ? new ScenarioQueue(entries) : null; } /** * Get the current global scenario queue. * Lazily loads from TAKT_MOCK_SCENARIO env var on first access. */ export function getScenarioQueue(): ScenarioQueue | null { if (globalQueue) return globalQueue; const envPath = process.env.TAKT_MOCK_SCENARIO; if (envPath) { const entries = loadScenarioFile(envPath); globalQueue = new ScenarioQueue(entries); return globalQueue; } return null; } /** * Reset global scenario state (for test cleanup). */ export function resetScenario(): void { globalQueue = null; } /** * Load and validate a scenario JSON file. * * @param filePath Absolute or relative path to scenario JSON * @throws Error if file not found or JSON invalid */ export function loadScenarioFile(filePath: string): ScenarioEntry[] { if (!existsSync(filePath)) { throw new Error(`Scenario file not found: ${filePath}`); } const raw = readFileSync(filePath, 'utf-8'); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`Scenario file is not valid JSON: ${filePath}`); } if (!Array.isArray(parsed)) { throw new Error(`Scenario file must contain a JSON array: ${filePath}`); } return parsed.map((entry, i) => validateEntry(entry, i)); } function validateEntry(entry: unknown, index: number): ScenarioEntry { if (typeof entry !== 'object' || entry === null) { throw new Error(`Scenario entry [${index}] must be an object`); } const obj = entry as Record; // content is required if (typeof obj.content !== 'string') { throw new Error(`Scenario entry [${index}] must have a "content" string`); } // status defaults to 'done' const validStatuses = ['done', 'blocked', 'error', 'approved', 'rejected', 'improve'] as const; const status = obj.status ?? 'done'; if (typeof status !== 'string' || !validStatuses.includes(status as typeof validStatuses[number])) { throw new Error( `Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${validStatuses.join(', ')}`, ); } // persona is optional if (obj.persona !== undefined && typeof obj.persona !== 'string') { throw new Error(`Scenario entry [${index}] "persona" must be a string if provided`); } return { persona: obj.persona as string | undefined, status: status as ScenarioEntry['status'], content: obj.content as string, }; }