//! Integration tests for stdio transport //! //! These tests spawn the actual mocker binary and communicate via stdin/stdout //! to verify end-to-end behavior including the "help" message feature. use serde_json::{json, Value}; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; /// Path to the mocker binary (debug build) fn mocker_binary() -> std::path::PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); std::path::Path::new(manifest_dir) .parent() .unwrap() .parent() .unwrap() .join("target") .join("debug") .join(if cfg!(windows) { "dirigate.exe" } else { "dirigate" }) } /// Path to the basic fixture file fn fixture_path() -> std::path::PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); std::path::Path::new(manifest_dir) .join("examples") .join("basic.yaml") } /// Helper to send a JSON-RPC request and read the response /// Skips any session/update notifications and returns only the response matching the request ID async fn send_request( stdin: &mut tokio::process::ChildStdin, stdout: &mut BufReader, request: Value, ) -> anyhow::Result { // Send request let request_json = serde_json::to_string(&request)?; let request_id = request.get("id").cloned(); stdin.write_all(request_json.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.flush().await?; // Read response, skipping notifications loop { let mut line = String::new(); stdout.read_line(&mut line).await?; // Parse the message let msg: Value = match serde_json::from_str(line.trim()) { Ok(v) => v, Err(e) => { eprintln!("Failed to parse line: {}", line.trim()); eprintln!("Error: {}", e); return Err(e.into()); } }; // Skip notifications (they have "method" but no "id") if msg.get("method").is_some() && msg.get("id").is_none() { continue; } // Check if this is the response we're looking for if let Some(id) = request_id.as_ref() { if msg.get("id") == Some(id) { return Ok(msg); } } else { // No ID means we're looking for any response return Ok(msg); } } } #[tokio::test] async fn test_stdio_basic_flow() -> anyhow::Result<()> { let binary = mocker_binary(); if !binary.exists() { eprintln!( "Mocker binary not found at {:?}. Run 'cargo build' first.", binary ); return Ok(()); // Skip test if binary doesn't exist } let fixture = fixture_path(); assert!(fixture.exists(), "Fixture file not found: {:?}", fixture); // Spawn the mocker process let mut child = Command::new(&binary) .args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) // Suppress logs for cleaner test output .spawn()?; let mut stdin = child.stdin.take().expect("Failed to open stdin"); let stdout = child.stdout.take().expect("Failed to open stdout"); let mut stdout = BufReader::new(stdout); // Test 1: Initialize let init_request = json!({ "jsonrpc": "2.0", "method": "initialize", "id": 1, "params": { "protocolVersion": 1, "clientCapabilities": {} } }); let init_response = send_request(&mut stdin, &mut stdout, init_request).await?; assert_eq!(init_response["jsonrpc"], "2.0"); assert_eq!(init_response["id"], 1); assert!(init_response["result"]["protocolVersion"].is_number()); assert!(init_response["result"]["agentCapabilities"].is_object()); // Test 2: Create session let session_request = json!({ "jsonrpc": "2.0", "method": "session/new", "id": 2, "params": { "cwd": ".", "mcpServers": [] } }); let session_response = send_request(&mut stdin, &mut stdout, session_request).await?; assert_eq!(session_response["jsonrpc"], "2.0"); assert_eq!(session_response["id"], 2); let session_id = session_response["result"]["sessionId"] .as_str() .expect("sessionId should be a string"); assert!(!session_id.is_empty()); // Test 3: Send a prompt let prompt_request = json!({ "jsonrpc": "2.0", "method": "session/prompt", "id": 3, "params": { "sessionId": session_id, "prompt": [ { "type": "text", "text": "hello world" } ] } }); let prompt_response = send_request(&mut stdin, &mut stdout, prompt_request).await?; assert_eq!(prompt_response["jsonrpc"], "2.0"); assert_eq!(prompt_response["id"], 3); assert!(prompt_response["result"]["stopReason"].is_string()); // Clean up child.kill().await?; Ok(()) } #[tokio::test] async fn test_stdio_help_message() -> anyhow::Result<()> { let binary = mocker_binary(); if !binary.exists() { eprintln!( "Mocker binary not found at {:?}. Run 'cargo build' first.", binary ); return Ok(()); // Skip test if binary doesn't exist } let fixture = fixture_path(); assert!(fixture.exists(), "Fixture file not found: {:?}", fixture); // Spawn the mocker process with stderr redirected to a temp file so we can read it let stderr_path = std::env::temp_dir().join("mocker_stderr.log"); let stderr_file = std::fs::File::create(&stderr_path)?; let mut child = Command::new(&binary) .args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(stderr_file) // Write to file .spawn()?; let mut stdin = child.stdin.take().expect("Failed to open stdin"); let stdout = child.stdout.take().expect("Failed to open stdout"); let mut stdout = BufReader::new(stdout); // Initialize let init_response = send_request( &mut stdin, &mut stdout, json!({ "jsonrpc": "2.0", "method": "initialize", "id": 1, "params": { "protocolVersion": 1, "clientCapabilities": {} } }), ) .await?; assert!(init_response["result"].is_object()); // Create session let session_response = send_request( &mut stdin, &mut stdout, json!({ "jsonrpc": "2.0", "method": "session/new", "id": 2, "params": { "cwd": ".", "mcpServers": [] } }), ) .await?; let session_id = session_response["result"]["sessionId"] .as_str() .expect("sessionId should be a string") .to_string(); // Store as owned string eprintln!("Created session: {}", session_id); // Send "help" message - now we have the session ID from THIS process let help_request = json!({ "jsonrpc": "2.0", "method": "session/prompt", "id": 3, "params": { "sessionId": session_id, "prompt": [ { "type": "text", "text": "help" } ] } }); // Write the request let request_json = serde_json::to_string(&help_request)?; eprintln!("Sending help request: {}", request_json); stdin.write_all(request_json.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.flush().await?; eprintln!("Request sent and flushed"); // Read the response AND the session/update notifications (streaming mode) let mut help_content = String::new(); let mut response_received = false; let mut notifications_count = 0; // With streaming enabled, we'll receive multiple session/update notifications // and then finally the response with stopReason // Read up to 200 lines to capture all chunks (with 10 second total timeout) let start = std::time::Instant::now(); while start.elapsed() < std::time::Duration::from_secs(10) { let mut line = String::new(); match tokio::time::timeout( std::time::Duration::from_millis(100), stdout.read_line(&mut line), ) .await { Ok(Ok(0)) => { eprintln!("Got EOF"); break; } Ok(Ok(_)) => { eprintln!("Received line: {}", line.trim()); let msg: Value = match serde_json::from_str(line.trim()) { Ok(v) => v, Err(e) => { eprintln!("Failed to parse JSON: {}", e); continue; } }; // Check if this is the response if msg.get("id") == Some(&json!(3)) { eprintln!("Got response!"); assert_eq!(msg["jsonrpc"], "2.0"); assert!(msg["result"]["stopReason"].is_string()); response_received = true; break; // Response is the last message } // Check if this is a session/update notification (streaming chunk) if msg.get("method") == Some(&json!("session/update")) { eprintln!("Got session/update notification #{}", notifications_count + 1); notifications_count += 1; let params = &msg["params"]; if let Some(update) = params.get("update") { if let Some(content) = update.get("content") { if let Some(text) = content.get("text") { help_content.push_str(text.as_str().unwrap_or("")); } } } } } Err(_) => { // Timeout - if we have content and response, we're done if response_received { break; } // Otherwise keep trying } Ok(Err(_)) => break, // Read error } } assert!( response_received, "Should receive response to help request (got {} notifications)", notifications_count ); assert!( !help_content.is_empty(), "Should receive help content via notifications" ); // Note: streaming may break words across chunks, so check for partial matches assert!( help_content.contains("ACP Mocker") || help_content.contains("ACPMocker"), "Help content should contain 'ACP Mocker'. Got: {}", help_content ); assert!( help_content.contains("Diagnostics"), "Help content should contain 'Diagnostics'" ); assert!( help_content.contains("Configuration") || help_content.contains("CurrentConfiguration"), "Help content should contain configuration section" ); assert!( help_content.contains("Available") && help_content.contains("Methods"), "Help content should contain methods list" ); // Clean up child.kill().await?; // Print stderr for debugging eprintln!("\n=== Mocker stderr log ==="); if let Ok(log_content) = std::fs::read_to_string(&stderr_path) { eprintln!("{}", log_content); } eprintln!("=== End stderr log ===\n"); Ok(()) } #[tokio::test] async fn test_stdio_session_not_found() -> anyhow::Result<()> { let binary = mocker_binary(); if !binary.exists() { return Ok(()); } let fixture = fixture_path(); assert!(fixture.exists()); let mut child = Command::new(&binary) .args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn()?; let mut stdin = child.stdin.take().unwrap(); let stdout = child.stdout.take().unwrap(); let mut stdout = BufReader::new(stdout); // Initialize send_request( &mut stdin, &mut stdout, json!({ "jsonrpc": "2.0", "method": "initialize", "id": 1, "params": { "protocolVersion": 1, "clientCapabilities": {} } }), ) .await?; // Try to use a non-existent session let error_response = send_request( &mut stdin, &mut stdout, json!({ "jsonrpc": "2.0", "method": "session/prompt", "id": 2, "params": { "sessionId": "non-existent-session-id", "prompt": [ { "type": "text", "text": "hello" } ] } }), ) .await?; // Should get an error response assert_eq!(error_response["jsonrpc"], "2.0"); assert_eq!(error_response["id"], 2); assert!(error_response["error"].is_object()); assert!(error_response["error"]["message"] .as_str() .unwrap() .contains("Session not found")); child.kill().await?; Ok(()) }