#!/usr/bin/env bun /** * Fixture Capture Tool * * Parses monitor mode logs and extracts structured test fixtures. * * Usage: * bun tests/capture-fixture.ts logs/monitor.log --output tests/fixtures/my_test.json * bun tests/capture-fixture.ts logs/monitor.log --name "simple_query" --category "text" */ import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; interface FixtureEvent { event: string; data: any; } interface Fixture { name: string; description: string; category: string; captured_at: string; request: { headers: Record; body: any; }; response: { type: "streaming" | "json"; events?: FixtureEvent[]; data?: any; }; assertions: { eventSequence: string[]; contentBlocks: Array<{ index: number; type: string; name?: string; hasContent?: boolean; }>; stopReason: string | null; hasUsage: boolean; minInputTokens?: number; minOutputTokens?: number; }; notes?: string; } /** * Normalize dynamic values for reproducible tests */ function normalizeValue(key: string, value: any): any { // Normalize IDs if (key === "id" && typeof value === "string") { if (value.startsWith("msg_")) return "msg_***NORMALIZED***"; if (value.startsWith("toolu_")) return "toolu_***NORMALIZED***"; } // Normalize tool_call_id if (key === "tool_call_id" && typeof value === "string") { return "toolu_***NORMALIZED***"; } // Normalize tool_use_id if (key === "tool_use_id" && typeof value === "string") { return "toolu_***NORMALIZED***"; } // Recursively normalize objects if (typeof value === "object" && value !== null) { if (Array.isArray(value)) { return value.map((item, idx) => normalizeValue(`${idx}`, item)); } const normalized: any = {}; for (const [k, v] of Object.entries(value)) { normalized[k] = normalizeValue(k, v); } return normalized; } return value; } /** * Parse monitor log file and extract request/response */ function parseMonitorLog(logContent: string): { request: any; response: FixtureEvent[] } | null { const lines = logContent.split("\n"); let request: any = null; const responseEvents: FixtureEvent[] = []; let inRequest = false; let inResponse = false; let jsonBuffer = ""; for (const line of lines) { // Detect request start if (line.includes("=== [MONITOR] Claude Code → Anthropic API Request ===")) { inRequest = true; inResponse = false; jsonBuffer = ""; continue; } // Detect response start if (line.includes("=== [MONITOR] Anthropic API → Claude Code Response (Streaming) ===")) { inRequest = false; inResponse = true; jsonBuffer = ""; continue; } // Detect end markers if (line.includes("=== End Request ===") || line.includes("=== End Streaming Response ===")) { inRequest = false; inResponse = false; continue; } // Capture request body if (inRequest && !line.includes("Headers received:") && !line.includes("API Key found:") && !line.includes("Request body:")) { jsonBuffer += line + "\n"; } // Capture response events (SSE format) if (inResponse) { // Parse SSE events if (line.startsWith("event:")) { const eventType = line.substring(6).trim(); // Next line should be data: continue; } if (line.startsWith("data:")) { const dataStr = line.substring(5).trim(); if (dataStr && dataStr !== "[DONE]") { try { const data = JSON.parse(dataStr); const eventType = data.type || "unknown"; responseEvents.push({ event: eventType, data: normalizeValue("root", data), }); } catch (e) { // Ignore parse errors (might be partial JSON in logs) } } } } } // Try to parse request JSON if (jsonBuffer.trim()) { try { const lines = jsonBuffer.trim().split("\n").filter(l => l.trim()); const jsonStart = lines.findIndex(l => l.trim().startsWith("{")); if (jsonStart >= 0) { const jsonStr = lines.slice(jsonStart).join("\n"); request = JSON.parse(jsonStr); } } catch (e) { console.error("Failed to parse request JSON:", e); } } if (!request || responseEvents.length === 0) { return null; } return { request, response: responseEvents }; } /** * Analyze response and build assertions */ function buildAssertions(events: FixtureEvent[]): Fixture["assertions"] { const eventSequence = events.map(e => e.event); const contentBlocks: Array<{ index: number; type: string; name?: string; hasContent?: boolean; }> = []; let stopReason: string | null = null; let hasUsage = false; let minInputTokens = 0; let minOutputTokens = 0; // Track content blocks const blockMap = new Map(); for (const event of events) { if (event.event === "content_block_start") { const index = event.data.index; const blockType = event.data.content_block?.type; const name = event.data.content_block?.name; blockMap.set(index, { type: blockType, name, hasContent: false }); } if (event.event === "content_block_delta") { const index = event.data.index; if (blockMap.has(index)) { blockMap.get(index)!.hasContent = true; } } if (event.event === "message_delta") { stopReason = event.data.delta?.stop_reason || null; if (event.data.usage) { hasUsage = true; minInputTokens = event.data.usage.input_tokens || 0; minOutputTokens = event.data.usage.output_tokens || 0; } } if (event.event === "message_start") { if (event.data.message?.usage) { hasUsage = true; } } } // Convert block map to array for (const [index, block] of blockMap.entries()) { contentBlocks.push({ index, type: block.type, ...(block.name && { name: block.name }), hasContent: block.hasContent, }); } return { eventSequence, contentBlocks, stopReason, hasUsage, ...(minInputTokens > 0 && { minInputTokens }), ...(minOutputTokens > 0 && { minOutputTokens }), }; } /** * Infer category from response */ function inferCategory(events: FixtureEvent[]): string { const hasToolUse = events.some(e => e.event === "content_block_start" && e.data.content_block?.type === "tool_use" ); const toolCount = events.filter(e => e.event === "content_block_start" && e.data.content_block?.type === "tool_use" ).length; if (toolCount > 1) return "multi_tool"; if (hasToolUse) return "tool_use"; if (events.length > 20) return "streaming"; return "text"; } /** * Main function */ function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` Fixture Capture Tool Usage: bun tests/capture-fixture.ts [options] Options: --output Output fixture file path (default: auto-generate) --name Fixture name (default: extracted from filename) --category Category: text|tool_use|multi_tool|streaming|error --description Custom description Examples: bun tests/capture-fixture.ts logs/test_1_simple.log bun tests/capture-fixture.ts logs/monitor.log --output tests/fixtures/my_test.json bun tests/capture-fixture.ts logs/tool_test.log --name "read_file" --category "tool_use" `); process.exit(0); } const logFile = args[0]; let outputPath: string | null = null; let fixtureName: string | null = null; let category: string | null = null; let description: string | null = null; // Parse options for (let i = 1; i < args.length; i++) { if (args[i] === "--output" && args[i + 1]) { outputPath = args[i + 1]; i++; } else if (args[i] === "--name" && args[i + 1]) { fixtureName = args[i + 1]; i++; } else if (args[i] === "--category" && args[i + 1]) { category = args[i + 1]; i++; } else if (args[i] === "--description" && args[i + 1]) { description = args[i + 1]; i++; } } // Validate input file if (!existsSync(logFile)) { console.error(`❌ Log file not found: ${logFile}`); process.exit(1); } console.log(`📖 Reading log file: ${logFile}`); const logContent = readFileSync(logFile, "utf-8"); console.log("🔍 Parsing monitor logs..."); const parsed = parseMonitorLog(logContent); if (!parsed) { console.error("❌ Failed to parse monitor logs. No request/response found."); console.error(" Make sure the log file contains monitor mode output with:"); console.error(" - [MONITOR] Claude Code → Anthropic API Request"); console.error(" - [MONITOR] Anthropic API → Claude Code Response"); process.exit(1); } const { request, response } = parsed; console.log(`✅ Parsed request with ${response.length} events`); // Infer fixture name from filename if not provided if (!fixtureName) { const basename = logFile.split("/").pop()?.replace(".log", "") || "fixture"; fixtureName = basename.replace(/^test_\d+_/, "").replace(/-/g, "_"); } // Infer category if not provided if (!category) { category = inferCategory(response); } // Build assertions const assertions = buildAssertions(response); // Generate description if not provided if (!description) { const toolNames = assertions.contentBlocks .filter(b => b.type === "tool_use" && b.name) .map(b => b.name) .join(", "); if (toolNames) { description = `${category} scenario using: ${toolNames}`; } else { description = `${category} scenario`; } } // Build fixture const fixture: Fixture = { name: fixtureName, description, category, captured_at: new Date().toISOString(), request: { headers: { "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", "content-type": "application/json", }, body: normalizeValue("request", request), }, response: { type: "streaming", events: response, }, assertions, notes: `Captured from monitor mode: ${logFile}`, }; // Determine output path if not provided if (!outputPath) { outputPath = join(process.cwd(), "tests", "fixtures", `${fixtureName}.json`); } // Write fixture console.log(`💾 Writing fixture to: ${outputPath}`); writeFileSync(outputPath, JSON.stringify(fixture, null, 2)); console.log(` ✅ Fixture created successfully! Summary: Name: ${fixture.name} Category: ${fixture.category} Description: ${fixture.description} Events: ${response.length} Blocks: ${assertions.contentBlocks.length} Stop Reason: ${assertions.stopReason} Content Blocks: ${assertions.contentBlocks.map(b => ` [${b.index}] ${b.type}${b.name ? ` (${b.name})` : ""}`).join("\n")} Event Sequence: ${assertions.eventSequence.slice(0, 10).join(" → ")}${assertions.eventSequence.length > 10 ? ` ... (${assertions.eventSequence.length} total)` : ""} Next steps: 1. Review the fixture: cat ${outputPath} 2. Run snapshot tests: bun test tests/snapshot.test.ts `); } main();